diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d030b92..8b541302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,9 +126,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.110021.jar - build/libs/OreSpawn-4.0.16.110021-sources.jar - build/libs/OreSpawn-4.0.16.110021-javadoc.jar + build/libs/OreSpawn-4.1.0.110021.jar + build/libs/OreSpawn-4.1.0.110021-sources.jar + build/libs/OreSpawn-4.1.0.110021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 238baa2d..981ddf3f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,108 @@ +Version 4.1.0.110021 + +* Clarify dimension-wide material controls and replace light-equivalent custom + aquifer-fluid injection with an exact one-shot generation mask, preserving + surfaces, caves and later decorator fluids while keeping generation near the + native path. Differently lit fluids retain the compatible generator path. +* Replace the first-palette-only biome editor with a responsive biome directory + that reports every loaded, referenced and missing biome once, together with + palette ownership, effective order, placement count and surface conflicts. + Its dimension selector filters routine entries through Forge biome domains + while retaining explicit placement, replacement and missing-provider rules. +* Add exact one-to-one biome replacements for newly generated terrain through a + reserved terminal palette which always runs after ordinary provider/profile + palettes. Replacement chains flatten, cycles are rejected and missing targets + remain dormant until their owning mod returns. Valid unmanaged targets no + longer produce a misleading warning or duplicate new-terrain notice. +* Expose palette enablement, placement weights, climate and dependency limits, + surfaces, namespace scope, region size, coverage, fallback weight, dimension + materials and geome influences without adding arbitrary weighted biome output. +* Add confirmed biome, palette, dimension and all-biome resets. Active providers + return to their shipped defaults, user-only palettes are removed and entries + owned by missing providers are preserved; all changes retain Done/Cancel + editor-session semantics and never rewrite existing chunks. +* Add Ore Sources policies that group equivalent configured outputs by material + and dimension while keeping ordinary and custom placement channels independent. +* Consolidate only reviewed high-confidence MMD conflicts for new worlds; + upgraded worlds retain their previous separate placement and output behaviour. +* Select one weighted output for an entire ordinary vein or custom deposit body + using stable world/deposit identity, including safe missing-mod restoration. +* Replace the technical Ore Sources list/detail flow with compact independently + scrolling group and output lists that remain visible together at compact + resolutions. The default view hides harmless single-source entries behind + a compact Show All control; red groups need attention, yellow groups have a saved rule and + green entries need no action. Each group has a cog for its name and aliases. +* Rename the old Advanced channel controls to Placement Rules and explain their + separate ownership of frequency, shape, depth and host restrictions. Single + managed sources are read-only, multiple sources are selectable and external + generators remain excluded. +* Add Keep Original, Balanced, Single and Custom output modes. Reviewed fresh + MMD conflicts balance every eligible output through one placement budget; + inactive provider definitions remain selectable outputs without contributing + another placement rule. Returning to Keep Original restores the output and + placement selections from when the editor was opened. An explicit Accept + action keeps that policy unchanged while clearing its pending review state; + the main editor's Done action remains the persistence boundary. +* Keep tooltips inside the available side of the screen and move Placement + Rules guidance into a compact help tooltip, with an explicit fixed, + selectable, missing or Keep Original status instead of an unexplained + disabled control. Keep the group-name label inline, expand the alias list by + two rows and reduce the Placement Rules list by one row at compact sizes. +* Ship curated Sulfur/Sulphur and Aluminum/Aluminium groups, keep Niter and + Saltpeter distinct, deduplicate identical registry states, and allow confirmed + alias reassignment plus stable custom groups. Group edits are saved to the + world and future-world defaults only when the main editor's Done is accepted. +* Keep unrelated ambiguous Ore Dictionary alias families in separate stable + review groups. Legacy shared Review required data is rediscovered into those + groups, so Adamantine aliases can no longer appear in Mercury settings. +* Ignore missing historical external-candidate records when the same registry + state is now supplied by a loaded managed provider, preventing resolved + groups from remaining falsely red while retaining the dormant record. +* Keep Group Settings validation visible on a dedicated bottom row, with the + Done action in the lower-right corner and full clipped messages available by + tooltip during confirmed Ore Dictionary alias reassignment. +* Prevent Balanced, Single or Custom consolidation when a material group has + no active OreSpawn-managed placement rule, retaining the review action and + Keep Original behaviour instead of entering an unresolved red policy. +* Refresh Ore Sources immediately when Manage Vanilla Ores changes. Native + vanilla rules become usable placement sources while it is enabled; while it + is disabled, attempts to move their Ore Dictionary aliases are rejected with + guidance instead of creating a policy OreSpawn cannot enforce. +* Reconcile output candidates, placement choices and review state immediately + after every group edit. Keep Original now exposes every moved candidate, + Accept clears only the edited group's review, and placement rows report their + available choice count while remaining inactive until consolidation. +* Restrict Delete to empty custom groups. Populated custom groups use a + confirmed Dissolve Group action that restores every alias to its inferred or + curated group; discovered groups remain non-deletable facts. +* Keep reassigned source policies dormant when a saved custom group is reopened, + so their former inferred groups do not reappear as false conflicts. Add a + confirmed Reset All action that restores built-in and inferred Ore Sources + defaults inside the pending editor session without rewriting existing chunks. +* Add provider schema 5 material and placement-channel declarations, global + schema 8 and world-profile schema 7 while retaining older readable schemas. +* Add an extended, binary-compatible ore generation context exposing stable + world seed, dimension and current-chunk identity to compiled pattern add-ons. +* Expose the active geology sampler to patterns when the generated dimension + has OreSpawn geology, including identical identity during supported retrogen. +* Replace per-add-on world-settings buttons with one fixed Mods directory that + lists loaded native OS4, legacy OS1-OS3 and UI-only integrations with their + mod version, provider lineage, revision and status. +* Add a packaged cog action for add-on-owned configuration screens, including + pagination, disabled-state explanations, safe failure handling and a return + path through the Mods directory. +* Preserve the original three-argument client registration descriptor while + adding one-screen-per-mod `registerConfigScreen` registration. +* Add optional per-resource background-generation scaling for controller + patterns, including deterministic vanilla gating and lowest-controller wins. +* Let structural providers keep newly introduced entries out of existing world + profiles while retaining the historical merge behavior by default. +* Guarantee geology column sampling does not load or generate neighbouring + chunks. +* Preserve API major 1, provider schemas 1-4, existing pattern binaries, + migration behaviour and generated chunks while advancing active schemas to + provider 5, global 8 and world profile 7. + Version 4.0.16.110021 * Adopt the shared 4.0.16 release identity. Forge 1.10 has neither diff --git a/README.md b/README.md index c4419edb..f8ee7f3d 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,13 @@ It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome palettes and world materials, flat bedrock, and bounded ore retrogen. -This branch builds target-qualified version `4.0.16.110021`: the OreSpawn 4.0.16 -feature set for Minecraft 1.10.2 and Forge. See the +This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0 +feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity, +non-loading geology sampling, per-resource background control, existing-world +provider merge policy, an Ore Sources arbiter for equivalent provider ores and +a biome directory with exact new-terrain replacement overrides, plus a client +world-settings extension point for add-ons such as Realistic Deposits +while retaining API major 1. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. Its deprecated compatibility layer imports OreSpawn 1 and OreSpawn 3 @@ -47,7 +52,61 @@ TerraBlender. When a provider exposes world settings, use **OreSpawn...** on the Create World screen. **Recommended Defaults** restores the settings supplied by the -installed mods and pack. The in-game **Help & Guide** explains the controls. +installed mods and pack. **Mods** opens a directory of every loaded mod that +OreSpawn recognizes through a current provider, legacy compatibility path, or +client configuration hook. It shows each mod's version, integration lineage, +provider revision and status; its cog opens an add-on-owned configuration +screen when one is available. The in-game **Help & Guide** explains the other +controls. + +On the ORES tab, **Ore Sources...** opens one material-group editor. Compact, +independently scrolling lists keep relevant material groups on the left and the +selected group's deduplicated ore blocks on the right. Harmless one-alias, +one-output entries are hidden by default and available through **Show All**. +Red groups need attention, yellow groups have a saved rule and green entries +need no action. Each group's cog opens its friendly name, exact Ore Dictionary +aliases and clearly labelled Placement Rules. Outputs choose the ore block; +empty custom groups can be deleted, while populated custom groups use a +confirmed **Dissolve Group** action that returns every alias to its inferred or +curated group. Automatically discovered groups are factual and cannot be +deleted. Placement Rules choose the frequency, +shape, depth and host restrictions for each managed channel. New worlds balance +all eligible outputs in reviewed MMD conflicts while running one placement +budget, so sulfur/sulphur providers do not multiply abundance. **Single** +chooses one output, **Custom** allows a weighted subset, and **Keep Original** +preserves every active rule and restores the selections that were present when +the editor was opened. A red Keep Original group exposes **Accept** so its +unchanged policy can be marked reviewed; this remains pending until the main +editor's **Done** is used. **Reset All** requires confirmation, removes custom +grouping and output choices from the pending edit, then rebuilds the built-in +and inferred groups from the currently loaded ores. It also remains pending +until the main editor's **Done** is used, while Cancel discards it. A Placement Rules row reports how many managed +sources are available, can cycle among them only in a consolidated mode, and +its help explains that the choice is inactive under Keep Original. +Existing worlds start in +Keep Original until their owner explicitly changes a group. Third-party generators remain outside +OreSpawn's control and are clearly reported rather than disabled. +Native vanilla outputs become controllable placement sources only while +**Manage Vanilla Ores** is enabled. Ore Sources updates immediately when that +option changes, and Group Settings prevents moving a vanilla ore alias while +management is off, with a prompt to enable it first. + +**Biomes** opens a directory rather than editing only the first palette. It +shows every provider/profile palette in its stored order, the effective surface +rule, owner, climate constraints and geome influences. Dimension materials sit +beside the dimension selector because they apply to every biome in that +dimension's newly generated terrain, not to the selected biome. Light-equivalent +aquifer fluids use a provenance mask and fast pre-decoration substitution; +unusual fluids with different lighting retain a compatible, potentially slower +generator path. +Routine unmanaged biomes are hidden behind **Show All**, which remains filtered +to the selected dimension using Forge's Nether/End biome types and explicit +provider placement declarations. A loaded biome may be +left unchanged or replaced exactly in newly generated terrain; missing targets +remain dormant and resume if their mod returns. The final user replacement +layer always runs after ordinary palettes, while existing chunks are never +rewritten. Provider, palette, dimension and all-biome resets remain pending +until the main editor's **Done** action; **Cancel** discards them. Important files: @@ -128,7 +187,9 @@ underwater, immediate filler, and ceiling-underside behavior. It also proves later vegetation, structures, and block entities survive, validates provider-rock vanilla springs and an external ore-pattern registration, then reopens and checks the exact saved world. The fixture is -not included in OreSpawn's published jars. +not included in OreSpawn's published jars. It also exercises multiple palettes +in one dimension, an unmanaged loaded biome, exact terminal replacement in new +chunks, unchanged old chunks, and fresh/reload profile and surface parity. Import or refresh this ForgeGradle 7 project through Eclipse Buildship, then run `genEclipseRuns`; do not run the removed ForgeGradle 2 diff --git a/build.gradle b/build.gradle index 3bf50c08..7721fde1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,6 @@ -import groovy.json.JsonSlurper -import groovy.xml.XmlSlurper -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.jar.Manifest -import java.util.zip.ZipFile import org.apache.tools.ant.filters.FixCrLfFilter + plugins { id 'java' id 'eclipse' @@ -18,8 +13,10 @@ plugins { group = project.mod_group version = project.mod_version -base.archivesName = 'OreSpawn' +base.archivesName = project.mod_artifact +// The final version component names the Minecraft target and loader. Reject a +// mismatch here before a jar can accidentally be labelled for another game. def versionParts = project.mod_version.toString().tokenize('.') if (versionParts.size() != 4 || !versionParts.every { it ==~ /\d+/ }) { throw new GradleException("mod_version must use Major.Minor.Bug.Target numeric form: ${project.mod_version}") @@ -48,6 +45,7 @@ def expectedMavenGroup = 'zone.moddev.mc.orespawn' def expectedMavenArtifact = 'OreSpawn' def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" +// Gradle runs on a modern JDK, but Forge 1.10 consumers need Java 8 classes. java { toolchain.languageVersion = JavaLanguageVersion.of(8) withSourcesJar() @@ -85,6 +83,8 @@ tasks.withType(AbstractArchiveTask).configureEach { reproducibleFileOrder = true } +// Canonical text and zip ordering make release jars comparable by hash on +// Windows and Linux, including the guides bundled into the mod. def archiveTextSuffixes = [ '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', '.mcmeta', '.md', '.properties', '.txt', '.xml' @@ -162,50 +162,8 @@ repositories { } } -def legacyFixtureRoot = file("${rootDir}/ci-fixtures") -def legacyFixtureArtifacts = new File(legacyFixtureRoot, 'artifacts') -def legacyFixtureWorlds = new File(legacyFixtureRoot, 'worlds') -def legacyMineralogyOracleJar = new File( - legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') -def legacyFixtureHashes = [ - 'artifacts/OreSpawn_1.10.2-1.1.0.jar': - '91345E4B825AA06E05F470066F467AABA0C7A2804A8DBCA4D3CD1905ED53607C', - 'artifacts/OreSpawn-1.10.2-3.2.2.104.jar': - 'D964148C20175E16CCEFAB02AE43A181A50DC08A1D636A77E59CFE8C8EA8D41A', - 'artifacts/BaseMetals_1.10.2-2.4.0.11.jar': - 'F919CEF794C73F02758BBD967346DBA0B53F6383153C7E29BD79F211CDA8C319', - 'artifacts/Mineralogy-1.10.2-3.3.8.26.jar': - '88A6237C9A0E2C8891718B68C373E741C78B8494F5E68D8093CA9339F3BC4D87', - 'worlds/sylvester-era-trio-source-v2.zip': - '11FC2FA44C43520AA9B21C29B43203EBFE0A84773D2BDCBB4F3C48C490559B07' -] - -task verifyLegacyFixtures { - group = 'verification' - description = 'Verifies the sealed ABI and migration corpus used only by isolated tests.' - inputs.files legacyFixtureHashes.keySet().collect { new File(legacyFixtureRoot, it) } - doLast { - legacyFixtureHashes.each { String relativePath, String expectedHash -> - File fixture = new File(legacyFixtureRoot, relativePath) - if (!fixture.isFile()) { - throw new GradleException("Missing sealed legacy fixture: ${fixture}") - } - MessageDigest digest = MessageDigest.getInstance('SHA-256') - fixture.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) - } - } - String actualHash = digest.digest().encodeHex().toString().toUpperCase() - if (actualHash != expectedHash) { - throw new GradleException("Legacy fixture hash mismatch for ${relativePath}: " - + "expected ${expectedHash}, found ${actualHash}") - } - } - } -} - +// Keep ordinary dependencies here. The sealed Mineralogy oracle belongs to +// the verification script, where its test-only scope is checked. dependencies { implementation minecraft.dependency( "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") @@ -215,580 +173,6 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' - // Buildship must see this as a test-scoped dependency so Eclipse can - // compile the parity test. The Eclipse launch guard excludes all - // test-scoped dependencies from ordinary client/server runs. - testImplementation files(legacyMineralogyOracleJar) -} - -tasks.named('compileTestJava', JavaCompile) { - dependsOn verifyLegacyFixtures -} -tasks.named('test', Test) { - dependsOn verifyLegacyFixtures -} - -tasks.register('verifyLegacyOracleIsolation') { - group = 'verification' - description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' - dependsOn verifyLegacyFixtures - - doLast { - File oracle = legacyMineralogyOracleJar.canonicalFile - def canonicalFiles = { FileCollection classpath -> - classpath.files.collect { it.canonicalFile } as Set - } - Set testCompileFiles = canonicalFiles(sourceSets.test.compileClasspath) - Set testRuntimeFiles = canonicalFiles(sourceSets.test.runtimeClasspath) - if (!testCompileFiles.contains(oracle) || !testRuntimeFiles.contains(oracle)) { - throw new GradleException('The sealed Mineralogy oracle is missing from the test classpath model') - } - - Map productionClasspaths = [ - mainCompile: sourceSets.main.compileClasspath, - mainRuntime: sourceSets.main.runtimeClasspath - ] - productionClasspaths.each { String name, FileCollection classpath -> - if (canonicalFiles(classpath).contains(oracle)) { - throw new GradleException("The sealed Mineralogy oracle leaked into ${name}") - } - } - } -} -tasks.named('check') { - dependsOn tasks.named('verifyLegacyOracleIsolation') -} - -def java8Launcher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) -} -def configureFromForgeRun = { JavaExec process, String runTaskName -> - process.actions.clear() - process.dependsOn { - JavaExec run = tasks.getByName(runTaskName) as JavaExec - run.taskDependencies.getDependencies(run) - } - process.doLast { - JavaExec run = tasks.getByName(runTaskName) as JavaExec - String runConfigurationName = runTaskName.substring(3).uncapitalize() - def runOptions = minecraft.runs.getByName(runConfigurationName) - File originalConfiguredWorkingDir = runOptions.workingDir.get().asFile - File originalWorkingDir = run.workingDir - List originalArgs = new ArrayList<>(run.args) - List originalJvmArgs = new ArrayList<>(run.jvmArgs) - Map originalSystemProperties = new LinkedHashMap<>(run.systemProperties) - Map originalEnvironment = new LinkedHashMap<>(run.environment) - FileCollection originalClasspath = files(run.classpath.files) - String originalMinHeap = run.minHeapSize - String originalMaxHeap = run.maxHeapSize - String originalMainClass = run.mainClass.orNull - try { - File forgeConfig = new File(process.workingDir, 'config/forge.cfg') - if (!forgeConfig.isFile()) { - forgeConfig.parentFile.mkdirs() - forgeConfig.setText('''general { - B:disableVersionCheck=true -} - -version_checking { - B:Global=false -} -''', 'UTF-8') - } - runOptions.workingDir.set(process.workingDir) - run.workingDir(process.workingDir) - run.args(process.args) - run.jvmArgs(process.jvmArgs) - run.systemProperties(process.systemProperties) - run.environment(process.environment) - Set splitMainOutputs = [ - tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, - sourceSets.main.output.resourcesDir - ] as Set - run.setClasspath(files(forge10DevelopmentOutput, run.classpath.filter { - !splitMainOutputs.contains(it) - })) - run.exec() - } finally { - runOptions.workingDir.set(originalConfiguredWorkingDir) - run.workingDir(originalWorkingDir) - run.setArgs(originalArgs) - run.setJvmArgs(originalJvmArgs) - run.setSystemProperties(originalSystemProperties) - run.setEnvironment(originalEnvironment) - run.setClasspath(originalClasspath) - run.minHeapSize = originalMinHeap - run.maxHeapSize = originalMaxHeap - if (originalMainClass == null) { - run.mainClass.unset() - } else { - run.mainClass.set(originalMainClass) - } - } - } -} - -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 - 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 createSurfaceProcess = { String phase, Object dependency -> - task("surfaceIntegration${phase}Process", type: JavaExec, dependsOn: dependency) { - group = 'verification' - workingDir surfaceIntegrationRunDirectory - systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase() - configureFromForgeRun(delegate, 'runServer') - } -} - -def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) -surfaceIntegrationFreshProcess.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}") - } -} -def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) - -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}") - } - if (!(result.getProperty('dynamic_fluid_placements') ?: '0').isInteger() - || result.getProperty('dynamic_fluid_placements').toInteger() <= 0) { - throw new GradleException("Dynamic fluid-deposit probe did not place any blocks: ${marker}") - } - [ - 'end.raw_natural_sources': '108', - 'end.structure_natural_sources': '108', - 'end.vegetation_natural_sources': '108', - 'end.cave_pockets': '54', - 'end.underwater_pockets': '54', - 'end.raw_bedrock': '9', - 'end.raw_block_entities': '9', - 'end.exposed_snow_converted': '9', - 'end.surface_ice_converted': '9', - 'end.buried_snow_preserved': '9', - 'end.buried_ice_preserved': '9', - 'nether.unconfigured_snow_preserved': '9', - 'nether.unconfigured_ice_preserved': '9' - ].each { key, expected -> - if (result.getProperty(key) != expected) { - throw new GradleException("Unexpected surface integration result ${key}: " - + "expected ${expected}, found ${result.getProperty(key)}") - } - } - File latest = new File(surfaceIntegrationRunDirectory, 'logs/latest.log') - if (!latest.isFile() || latest.text.contains('Tried to assign a mutable BlockPos')) { - throw new GradleException("Surface integration leaked a mutable position into tick data: ${latest}") - } - 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 - -def migrationIntegrationClasses = file("${buildDir}/migration-integration-fixture/classes") -task compileMigrationIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/migrationIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = migrationIntegrationClasses - options.encoding = 'UTF-8' -} - -task migrationIntegrationTestModJar(type: Jar, dependsOn: compileMigrationIntegrationTestMod) { - archiveFileName = 'migrationprobe.jar' - destinationDirectory = file("${buildDir}/migration-integration-fixture") - from migrationIntegrationClasses - from 'src/migrationIntegrationTest/resources' -} - -if (project.hasProperty('migrationRunDir')) { - def migrationRunDirectory = file(project.property('migrationRunDir')) - task prepareMigrationIntegrationRun(dependsOn: migrationIntegrationTestModJar) { - doLast { - copy { from migrationIntegrationTestModJar.archiveFile; into new File(migrationRunDirectory, 'mods') } - new File(migrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - File serverProperties = new File(migrationRunDirectory, 'server.properties') - Properties values = new Properties() - if (serverProperties.isFile()) serverProperties.withInputStream { values.load(it) } - values.setProperty('spawn-animals', 'false') - values.setProperty('spawn-monsters', 'false') - serverProperties.withOutputStream { values.store(it, 'OreSpawn migration qualification') } - } - } - task migrationIntegrationProcess(type: JavaExec, dependsOn: prepareMigrationIntegrationRun) { - group = 'verification' - workingDir migrationRunDirectory - systemProperty 'orespawn.migrationFamily', project.findProperty('migrationFamily') ?: 'unspecified' - systemProperty 'orespawn.migrationPhase', project.findProperty('migrationPhase') ?: 'fresh' - configureFromForgeRun(delegate, 'runServer') - doLast { - String phase = project.findProperty('migrationPhase') ?: 'fresh' - File marker = new File(migrationRunDirectory, 'world/orespawn4-migration-probe.properties') - if (!marker.isFile()) throw new GradleException("Migration probe did not produce ${marker}") - Properties values = new Properties(); marker.withInputStream { values.load(it) } - if (values.getProperty("${phase}_complete") != 'true') { - throw new GradleException("Migration ${phase} phase did not complete: ${marker}") - } - File latest = new File(migrationRunDirectory, 'logs/latest.log') - if (latest.isFile() && (latest.text.contains('Migration audit failed') - || latest.text.contains('Encountered an unexpected exception'))) { - throw new GradleException("Migration ${phase} phase logged a server failure: ${latest}") - } - } - } -} - -def legacyMineralogyMigrationRunDirectory = file("${buildDir}/legacy-mineralogy-migration-run") -def legacyMineralogyMigrationArchive = new File(legacyFixtureWorlds, - 'sylvester-era-trio-source-v2.zip') - -task prepareLegacyMineralogyMigrationRun(dependsOn: [migrationIntegrationTestModJar, - verifyLegacyFixtures]) { - doLast { - delete legacyMineralogyMigrationRunDirectory - copy { from zipTree(legacyMineralogyMigrationArchive); into legacyMineralogyMigrationRunDirectory } - File mods = new File(legacyMineralogyMigrationRunDirectory, 'mods') - mods.mkdirs() - copy { - from new File(legacyFixtureArtifacts, 'BaseMetals_1.10.2-2.4.0.11.jar') - from new File(legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') - from migrationIntegrationTestModJar.archiveFile - into mods - } - new File(legacyMineralogyMigrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -def configureLegacyMineralogyMigrationProcess = { JavaExec process, String phase -> - process.group = 'verification' - process.workingDir legacyMineralogyMigrationRunDirectory - process.systemProperty 'orespawn.migrationFamily', 'legacy-mineralogy-cyano' - process.systemProperty 'orespawn.migrationPhase', phase - configureFromForgeRun(process, 'runServer') - process.doLast { - File marker = new File(legacyMineralogyMigrationRunDirectory, - 'world/orespawn4-migration-probe.properties') - if (!marker.isFile()) throw new GradleException("Legacy Mineralogy migration did not produce ${marker}") - Properties values = new Properties(); marker.withInputStream { values.load(it) } - if (values.getProperty("${phase}_complete") != 'true') { - throw new GradleException("Legacy Mineralogy migration ${phase} phase did not complete") - } - } -} - -task legacyMineralogyMigrationFresh(type: JavaExec, dependsOn: prepareLegacyMineralogyMigrationRun) -configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationFresh, 'fresh') - -task legacyMineralogyMigrationReload(type: JavaExec, dependsOn: legacyMineralogyMigrationFresh) -configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationReload, 'reload') - -task legacyMineralogyMigrationTest(dependsOn: legacyMineralogyMigrationReload) { - group = 'verification' - description = 'Proves an existing Mineralogy 3 world is pinned to Cyano settings across reload.' - doLast { - File marker = new File(legacyMineralogyMigrationRunDirectory, - 'world/orespawn4-migration-probe.properties') - Properties values = new Properties(); marker.withInputStream { values.load(it) } - ['fresh_complete', 'reload_complete', 'legacy_mineralogy_config_sha256', - 'legacy_mineralogy_world_profile_sha256'].each { key -> - if (!values.getProperty(key)) throw new GradleException("Missing legacy Mineralogy evidence ${key}") - } - logger.lifecycle('Existing Mineralogy 3 world retained Cyano profile and exact config/profile hashes across reload') - } -} - -check.dependsOn legacyMineralogyMigrationTest - -def os1AbiFixtureClasses = file("${buildDir}/legacy-abi/os1/classes") -def os3AbiFixtureClasses = file("${buildDir}/legacy-abi/os3/classes") - -task compileOs1AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { - source fileTree('src/os1AbiFixture/java') - classpath = files(sourceSets.main.compileClasspath, - new File(legacyFixtureArtifacts, 'OreSpawn_1.10.2-1.1.0.jar')) - destinationDirectory = os1AbiFixtureClasses - options.encoding = 'UTF-8' -} - -task os1AbiFixtureJar(type: Jar, dependsOn: compileOs1AbiFixture) { - archiveFileName = 'os1abiprobe.jar' - destinationDirectory = file("${buildDir}/legacy-abi/os1") - from os1AbiFixtureClasses - from 'src/os1AbiFixture/resources' -} - -task compileOs3AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { - source fileTree('src/os3AbiFixture/java') - classpath = files(sourceSets.main.compileClasspath, - new File(legacyFixtureArtifacts, 'OreSpawn-1.10.2-3.2.2.104.jar')) - destinationDirectory = os3AbiFixtureClasses - options.encoding = 'UTF-8' -} - -task os3AbiFixtureJar(type: Jar, dependsOn: compileOs3AbiFixture) { - archiveFileName = 'os3abiprobe.jar' - destinationDirectory = file("${buildDir}/legacy-abi/os3") - from os3AbiFixtureClasses - from 'src/os3AbiFixture/resources' -} - -def configureLegacyAbiProcess = { String generation, Task fixtureJar, String markerName -> - File runDirectory = file("${buildDir}/legacy-abi/${generation}/run") - Task prepare = tasks.create("prepare${generation.capitalize()}AbiRun", Copy) { - dependsOn fixtureJar - into new File(runDirectory, 'mods') - from fixtureJar.archiveFile - doFirst { project.delete(runDirectory) } - doLast { - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - new File(runDirectory, 'server.properties').setText( - 'eula=true\nonline-mode=false\nlevel-name=world\nlevel-seed=zsjpxah\n' + - 'spawn-animals=false\nspawn-monsters=false\n', 'UTF-8') - } - } - tasks.create("${generation}AbiIntegrationProcess", JavaExec) { - group = 'verification' - dependsOn prepare - workingDir runDirectory - configureFromForgeRun(delegate, 'runServer') - doLast { - File marker = new File(runDirectory, "world/${markerName}") - if (!marker.isFile() || !marker.text.contains('registered=true')) { - throw new GradleException("${generation.toUpperCase()} binary ABI probe did not complete: ${marker}") - } - File latest = new File(runDirectory, 'logs/latest.log') - if (latest.isFile() && latest.text.contains('Encountered an unexpected exception')) { - throw new GradleException("${generation.toUpperCase()} binary ABI probe logged a server failure") - } - } - } -} - -configureLegacyAbiProcess('os1', os1AbiFixtureJar, 'os1-abi-probe.properties') -configureLegacyAbiProcess('os3', os3AbiFixtureJar, 'os3-abi-probe.properties') - -task legacyAbiIntegrationTest { - group = 'verification' - dependsOn os1AbiIntegrationProcess, os3AbiIntegrationProcess -} - -check.dependsOn legacyAbiIntegrationTest - -def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") -task compileClientIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/clientIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = clientIntegrationClasses - options.encoding = 'UTF-8' -} - -task clientIntegrationTestModJar(type: Jar, dependsOn: 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') -} - -tasks.register('preparePackagedClientProbe') { - group = 'verification' - description = 'Builds the reobfuscated client probe used only with a disposable packaged Forge runtime.' - dependsOn packagedClientProbeJar -} - -def clientIntegrationRunDirectory = file("${buildDir}/client-integration-run") -task prepareClientIntegrationTest(dependsOn: clientIntegrationTestModJar) { - doLast { - delete clientIntegrationRunDirectory - clientIntegrationRunDirectory.mkdirs() - copy { - from clientIntegrationTestModJar.archiveFile - into new File(clientIntegrationRunDirectory, 'mods') - } - new File(clientIntegrationRunDirectory, 'options.txt').setText( - 'fullscreen:false\nforceUnicodeFont:false\nguiScale:2\n' + - 'renderDistance:4\nshowSubtitles:false\n', 'UTF-8') - } -} - -task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegrationTest) { - group = 'verification' - workingDir clientIntegrationRunDirectory - systemProperty 'clientprobe.enabled', 'true' - configureFromForgeRun(delegate, 'runClient') - doLast { - File marker = new File(clientIntegrationRunDirectory, 'client-smoke-pass.properties') - if (!marker.isFile()) { - throw new GradleException("Client integration completion marker is missing: ${marker}") - } - Properties result = new Properties(); marker.withInputStream { result.load(it) } - if (result.getProperty('reload_rendered') != 'true' - || result.getProperty('world_settings_opened') != 'true' - || result.getProperty('long_editor_roundtrip') != 'true' - || Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) { - throw new GradleException("Client integration result is incomplete: ${marker}") - } - File latest = new File(clientIntegrationRunDirectory, 'logs/fml-client-latest.log') - if (latest.isFile() && (latest.text.contains('Encountered an unexpected exception') - || latest.text.contains('Reported exception thrown!'))) { - throw new GradleException("Client integration logged a runtime failure: ${latest}") - } - List allowedDevelopmentErrors = [ - 'The binary patch set is missing', - 'FML appears to be missing any signature data', - 'Unable to read a class file correctly', - 'problem reading the entry META-INF/versions/11/' - ] - List unaccountedErrors = latest.readLines('UTF-8').findAll { line -> - (line.contains('/ERROR]') || line.contains('/FATAL]')) - && !allowedDevelopmentErrors.any { line.contains(it) } - } - if (!unaccountedErrors.isEmpty()) { - throw new GradleException("Client integration logged unaccounted errors: ${unaccountedErrors}") - } - File crashDirectory = new File(clientIntegrationRunDirectory, 'crash-reports') - if (crashDirectory.isDirectory() && crashDirectory.listFiles().length > 0) { - throw new GradleException("Client integration produced crash reports: ${crashDirectory}") - } - } -} - -task syncForge12EclipseIntegrationLaunches { - group = 'ide' - doLast { - def writeGradleLaunch = { String fileName, String displayName, String arguments -> - File launch = file(fileName) - String escapedArguments = arguments - .replace('&', '&') - .replace('"', '"') - launch.setText(""" - - - - - - - - -""", 'UTF-8') - } - - writeGradleLaunch('OreSpawn_Surface_FreshReload.launch', - 'OreSpawn surface fresh/reload gate', - 'surfaceIntegrationTest --offline --no-daemon') - writeGradleLaunch('OreSpawn_Migration_Fresh.launch', - 'OreSpawn migration fresh gate', - 'migrationIntegrationProcess --offline --no-daemon ' + - '-PmigrationRunDir=build/eclipse-migration-run ' + - '-PmigrationFamily=eclipse-manual -PmigrationPhase=fresh') - writeGradleLaunch('OreSpawn_Migration_Reload.launch', - 'OreSpawn migration reload gate', - 'migrationIntegrationProcess --offline --no-daemon ' + - '-PmigrationRunDir=build/eclipse-migration-run ' + - '-PmigrationFamily=eclipse-manual -PmigrationPhase=reload') - writeGradleLaunch('OreSpawn_Client_FreshReload.launch', - 'OreSpawn client world and editor fresh/reload gate', - 'clientIntegrationProcess --offline --no-daemon') - } -} - -tasks.named('genEclipseRuns') { - finalizedBy syncForge12EclipseIntegrationLaunches -} - -if (project.hasProperty('benchmarkRunDir')) { - def benchmarkRunDirectory = file(project.property('benchmarkRunDir')) - task prepareWorldgenBenchmark { - doLast { - delete benchmarkRunDirectory - benchmarkRunDirectory.mkdirs() - new File(benchmarkRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - new File(benchmarkRunDirectory, 'server.properties').setText( - 'online-mode=false\nlevel-name=world\nlevel-type=default\n' + - "level-seed=${project.findProperty('benchmarkSeed') ?: '-4965128775892001975'}\n" + - 'generate-structures=false\nspawn-animals=false\nspawn-monsters=false\n' + - 'max-tick-time=-1\n', 'UTF-8') - } - } -task benchmarkIntegrationProcess(type: JavaExec, dependsOn: prepareWorldgenBenchmark) { - group = 'verification' - workingDir benchmarkRunDirectory - systemProperty 'orespawn.worldgenBenchmarkMode', project.findProperty('benchmarkMode') ?: 'sky' - systemProperty 'orespawn.worldgenBenchmarkRadius', project.findProperty('benchmarkRadius') ?: '4' - systemProperty 'orespawn.worldgenBenchmarkRepetitions', project.findProperty('benchmarkRepetitions') ?: '3' - systemProperty 'orespawn.worldgenBenchmarkCenterX', project.findProperty('benchmarkCenterX') ?: '256' - systemProperty 'orespawn.worldgenBenchmarkCenterZ', project.findProperty('benchmarkCenterZ') ?: '256' - systemProperty 'orespawn.worldgenBenchmarkCenterStep', project.findProperty('benchmarkCenterStep') ?: '64' - systemProperty 'orespawn.worldgenBenchmarkStopServer', 'true' - systemProperty 'orespawn.worldgenBenchmarkVanillaOres', - project.findProperty('benchmarkVanillaOres') ?: 'false' - systemProperty 'orespawn.worldgenBenchmarkOreAudit', - project.findProperty('benchmarkOreAudit') ?: 'false' - if (project.hasProperty('benchmarkBlockAudit')) { - systemProperty 'orespawn.worldgenBenchmarkBlockAudit', project.property('benchmarkBlockAudit') - } - configureFromForgeRun(delegate, 'runServer') - doLast { - File latest = new File(benchmarkRunDirectory, 'logs/fml-server-latest.log') - String mode = project.findProperty('benchmarkMode') ?: 'sky' - if (!latest.isFile() || !latest.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { - throw new GradleException("Missing ${mode} worldgen benchmark summary: ${latest}") - } - if (latest.text.contains('Encountered an unexpected exception') - || latest.text.contains('Tried to assign a mutable BlockPos')) { - throw new GradleException("Worldgen benchmark logged a runtime failure: ${latest}") - } - } - } } def trackedDocumentationFiles = { @@ -819,6 +203,8 @@ tasks.register('verifyDocumentationParity') { } } +// Bundle the player guides and resolve mcmod.info's version placeholders. +// The parity task checks every guide against its processed copy. tasks.named('processResources', ProcessResources) { filteringCharset = 'UTF-8' inputs.property('version', project.version) @@ -837,31 +223,31 @@ tasks.named('processResources', ProcessResources) { filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } -def prepareEclipseResources = tasks.register('prepareEclipseResources') { - group = 'ide' - description = 'Copies Gradle-processed production resources into Eclipse merged output.' - dependsOn tasks.named('processResources') - doLast { - // Eclipse owns bin/main, so copy into it without declaring the - // directory as a Gradle output or deleting compiled classes. - project.copy { - from(layout.buildDirectory.dir('resources/main')) - into(layout.projectDirectory.dir('bin/main')) - } - } -} - +// Scripts have their own local variables. Share only the project values they +// actually need; release identity remains checked in the audit tasks. +ext.oreSpawnBuild = [ + mavenGroup: expectedMavenGroup, + mavenArtifact: expectedMavenArtifact, + mavenCoordinate: expectedMavenCoordinate, + archiveTextSuffixes: archiveTextSuffixes, + developmentOutput: forge10DevelopmentOutput, + trackedDocumentationFiles: trackedDocumentationFiles, + documentationRelativePath: documentationRelativePath +].asImmutable() + +// The development jar is mapped; the release script creates the reobfuscated +// jar that a player installs. Keep these source and documentation jars stable. tasks.named('jar', Jar) { archiveClassifier = 'deobf' destinationDirectory = layout.buildDirectory.dir('libs-dev') manifest { attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', + 'Specification-Title' : base.archivesName.get(), + 'Specification-Vendor' : project.mod_vendor, 'Specification-Version' : '1', 'Implementation-Title' : base.archivesName.get(), 'Implementation-Version' : project.version, - 'Implementation-Vendor' : 'SkyBlade1978', + 'Implementation-Vendor' : project.mod_vendor, 'OreSpawn-API-Version' : '1', 'FMLAT' : 'accesstransformer.cfg', 'Maven-Artifact' : expectedMavenCoordinate, @@ -871,37 +257,6 @@ tasks.named('jar', Jar) { } } -def releaseJar = renamer.classes(tasks.named('jar', Jar)) { - map.from minecraft.dependency.toSrgFile - archiveClassifier = null - accessTransformers = true - output = layout.buildDirectory.file("libs/OreSpawn-${project.version}.jar") -} - -def apiJar = tasks.register('apiJar', Jar) { - dependsOn tasks.named('classes') - archiveClassifier = 'api' - destinationDirectory = layout.buildDirectory.dir('libs-dev') - from sourceSets.main.output - include 'zone/moddev/mc/orespawn/api/**' - include 'com/mcmoddev/orespawn/api/**' - include 'com/mojang/serialization/**' - include 'cyano/orespawn/**' - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-api', - 'Implementation-Version': project.version, - 'OreSpawn-API-Version' : '1' - ]) - } -} - -tasks.register('deobfJar') { - group = 'build' - description = 'Builds the local deobfuscated development jar under build/libs-dev.' - dependsOn tasks.named('jar') -} - tasks.named('sourcesJar', Jar) { filteringCharset = 'UTF-8' includeEmptyDirs = false @@ -929,583 +284,17 @@ tasks.named('javadocJar', Jar) { } } -['apiElements', 'runtimeElements'].each { configurationName -> - configurations.named(configurationName) { - artifacts.clear() - } - artifacts { - add(configurationName, releaseJar) - } -} - -tasks.named('assemble') { - dependsOn releaseJar - dependsOn apiJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -def expectedReleaseFiles = providers.provider { - def prefix = "${base.archivesName.get()}-${project.version}" - [ - "${prefix}.jar", - "${prefix}-sources.jar", - "${prefix}-javadoc.jar" - ] -} -def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' - - doLast { - if (project.mod_version != '4.0.16.110021' - || project.mod_group != expectedMavenGroup) { - throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") - } - if (project.minecraft_version != '1.10.2' - || project.forge_version != '12.18.3.2511' - || project.mapping_channel != 'stable' - || project.mapping_version != '29-1.10.2') { - throw new GradleException('Unexpected Minecraft, Forge, or mappings target') - } - if (project.loader_name != 'forge' || project.loader_code != '1' - || project.java_version != '8' || project.gradle_java_version != '17') { - throw new GradleException('Unexpected dispatcher target metadata') - } - if (project.group.toString() != expectedMavenGroup - || base.archivesName.get() != expectedMavenArtifact - || project.curseforge_project_id != '245586') { - throw new GradleException('Unexpected Maven or CurseForge publication identity') - } - - [ - 'src/main/java/zone/moddev/mc/orespawn/OreSpawn.java', - 'src/main/java/cyano/orespawn/OreSpawn.java', - 'src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', - 'README.md', 'CHANGELOG.txt' - ].each { path -> - if (!file(path).getText('UTF-8').contains('4.0.16.110021')) { - throw new GradleException("Authoritative release location does not contain 4.0.16.110021: ${path}") - } - } - if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[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 schema versions must remain 6/5') - } - def providerSchema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) - if (!(providerSchema.properties.schema_version.enum as List).contains(4)) { - throw new GradleException('Provider schema must continue to support schema version 4') - } - } -} - -tasks.register('verifyReleaseArtifacts') { - group = 'verification' - description = 'Audits the exact three distributable jars and their release-critical contents.' - dependsOn tasks.named('verifyReleaseConfiguration') - 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 } - List actual = jars.collect { it.name } - List expected = expectedReleaseFiles.get().sort() - if (actual != expected) { - throw new GradleException("Expected exactly ${expected}, found ${actual}") - } - - jars.each { candidate -> - if (candidate.length() == 0L) { - throw new GradleException("Empty release artifact: ${candidate.name}") - } - ZipFile candidateZip = new ZipFile(candidate) - try { - candidateZip.entries().findAll { entry -> - !entry.isDirectory() - && (archiveTextSuffixes.any { entry.name.endsWith(it) } - || entry.name.endsWith('/element-list') - || entry.name.endsWith('/package-list')) - }.each { entry -> - boolean containsCarriageReturn = candidateZip.getInputStream(entry).withCloseable { input -> - input.bytes.any { value -> value == 13 } - } - if (containsCarriageReturn) { - throw new GradleException( - "${candidate.name}!/${entry.name} does not use canonical LF line endings") - } - } - ['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/', - 'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/', - 'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe', - 'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden -> - if (candidateZip.entries().any { it.name.contains(forbidden) }) { - throw new GradleException( - "${candidate.name} contains forbidden entry matching ${forbidden}") - } - } - } finally { - candidateZip.close() - } - } - - File mainJar = new File(libs, expectedReleaseFiles.get()[0]) - ZipFile zip = new ZipFile(mainJar) - try { - List names = zip.entries().collect { it.name } - [ - 'mcmod.info', - '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', - 'META-INF/orespawn/docs/schemas/orespawn-global.schema.json', - 'META-INF/orespawn/docs/schemas/orespawn-world.schema.json', - 'AGENTS.md' - ].each { required -> - if (!names.contains(required)) { - throw new GradleException("Release jar is missing ${required}") - } - } - List trackedDocs = trackedDocumentationFiles() - if (trackedDocs.size() != 21) { - throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") - } - trackedDocs.each { File source -> - String relative = documentationRelativePath(source) - def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") - if (entry == null || !java.util.Arrays.equals(source.bytes, - zip.getInputStream(entry).withCloseable { it.bytes })) { - throw new GradleException("Packaged documentation differs for ${relative}") - } - } - - String metadata = zip.getInputStream(zip.getEntry('mcmod.info')) - .getText(StandardCharsets.UTF_8.name()) - def parsed = new JsonSlurper().parseText(metadata) - def mod = parsed instanceof List ? parsed.first() : parsed - if (mod.modid != 'orespawn' || mod.version != project.mod_version - || mod.mcversion != project.minecraft_version) { - throw new GradleException('Packaged mcmod.info version or target is incorrect') - } - - String packagedAccessTransformer = zip.getInputStream( - zip.getEntry('META-INF/accesstransformer.cfg')) - .getText(StandardCharsets.UTF_8.name()) - List packagedAccessTransformerRules = packagedAccessTransformer.readLines() - .collect { it.replaceFirst(/\s*#.*/, '').trim() } - .findAll { !it.isEmpty() } - List expectedRuntimeAccessTransformerRules = [ - 'public-f net.minecraft.world.WorldProvider field_76578_c', - 'public-f net.minecraft.world.gen.ChunkProviderOverworld field_186001_t' - ] - if (packagedAccessTransformerRules != expectedRuntimeAccessTransformerRules) { - throw new GradleException('Packaged access transformer was not remapped to the runtime SRG rules') - } - - 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 - || manifest.getValue('Timestamp') != null) { - throw new GradleException('Release manifest identity/API/FMLAT 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) != header.length) { - throw new GradleException("Cannot inspect bytecode header for ${entry.name}") - } - } - int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 52) { - throw new GradleException("${entry.name} uses Java class major ${major}, expected 52") - } - } - } finally { - zip.close() - } - - File sources = new File(libs, expectedReleaseFiles.get()[1]) - new ZipFile(sources).withCloseable { sourceZip -> - if (sourceZip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { - throw new GradleException('Sources jar is missing OreSpawn.java') - } - } - File javadocs = new File(libs, expectedReleaseFiles.get()[2]) - new ZipFile(javadocs).withCloseable { javadocZip -> - if (javadocZip.getEntry('index.html') == null) { - throw new GradleException('Javadoc jar is missing index.html') - } - } - } -} - -tasks.register('writeReleaseChecksums') { - group = 'verification' - description = 'Writes SHA-256 checksums for the audited release jars.' - dependsOn tasks.named('verifyReleaseArtifacts') - def outputFile = layout.buildDirectory.file('release/SHA256SUMS') - inputs.files(providers.provider { - expectedReleaseFiles.get().collect { name -> - layout.buildDirectory.file("libs/${name}").get().asFile - } - }) - 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' - description = 'Audits a previously built immutable release bundle before remote Maven publication.' - - doLast { - if (!preparedReleaseDir.isPresent()) { - throw new GradleException('preparedReleaseDir is required for prepared artifact publication') - } - File prepared = file(preparedReleaseDir.get()) - if (!prepared.isDirectory()) { - throw new GradleException("Prepared release directory does not exist: ${prepared}") - } - List expected = expectedReleaseFiles.get().sort() - List jars = (prepared.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') } - .sort { it.name } - if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { - throw new GradleException("Prepared release must contain exactly the non-empty jars ${expected}") - } - File checksums = new File(prepared, 'SHA256SUMS') - File changelog = new File(prepared, 'CHANGELOG.txt') - if (!checksums.isFile() || !changelog.isFile()) { - throw new GradleException('Prepared release is missing SHA256SUMS or CHANGELOG.txt') - } - List actualChecksums = 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() - List declaredChecksums = checksums.readLines('UTF-8') - .findAll { !it.trim().isEmpty() } - .sort() - if (actualChecksums != declaredChecksums) { - throw new GradleException('Prepared release checksums do not match the immutable jars') - } - } -} - -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' - } - } - scm { - connection = 'scm:git:https://github.com/MinecraftModDevelopmentMods/OreSpawn.git' - developerConnection = 'scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/OreSpawn.git' - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - } - } - } - } - 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' - description = 'Prevents Maven publication from targeting a local or incomplete repository.' - 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') - } - String target = providers.environmentVariable('MAVEN_UPLOAD_URL').get() - if (target.startsWith('file:')) { - throw new GradleException('Maven release publication must use a remote repository') - } - } -} - -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - if (preparedReleaseDir.isPresent()) { - dependsOn tasks.named('verifyPreparedReleaseArtifacts') - } else { - dependsOn tasks.named('verifyReleaseArtifacts') - } -} - -tasks.named('check') { - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn tasks.named('verifyDocumentationParity') -} - idea { module { downloadSources = true downloadJavadoc = true } } - -// ForgeGradle 2 generated these launch names with the removed GradleStart -// bootstrap. They can survive an in-place Buildship migration and remain the -// last-run Eclipse configuration even after ForgeGradle 7 generates runClient -// and runServer. Remove only the exact obsolete launch types; leave any -// independently maintained launch with the same file name untouched. -def obsoleteForgeGradle2EclipseLaunches = [ - 'OreSpawn_Client.launch': 'GradleStart', - 'OreSpawn_Server.launch': 'GradleStartServer' -] - -eclipse { - classpath { - downloadSources = true - downloadJavadoc = true - } - synchronizationTasks 'isolateEclipseProductionRuns' -} - -tasks.register('configureEclipseBuildship') { - group = 'ide' - description = 'Creates the Buildship project preferences used by ForgeGradle 7 imports.' - doLast { - File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') - Properties preferences = new Properties() - Map requiredPreferences = [ - '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' - ] - requiredPreferences.each { key, value -> preferences.setProperty(key, value) } - preferencesFile.parentFile.mkdirs() - preferencesFile.withOutputStream { - preferences.store(it, - 'Generated by configureEclipseBuildship; keep Eclipse and command-line caches aligned.') - } - } -} - -tasks.register('isolateEclipseProductionRuns') { - group = 'ide' - description = 'Marks ordinary ForgeGradle 7 Eclipse launches as production-only.' - dependsOn tasks.named('genEclipseRuns') - dependsOn tasks.named('configureEclipseBuildship') - dependsOn prepareEclipseResources - - doLast { - obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> - File launch = file(name) - if (launch.isFile()) { - String contents = launch.getText('UTF-8') - String obsoleteMainType = - "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"" - if (contents.contains(obsoleteMainType) && !launch.delete()) { - throw new GradleException("Could not remove obsolete ForgeGradle 2 launch ${name}") - } - } - } - - ['runClient.launch', 'runServer.launch'].each { name -> - File launch = file(name) - if (!launch.isFile()) { - throw new GradleException("ForgeGradle did not generate ${name}") - } - String contents = launch.getText('UTF-8') - if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - String marker = '' - String attribute = - ' ' - contents = contents.replace(marker, attribute + '\n' + marker) - } - launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') - } - } -} - -tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - description = 'Verifies that ordinary generated Eclipse launches exclude tests, fixtures, and probe mods.' - dependsOn tasks.named('isolateEclipseProductionRuns') - dependsOn tasks.named('verifyLegacyOracleIsolation') - - doLast { - File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') - if (!buildshipPreferences.isFile()) { - throw new GradleException('Missing Eclipse Buildship project preferences') - } - File eclipseResources = file('bin/main') - List trackedDocs = trackedDocumentationFiles() - if (trackedDocs.size() != 21) { - throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") - } - trackedDocs.each { File source -> - String relative = documentationRelativePath(source) - File eclipseCopy = new File(eclipseResources, "META-INF/orespawn/docs/${relative}") - if (!eclipseCopy.isFile() - || !java.util.Arrays.equals(source.bytes, eclipseCopy.bytes)) { - throw new GradleException("Eclipse documentation differs for ${relative}") - } - } - [ - 'mcmod.info', - 'META-INF/orespawn/docs/README.md', - 'META-INF/orespawn/docs/VERSIONS.md' - ].each { relative -> - File required = new File(eclipseResources, relative) - if (!required.isFile()) { - throw new GradleException( - "Eclipse production output is missing processed resource ${relative}") - } - } - String eclipseMetadata = new File(eclipseResources, 'mcmod.info').getText('UTF-8') - if (!eclipseMetadata.contains("\"version\": \"${project.version}\"") - || !eclipseMetadata.contains("\"mcversion\": \"${minecraft_version}\"")) { - throw new GradleException('Eclipse mcmod.info retains unexpanded build placeholders') - } - List launchFiles = fileTree(project.projectDir) { - include 'runClient.launch' - include 'runServer.launch' - include '.eclipse/runClient.launch' - include '.eclipse/runServer.launch' - }.files as List - if (launchFiles.size() < 2) { - throw new GradleException('ForgeGradle did not generate ordinary client/server Eclipse launches') - } - obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> - File launch = file(name) - if (launch.isFile() && launch.getText('UTF-8').contains( - "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"")) { - throw new GradleException( - "Obsolete ForgeGradle 2 ${mainClass} launch remains at ${name}") - } - } - List forbidden = [ - 'src/test', 'bin/test', 'build/classes/java/test', - 'biomeIntegrationTest', 'migrationIntegrationTest', 'clientIntegrationTest', - 'os1AbiFixture', 'os3AbiFixture', 'surfaceprobe', 'migrationprobe', 'clientprobe', - 'junit-', 'opentest4j-', 'junit-platform-', 'Mineralogy-1.10.2-3.3.8.26.jar' - ] - launchFiles.each { launch -> - String contents = launch.getText('UTF-8').replace('\\', '/') - List present = forbidden.findAll { contents.contains(it) } - if (!present.isEmpty()) { - throw new GradleException("${launch.name} exposes test code/dependencies: ${present}") - } - if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE') - || !contents.contains('value="true"')) { - throw new GradleException("${launch.name} does not exclude test code") - } - if (!contents.contains('PROJECT_ATTR" value="OreSpawn"')) { - throw new GradleException("${launch.name} targets the wrong Eclipse project") - } - if (launch.name.toLowerCase().contains('server') - && (!contents.contains('nogui') || contents.contains('--nogui'))) { - throw new GradleException("${launch.name} does not use Forge 1.10's bare nogui argument") - } - } - } -} +// Apply in dependency order: fixtures and run helpers first, then their gates, +// release artifacts, publishing, and finally Eclipse integration. +apply from: 'gradle/verification/support.gradle' +apply from: 'gradle/verification/legacy-and-migration.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' diff --git a/docs/API.md b/docs/API.md index 5b21d46c..c45a3a0e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -52,8 +52,16 @@ 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 ore material identity and independent placement channels. + +OreSpawn 4.1's biome directory and exact replacement layer add no public Java +API methods or descriptors. Providers continue to declare ordinary schema-4 +biome palettes and dimension materials through the existing builders below. +The editor's provider-default snapshot and terminal override palette are +implementation details; add-ons must not depend on their client classes or +reserved `orespawn:ui/biome_overrides/` IDs. Provider-owned fluid deposits are declarative and may target several dimensions: @@ -91,6 +99,46 @@ exclusions. These methods work on both explicit `.dimension(...)` rules and `.dimensionSelector(...)` fallbacks; built definitions and their returned filter sets are immutable. +Declare the canonical material separately from the output block when equivalent +ores may be supplied by several mods: + +```java +ResourceLocation sulfur = new ResourceLocation("orespawn", "sulfur"); +ResourceLocation standard = new ResourceLocation("orespawn", "standard"); + +WorldgenProvider provider = WorldgenProvider.builder("examplemod", 2) + .ore(new ResourceLocation("examplemod", "ore/sulfur"), ore -> ore + .material(sulfur) + .dimension(new ResourceLocation("minecraft", "overworld"), placement -> placement + .placementChannel(standard) + .yRange(0, 48) + .attempts(4.0) + .quantity(8) + .pattern(OrePattern.VEIN) + .hostTag(new ResourceLocation("forge", "stone")))) + .build(); +``` + +`OreDefinition.material()` is optional. Built-in patterns default their +`OreDimensionDefinition.placementChannel()` to `orespawn:standard`; a custom +pattern defaults to its registered pattern-type ID. Explicit channels keep an +ordinary vein budget independent from a region-scale deposit budget while the +material group may still share the same selectable outputs. + +Provider definitions are also the authoritative managed-output inventory. A +disabled provider ore remains available as an output-only candidate, but it +cannot own frequency, shape or placement. This is how a sibling mod can offer +its sulfur block without multiplying sulfur generation. Ore Dictionary-only +third-party blocks may be selected as outputs, but OreSpawn never claims to +disable their independent generators. + +Global schema 8 and world-profile schema 7 persist `ore_material_groups` and +the output mode chosen for each policy. These are profile configuration, not +additional public Java descriptors. Existing provider and pattern binaries +continue to use the same API-major-1 methods. A compiled custom pattern should +pass its stable deposit identity to `tryPlace(x, y, z, outputIdentity)` so the +selected material output remains constant throughout a body and across chunks. + Create one `BiomeRegistrar` during normal mod construction. It attaches to the calling mod's event bus and defers biome factories until Forge's biome registry event. `OreSpawnBiomes.copyAndRegister` clones a known biome without adding a @@ -150,9 +198,57 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> { ``` `sampleColumn` performs one biome/geome classification and reuses it for every -Y query. Sampling is read-only and is intended for gameplay decisions, -diagnostics, and compatible generation outside OreSpawn's block loops. -Callbacks inside OreSpawn generation loops are intentionally unsupported. +Y query. Sampling is read-only, does not load or generate the requested chunk, +and is intended for gameplay decisions, diagnostics, and compatible generation +outside OreSpawn's block loops. + +OreSpawn 4.1 adds `OreGenerationContext` as a binary-compatible subtype of the +original `OrePlacementContext`. Every context supplied by OreSpawn implements +the extended type. A custom compiled pattern can obtain stable region-scale +identity without retaining a world object: + +```java +if (!(context instanceof OreGenerationContext)) return false; +OreGenerationContext generation = (OreGenerationContext) context; +long seed = generation.worldSeed(); +ResourceLocation dimension = generation.dimension(); +int chunkX = generation.chunkX(); +int chunkZ = generation.chunkZ(); +Optional geology = generation.geologySampler(); +``` + +The world seed, dimension and chunk coordinates are identical for ordinary +generation and supported retrogen. `geologySampler()` is empty when that +dimension has no active OreSpawn geology configuration. It otherwise returns +the already-prepared, allocation-light sampler for the active world. Large +patterns must independently render only the slice intersecting the current +chunk and continue to use `inside(...)` and `tryPlace(...)` for safe writes. +Do not retain the sampler or placement context beyond the current call. + +For a body that intersects several chunks, call +`tryPlace(x, y, z, stableBodyIdentity)` instead. This Java 8 default overload +preserves the original three-coordinate method and therefore existing pattern +binaries. OreSpawn uses the supplied identity to choose one output source for +the complete body rather than independently selecting an output per chunk. + +Large-deposit add-ons may make one OreSpawn rule the controller for a resource +in a dimension. Set `.backgroundGenerationScale(value)` on that rule to scale +other OreSpawn-managed rules and vanilla generation for the same primary +output. The controller rule remains unscaled, omitted values mean `1.0`, zero +fully suppresses background generation, and the lowest value wins when several +controllers target the same resource. Vanilla decisions use a stable hash of +world, dimension, chunk and resource rather than mutable event order. + +When a rule declares `material`, background scaling applies only to that +material's `orespawn:standard` channel; it does not scale another custom +placement engine. Undeclared legacy rules retain the existing block-based +background behaviour. + +Providers whose entries capture structural configuration can call +`.mergeNewEntriesIntoExistingWorlds(false)`. New worlds still receive the +complete provider, while an existing saved world profile does not silently gain +definitions introduced after that world was created. The historical default is +`true`. Forge 12 custom-pattern mods attach a generic `RegistryEvent.Register` listener to their mod event bus and @@ -163,5 +259,37 @@ register named values into `OreSpawnPatternRegistry.REGISTRY_NAME`. An baking the profile; only the compiled placement function runs during generation. +## Client world-settings extensions + +An add-on may contribute one configuration screen to OreSpawn's **Mods** +directory without depending on OreSpawn implementation classes. Prefer the +owning-mod registration form during client initialization: + +```java +WorldSettingsExtensionRegistry.registerConfigScreen( + "examplemod", + parent -> new ExampleDepositSettingsScreen(parent)); +``` + +The original three-argument form remains binary compatible. Its resource +namespace is treated as the owning mod ID: + +```java +WorldSettingsExtensionRegistry.register( + new ResourceLocation("examplemod", "deposit_settings"), + "button.examplemod.deposit_settings", + parent -> new ExampleDepositSettingsScreen(parent)); +``` + +OreSpawn owns directory layout and passes the Mods directory as the factory +parent. Add-ons should use that parent for Done; OreSpawn also redirects the +inherited vanilla Escape action there before the player returns to the main +geology screen. Exactly one screen may be owned by +each mod; duplicate ownership, blank legacy translation keys and null factories +are rejected deterministically. The legacy translation key and extension +accessors remain available to existing binaries, although the directory now +uses its packaged cog action. These types are client-only and must not be +loaded from a dedicated-server initialization path. + `OreSpawnOreIntegration` remains as a deprecated facade for early ore-provider integrations. New code should use `OreSpawnApi`. diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 796a80ae..05df5168 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -144,14 +144,24 @@ in base terrain are eligible for geology; matching blocks authored later by structures or vegetation are not. Air, liquids, bedrock, and block-entity states remain protected even if a provider mistakenly lists their block IDs. -Dimension materials support the ordinary aquifer fluid and replacements for -vanilla snow and ice. Minecraft 1.10.2 has one exposed generator-fluid field, -so `default_fluid` is fully supported. Later-format `deep_aquifer_fluid` and -`deep_aquifer_max_y` values remain readable and are preserved in saved profiles, -but this branch disables their editor controls, warns when a distinct deep -fluid was requested, and uses the ordinary fluid for generation. OreSpawn converts -weather products in loaded chunks and around players; it does not replace every -water or lava block after generation. +Dimension materials apply to every biome in one dimension and only to newly +generated terrain. They support the ordinary aquifer fluid and replacements +for vanilla snow and ice. For a fluid with the same opacity and emitted light +as the native generator fluid, OreSpawn records only native aquifer cells while +the chunk is being built, lets Minecraft generate and light the terrain with +its native fluid, then substitutes exactly those cells before decoration. This +does not touch later lakes, springs or decorator fluids and creates no reload +retrogen. A fluid with different lighting uses the compatible direct-generator +path so its light remains correct; that path can be slower. + +Minecraft 1.10.2 has one exposed generator-fluid field, so `default_fluid` is +fully supported. Later-format `deep_aquifer_fluid` and +`deep_aquifer_max_y` values remain readable and are preserved in saved +profiles, but this branch disables their editor controls, warns when a distinct +deep fluid was requested, and uses the ordinary fluid for generation. OreSpawn +converts weather products in loaded chunks and around players; it does not +replace every water or lava block after generation. Unsupported independent +chunk generators are reported and left unchanged. ## Templates And Total Conversions @@ -175,12 +185,52 @@ Automatic selection occurs only for fresh worlds when no explicit global If several providers request automatic selection, the highest priority wins, 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. +## Biome Directory And Exact Overrides + +**Biomes** is visible even when rock strata are disabled. At normal window +sizes it keeps a compact biome list and the selected biome's details together; +at the minimum supported width it uses list and detail pages without losing the +selection or pending edits. The default list contains provider-managed, +modified, disabled and missing entries. **Show All** also displays routine +registered biomes associated with the selected dimension. OreSpawn uses Forge's +Nether/End biome types for ordinary loaded entries and exact provider placement +declarations when they exist. Explicit replacements and missing entries remain +visible in their configured dimension. + +The detail pane reports the friendly name, registry ID, mod owner, status and +effective placement-rule count. Every profile palette is shown in its stored +sequential order instead of only the first palette for the dimension. Placement +details expose enabled state, weight, source-biome and required-biome limits, +climate range, top/filler/underwater/ceiling blocks, filler depth, owner and +effective order. When several enabled palettes define a surface for one biome, +the directory identifies the effective last rule without silently reordering +the profile. + +**Leave original behaviour** makes no exact replacement. **Replace in new +terrain with...** accepts any loaded source and target biome, including an +external biome. Provider ownership remains available in the biome details but +does not produce a warning for a valid replacement. OreSpawn stores these +choices in the reserved +`orespawn:ui/biome_overrides/` palette. It is a 100% `replace/all` +palette with zero fallback and always bakes after every ordinary palette, +regardless of JSON insertion order. Each source has one terminal target; +chains are flattened, inbound mappings follow a subsequently replaced target, +and cycles or self-replacements are rejected. A missing target remains in the +profile but leaves its source unchanged until the target mod returns. + +Exact replacements affect only chunks generated after the edit. They do not +unregister a biome, suppress another mod's decorators, alter spawn lists or +rewrite existing chunks. Arbitrary weighted injection of loaded biomes is not +offered; only provider-declared palette outputs retain weighted placement. + +The palette-settings page exposes mode, scope, region size, coverage, fallback +weight and namespace include/exclude lists. Dimension materials and geome +influences remain reachable from the directory. Reset Biome clears its exact +replacement and restores active provider placements for that biome; palette, +dimension and all-biome resets restore loaded-provider defaults and remove +user/profile-only palettes while preserving entries owned by missing providers. +All edits and resets remain pending until the main editor's **Done** action; +**Cancel** discards them byte-for-byte. ## Performance Boundaries diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2649879a..8ce98181 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 `assets//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 `assets//orespawn/provider.json`. +Legacy provider schemas 1-4 remain accepted. Fluid deposits require schema 3; +biome palettes and dimension materials require schema 4; ore `material` and +`placement_channel` declarations 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, @@ -21,7 +22,7 @@ world. Restart after editing JSON by hand. | 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 | @@ -36,6 +37,8 @@ world. Restart after editing JSON by hand. | `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 stable group ID | Friendly material names and exact Ore Dictionary aliases | +| `ore_source_policies` | object keyed by material and dimension/domain | Persisted output and placement arbitration choices | | `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 | @@ -147,10 +150,20 @@ ignored when absent. If a required similar biome is absent, that output entry is disabled with one setup warning. Surface fields are `top_block`, `filler_block`, `underwater_block`, `ceiling_block`, and `filler_depth`. -Dimension-material rules may set `default_fluid`, `deep_aquifer_fluid`, -`deep_aquifer_max_y`, `snow_block`, and `ice_block`. Fluid IDs must resolve to -blocks with non-empty fluid states. These substitutions are opt-in; a dimension -with no matching rule retains its native generator and weather materials. +Dimension-material rules apply to every biome in their selected dimension and +may set `default_fluid`, `deep_aquifer_fluid`, `deep_aquifer_max_y`, +`snow_block`, and `ice_block`. Fluid IDs must resolve to blocks with non-empty +fluid states. These substitutions are opt-in, affect only newly generated +terrain and perform no retrogen; a dimension with no matching rule retains its +native generator and weather materials. + +For a `default_fluid` with the native fluid's opacity and emitted light, +OreSpawn records the exact native aquifer cells during terrain construction and +substitutes only those cells before decoration. Later lakes, springs and fluids +placed by decorators are not included. A fluid with different lighting uses a +slower direct-generator compatibility path so lighting remains correct. +Unsupported independent chunk generators are detected and left unchanged. + Minecraft 1.10.2 exposes one generator fluid, so this branch applies `default_fluid` only. It retains `deep_aquifer_fluid` and `deep_aquifer_max_y` in provider and world profiles for cross-version @@ -158,11 +171,39 @@ portability, but the editor keeps those controls disabled and generation does not use a distinct deep fluid. See `BIOMES.md` for complete examples and practical guidance. +### Biome directory overrides + +The editor reads all `biome_palettes` entries for a dimension in their stored +order. It does not rewrite or publicly prioritize ordinary palettes. Exact +one-to-one user replacements are encoded as +`orespawn:ui/biome_overrides/` using the existing palette schema: +`mode: replace`, `scope: all`, `coverage: 1.0`, `fallback_weight: 0.0`, with +each target's exact sources in `similar_biomes`. OreSpawn always bakes this +reserved palette after the ordinary entries regardless of JSON order. + +Only loaded biomes can be selected in the GUI, although an already saved rule +whose target becomes unavailable is retained. That dormant rule leaves the +source unchanged and automatically becomes active when the target is loaded +again. Replacement chains are stored as terminal mappings; self-replacements +and cycles are invalid. These rules control new terrain only and do not disable +external biome generators, decorators, structures, mobs or registry entries. + +Biome reset operations use an internal immutable snapshot of currently loaded +provider defaults. A biome reset restores that biome's active provider entries; +a palette reset restores the matching active provider palette; dimension/all +resets remove user/profile-only biome palettes and restore active provider +palettes and materials. Definitions owned by providers which are currently +missing remain untouched. The pending editor copy is written to global defaults +or the world profile only when the main editor's **Done** action succeeds. + ## Ore Fields An ore has `enabled`, one output `block` or weighted `outputs`, and at least one -entry in `dimensions` or `dimension_selectors`. Optional fields include `native_generation`, -`suppress_vanilla`, `retrogen`, `deep_output`, and `deep_output_max_y`. +entry in `dimensions` or `dimension_selectors`. Optional fields include +`material`, `native_generation`, `suppress_vanilla`, `retrogen`, `deep_output`, +and `deep_output_max_y`. Schema 5 `material` is a canonical material ID such as +`orespawn:sulfur`; it lets several rules share outputs without sharing placement +budgets. Each enabled ore dimension uses: @@ -173,6 +214,7 @@ Each enabled ore dimension uses: | `quantity` | 1-64 | Fixed block budget for each attempt | | `min_quantity`, `max_quantity` | 1-64 | Inclusive random block-budget range; both fields are required | | `pattern` | pattern name or codec object | Deposit shape | +| `placement_channel` | registry ID | Independent placement engine; built-ins default to `orespawn:standard`, custom patterns to their pattern-type ID | | `height_distribution` | one of four values | Vertical probability curve | | `discard_chance_on_air_exposure` | 0-1 | Chance to omit candidates touching cave air | | `spread` | 0-64 | Horizontal pattern reach | @@ -203,6 +245,98 @@ 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. +## Ore Source Policies + +OreSpawn builds one immutable source catalog while providers and the Forge Ore +Dictionary are being baked. Explicit `material` declarations win. Otherwise, +only exact Ore Dictionary names of the form `oreX` are inferred; curated +spelling aliases include sulfur/sulphur and aluminum/aluminium. Niter and +Saltpeter remain distinct, and ambiguous entries are marked **Review required** +instead of being guessed. Each ambiguous alias family receives its own stable +provisional group ID, so unrelated families cannot be combined merely because +both need review. Old shared `orespawn:review_required` data is split by fresh +discovery on load. Registry, dictionary and policy work never runs in a +chunk-generation loop. + +Each `ore_material_groups` entry has a persistent registry-style group ID, a +`display_name`, and `ore_dictionary_entries`. Entries must be exact `oreX` +names and may belong to only one group. Renaming a group does not change its +ID. OreSpawn supplies Sulfur (`oreSulfur`, `oreSulphur`) and Aluminum +(`oreAluminum`, `oreAluminium`) groups; Niter and Saltpeter deliberately remain +separate. The UI can create custom groups and confirm moving an alias from +another group. An empty custom group can be deleted immediately; a populated +custom group requires a confirmed dissolution that returns each alias to its +deterministic inferred or curated group. Automatically discovered groups +cannot be deleted because they represent the loaded Ore Dictionary. Curated +Sulfur and Aluminum groups instead offer Reset Defaults. + +Each `ore_source_policies` key combines the group ID with an exact dimension or +existing dimension-selector ID. `mode` is `consolidated` or `keep_separate`. +Consolidated policies also store `output_mode` as `balanced`, `single`, or +`custom`, selected output rule IDs with positive weights, and one active +placement-source rule per placement channel. Balanced uses equal weights, +Single has exactly one output, and Custom accepts any non-empty positively +weighted subset. The UI calls `keep_separate` **Keep Original**. Returning to +Keep Original restores the output weights and placement-source selections from +the profile snapshot taken when the editor was opened. If Keep Original itself +still needs review, **Accept** leaves those restored values unchanged and only +marks the policy reviewed. Like every Ore Sources edit, it is persisted only by +the main editor's **Done** action. + +**Reset All** is a confirmed pending action. It removes custom material groups +and source-policy choices, restores the built-in aliases, and rediscovers the +inferred groups from the ores currently loaded. It does not rewrite generated +chunks. The reset reaches the world profile and future-world defaults only when +the main editor's **Done** action succeeds; its **Cancel** action discards it. + +The Ore Sources screen keeps compact scrollable group and output lists visible +together. Its default view hides harmless one-alias, one-output entries; **Show +All** reveals them. Red groups need attention, yellow groups have a saved rule, +and green entries need no action. A saved consolidated rule or an explicitly +accepted Keep Original policy therefore clears a multi-output group's attention +state without hiding the resolved group. Use a +group's cog for its friendly name, Ore Dictionary aliases and **Placement +Rules**. Output selection controls which registered block represents +the material. Placement Rules separately control the managed rule that supplies +frequency, shape, depth and host restrictions for each channel. `orespawn:standard` +is shown as **Standard veins**; exact custom-channel IDs remain available in +tooltips. A sole active managed source is read-only, multiple active managed +sources are selectable, and independent external generators are never offered +as placement owners. Each row shows the selected source and the available +choice count. It cycles only in a consolidated mode; under Keep Original it is +informational because the original rules remain independent. The compact help +and row tooltips explain fixed, selectable, missing and inactive states without +reducing the alias-list height. + +Native vanilla ore rules are active placement sources only when +`manage_vanilla_ores` is enabled. Changing that option refreshes the pending +Ore Sources snapshot before the editor opens. While it is disabled, Group +Settings refuses to move a native vanilla ore's Ore Dictionary alias into +another material group and explains that vanilla management must be enabled +first. + +Consolidation runs the chosen placement rule's pattern, hosts, filters, height +and frequency once; selected candidates contribute output bundles only. An +inactive managed provider may therefore supply an output block without adding +a placement budget. Output choice is stable for a whole ordinary vein, and +custom patterns can supply a stable body identity so one deposit uses one +source across chunk boundaries. External Ore Dictionary members can be chosen +as outputs, but their independent native generators remain uncontrolled. + +New worlds consolidate only reviewed high-confidence MMD catalog conflicts and +start those groups in Balanced mode with all eligible outputs selected. +Upgraded worlds initialize discovered conflicts as `keep_separate`, preserving +their historical placement frequency and outputs until changed through **Ore +Sources...**. Policy edits affect newly generated chunks only and never cause +retrogen. Missing selected candidates remain in the profile; remaining outputs +are reweighted, while a channel with no remaining selected placement source is +disabled and reported rather than silently reassigned. + +DenseMetals entries are shown as enrichment rather than interchangeable output. +BaseSciences has no ordinary ore-source role. An Ore Dictionary member whose +generator is not controlled by OreSpawn is reported as external generation and +is never suppressed, so total abundance may still be increased by that mod. + ## 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 43f94896..b435417a 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -13,6 +13,10 @@ | Add or place biomes without a framework dependency | Provider schema 4 biome palette | | Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials | | Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` | +| Build a deterministic region-scale ore pattern | `OreGenerationContext` | +| Replace or blend background ore generation | `backgroundGenerationScale` | +| Share equivalent ore outputs without multiplying placement | Provider schema 5 `material` and `placement_channel` | +| Add an add-on settings screen | Client-only `WorldSettingsExtensionRegistry` | Strata are optional. If no enabled terrain dimension has eligible rocks, OreSpawn skips terrain replacement and all formation/geome settings are inert. @@ -21,7 +25,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/assets/examplemod/orespawn/provider.json @@ -33,12 +37,13 @@ stone without enabling strata: ```json { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "ores": { "examplemod:ore/tin": { "block": "examplemod:tin_ore", + "material": "orespawn:tin", "enabled": true, "source_mod": "examplemod", "dimensions": { @@ -50,6 +55,7 @@ stone without enabling strata: "min_quantity": 4, "max_quantity": 11, "pattern": "vein", + "placement_channel": "orespawn:standard", "height_distribution": "triangle", "host_tags": ["forge:stone"] } @@ -90,6 +96,7 @@ public void init(FMLInitializationEvent event) { ResourceLocation tin = new ResourceLocation("examplemod", "tin_ore"); WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) .ore(tin, ore -> ore + .material(new ResourceLocation("orespawn", "tin")) .retrogen(false) .dimensionSelector(OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END, placement -> placement @@ -97,6 +104,7 @@ public void init(FMLInitializationEvent event) { .attempts(6.0) .quantityRange(4, 11) .pattern(OrePattern.VEIN) + .placementChannel(new ResourceLocation("orespawn", "standard")) .heightDistribution(OreHeightDistribution.TRIANGLE) .biome(new ResourceLocation("minecraft", "plains")) .biomeDictionary("FOREST") @@ -119,6 +127,63 @@ needs different settings; the explicit rule overrides the selector there. Ore dimension builders support the same exact-ID and biome-dictionary include and exclude filters as provider JSON and fluid-deposit builders. +Give equivalent ores the same canonical `.material(...)`. A placement channel +identifies an independent generation engine: built-in patterns default to +`orespawn:standard`, while a custom pattern defaults to its pattern-type ID. +OreSpawn can then consolidate one placement budget while retaining weighted +whole-vein output choice. Do not use a block ID as a material merely because +the names happen to match; use a stable semantic material ID. + +## Region-scale custom patterns + +OreSpawn 4.1 supplies compiled patterns with `OreGenerationContext`, a subtype +of the original placement context. Its world seed, dimension ID and current +chunk coordinates let an add-on derive a stable region/deposit identity that +does not depend on chunk generation order. `geologySampler()` provides the +active OreSpawn geology where one is configured. + +Render only the part of a deterministic body that intersects `chunkX()` and +`chunkZ()`. All reads and writes must still pass through `inside(...)`, +`isFluid(...)` and `tryPlace(...)`; Forge 1.10 deliberately restricts those +operations to the current chunk. Perform definition parsing and expensive +setup in the registered pattern compiler, not its placement callback. + +When placing a multi-chunk body, call +`tryPlace(x, y, z, stableBodyIdentity)`. The overload is binary-compatible with +existing patterns and makes OreSpawn select one output source for the entire +body. The identity must be stable for the deposit and independent of chunk +generation order. + +## Add-on world settings + +Register one optional client configuration screen during client +initialization. The preferred form identifies the owning mod directly: + +```java +WorldSettingsExtensionRegistry.registerConfigScreen( + "examplemod", + parent -> new ExampleDepositSettingsScreen(parent)); +``` + +The existing form remains available to already-compiled add-ons, and its +resource namespace becomes the owner: + +```java +WorldSettingsExtensionRegistry.register( + new ResourceLocation("examplemod", "deposit_settings"), + "button.examplemod.deposit_settings", + parent -> new ExampleDepositSettingsScreen(parent)); +``` + +The registry is client-only. OreSpawn lists the loaded mod once in its +paginated **Mods** directory, renders a packaged cog, and supplies that +directory as the parent. The extension owns its complete screen and should +return to that parent from Done; OreSpawn redirects inherited vanilla Escape +there as well. OreSpawn synchronizes its pending editor session before opening +the directory. Only one configuration screen may be +registered per owning mod, so two resource paths in the same namespace are a +deterministic duplicate rather than extra main-screen rows. + ## Pack Override Quick Start Copy `examples/examplemod-orespawn.json` to: @@ -174,7 +239,9 @@ See `CONFIGURATION.md` and the JSON Schemas for every field and numeric range. Provider files and API definitions freeze before generation. OreSpawn resolves registry IDs, tags, dimensions, geomes, aliases, and block states while baking. The generation loop must not contain provider callbacks, config reads, registry -lookups, strings, logging, reflection, or avoidable allocation. +lookups, strings, logging, reflection, or avoidable allocation. Compiled custom +patterns are the intentional extension point; they receive only baked settings, +the allocation-free placement context and cached generation identity/sampler. Biome filters retain their exact registry IDs. Minecraft 1.10.2 uses a static Forge-backed biome registry, so generation carries those stable IDs alongside diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 1d691406..c2ee05c7 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -117,17 +117,28 @@ replacement, retrogen, forced retrogen, flat bedrock, and bedrock thickness. Unknown numeric dimensions and obsolete block states are reported instead of guessed. Review `config/orespawn-migration/migration-report.txt` after import. -Global schemas 1-5 and world schemas 1-4 are upgraded in memory and persisted +Global schemas 1-7 and world schemas 1-6 are upgraded in memory and persisted where safe. A schema-1 world held only mode/oil/formation choices and is overlaid on the effective installed-pack profile. A schema-2 world is already a full snapshot and preserves its geology. Existing terrain is not rewritten. -Provider schemas 1-3 remain readable. Schema 4 adds `biome_palettes`, +Provider schemas 1-4 remain readable. Schema 4 adds `biome_palettes`, `dimension_materials`, and automatic fresh-world template metadata. Older profiles receive empty biome/material sections, so migration cannot change their terrain or biome output. Auto-selected templates are never applied to an existing world profile during migration. +Provider schema 5 adds optional canonical ore `material` and per-rule +`placement_channel` fields. Global schema 8 and world schema 7 persist +`ore_material_groups`, each policy's `output_mode`, and the existing +`ore_source_policies`. Existing consolidated policies infer Single, Balanced, +or Custom from their selected outputs and weights. A new world may consolidate +only reviewed MMD conflicts and begins those groups Balanced; every discovered +conflict in an upgraded world is initialized as `keep_separate` (shown as Keep +Original), preserving prior placement frequency, ordering and output behaviour. +Missing selected sources and dormant policies remain recorded. Policy changes +affect new chunks only and do not add retrogen. + The old `place_crude_oil` and singleton `oil` fields migrate to `place_fluid_deposits` and a provider-owned rule. A valid block becomes `:fluid_deposit/`; `minecraft:air` creates no rule. Mineralogy diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 2bd031c2..bcc6829f 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -9,8 +9,11 @@ For a normal game: 1. Open **OreSpawn...** while creating the world. 2. Choose **Recommended Defaults** unless you want to customise geology. -3. Open **Help & Guide** for a plain-language tour of the controls. -4. Press **Done**, then create the world normally. +3. Open **Mods** to see which installed mods OreSpawn recognized and whether + their provider is active. A lit cog opens that mod's own settings; a dim cog + explains that the mod did not supply a screen. +4. Open **Help & Guide** for a plain-language tour of the controls. +5. Press **Done**, then create the world normally. Hover over unfamiliar controls for a short explanation. The same explanations are collected in **Help & Guide**, so a setting can be learned either while @@ -25,10 +28,10 @@ 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 -installed registry entries. Missing optional compatibility biomes are skipped -safely instead of breaking world creation. +Use **Biomes** to inspect every installed or referenced biome, every palette in +its effective order, surface blocks, aquifer fluids, snow and ice. The picker +only accepts real installed registry entries. Saved missing targets remain +dormant instead of breaking world creation. ## What The Main Controls Mean @@ -43,23 +46,76 @@ safely instead of breaking world creation. region. - **Manage Vanilla Ores** lets OreSpawn replace vanilla ore features with the configured OreSpawn rules. Leave it off to keep normal Minecraft placement. +- **Ore Sources** opens the material-group editor beside **Add Block**. Compact + scrolling lists keep relevant groups on the left and the deduplicated union + of installed ore blocks represented by their aliases on the right. Ordinary + one-alias, one-output entries are hidden until **Show All** is selected. Red + groups need attention, yellow groups have a saved rule and green entries need + no action: + **Balanced** shares them equally, **Single** uses one, **Custom** enables a + weighted subset, and **Keep Original** preserves every old placement rule and + restores the output and placement selections present when the editor opened. + Groups needing attention sort first. Saving a consolidated rule clears that + attention state. To keep an unchanged red Keep Original policy, press + **Accept**; it marks the group reviewed without changing its outputs or + placement rules. It is saved only when the main editor's **Done** is used. + **Reset All** asks for confirmation, removes pending custom groups and source + choices, and rebuilds the built-in and inferred groups from the currently + loaded ores. Main-editor **Cancel** still discards that reset. + The cog beside a group opens its friendly + name, Ore Dictionary aliases and **Placement Rules**. Outputs decide which + block is used; Placement Rules decide the frequency, shape, depth and host + restrictions for each managed channel. A single managed source is read-only; + a row with multiple sources shows the choice count and can be clicked to + cycle the source after Balanced, Single or Custom is selected. Under Keep + Original the row is informational because every rule remains independent. + Hover the Placement Rules help or + a rule row to see whether it is fixed, selectable, missing, or inactive under + Keep Original. + Empty custom groups can be deleted immediately. A populated custom group can + be dissolved after a second confirmation, returning all aliases to their + inferred or curated groups. Automatically discovered groups cannot be + deleted, while the curated Sulfur and Aluminum groups can be reset. + Turning **Manage Vanilla Ores** on makes native vanilla rules available here + immediately. When it is off, OreSpawn will not let a vanilla ore alias be + moved into another group; the screen tells you to enable vanilla management + first. + Independent external generators + are explained but never offered as controllable placement sources. +- **Mods** opens a paginated directory of loaded OreSpawn integrations. It + reports the Forge mod version, native OS4 or legacy OS1-OS3 lineage, provider + schema/revision and current status. Historical profile owners that are not + loaded are not shown. - **Fluid Deposits** appears after strata are enabled or when a mod or pack supplies a rule. Press **Add** to choose water, lava, or a fluid block from an 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. - -**World Materials** applies across an entire dimension. **Aquifer Fluid** -changes the normal below-sea-level fluid. Minecraft 1.10.2 exposes only that -single generator fluid, so the later-format **Deep Aquifer** values remain -stored but their controls are disabled on this branch. Snow and ordinary ice -can also be replaced. Use **Fluid Deposits**, not World Materials, for -occasional underground lakes or pockets. +- **Biomes** opens a compact directory. Managed, changed and missing entries + appear first; **Show All** includes ordinary registered biomes belonging to + the selected dimension. Select a + biome to inspect every placement rule, owner, order, climate limit and + effective surface. **Leave original behaviour** keeps it unchanged, while + **Replace in new terrain with...** creates an exact terminal replacement. + This affects only newly generated chunks and never unregisters the source or + disables another mod's decoration. Use **Palettes** for mode, scope, region + size, coverage, fallback and namespace controls. The dimension-named + Materials control sits with the dimension selector because it is not a + property of the selected biome. Geome Influences remains available from the + selected-biome side. Confirmed biome, + palette, dimension and all-biome resets stay pending until the main editor's + **Done** action; **Cancel** discards them. + +**Overworld Materials**, or the corresponding dimension-named button, applies +to every biome in that dimension's newly generated terrain. It is not limited +to the biome selected on the right and never rewrites existing chunks. +**Aquifer Fluid** changes the normal below-sea-level terrain fluid. Fluids with +native-equivalent lighting use the fast substitution path; an unusual luminous +or differently opaque fluid uses the compatible generator path and may be +slower. Minecraft 1.10.2 exposes only one generator fluid, so the later-format +**Deep Aquifer** values remain stored but their controls are disabled on this +branch. Snow and ordinary ice can also be replaced. Use **Fluid Deposits**, not +dimension materials, for occasional underground lakes or pockets. ## Rocks, Ores, And Other Mods @@ -73,6 +129,25 @@ whether a deposit is compact, vein-like, clustered, cloud-like, or below a fluid. Hosts decide which blocks, tags, or configured rock families it may replace. +When two or more OreSpawn-managed rules represent one material, open **Ore +Sources...**. The list reports the material, dimension, candidate count and +whether it is Consolidated, Separate, Review required, Missing source, or also +has External generation. Open a material to choose one placement source for +each independent channel and enable one or more weighted output sources. + +Consolidated mode uses one placement budget, so three sulfur blocks do not mean +three times as much sulfur. Each vein uses one selected output instead of a +salt-and-pepper mixture. Custom region-scale deposit engines remain on their +own placement channel and can keep one output across every chunk of a deposit. +External native generators are explained but never disabled by OreSpawn. + +New worlds automatically consolidate only reviewed high-confidence MMD +conflicts. Existing worlds start with conflicts Separate so their established +frequency remains unchanged. Missing selected mods stay visible and are +restored automatically if reinstalled. These edits stay pending until the main +OreSpawn editor's **Done** is pressed; its **Cancel** discards them. Changes +affect only newly generated chunks and never retrogen existing terrain. + Removing a rock from generation does not unregister its block or recipes. It only prevents that rock appearing in newly generated terrain. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 2bf28838..46ee066c 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -6,12 +6,13 @@ 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 introduced biome and world-material controls, and schema 5 +adds canonical ore materials and independent placement channels. 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, @@ -26,6 +27,7 @@ ownership keys, not necessarily block IDs. Set `block` for one output or ```json "examplemod:ore/tin": { "block": "examplemod:tin_ore", + "material": "orespawn:tin", "outputs": [ { "block": "examplemod:tin_ore", "weight": 90 }, { "block": "examplemod:rich_tin_ore", "weight": 10 } @@ -40,12 +42,20 @@ ownership keys, not necessarily block IDs. Set `block` for one output or "min_quantity": 4, "max_quantity": 11, "pattern": "default", + "placement_channel": "orespawn:standard", "host_tags": ["forge:stone"] } } } ``` +`material` identifies what an ore represents; it is not an output registry ID. +Rules for one material and dimension share selectable output sources, while +`placement_channel` keeps ordinary veins independent from custom deposit +engines. Built-in patterns default to `orespawn:standard`; custom patterns +default to their registered pattern-type ID. A schema-5 provider should declare +these fields explicitly when it expects Ore Sources arbitration. + Fluid-deposit IDs also use the provider namespace. Their `block` may belong to any installed mod, but it must be a real fluid block. Every enabled dimension needs hosts and can independently set depth, attempts, lobe geometry, cover, @@ -101,9 +111,41 @@ Only suppress a provider mod's native ore generation when `OreSpawnApi.isOreTakeoverActive(modid)` returns true. `PENDING` means discovery has not frozen. `INACTIVE` is the fail-safe and native generation must remain. -Existing worlds merge newly introduced provider rule IDs but do not overwrite -world edits. Disabled and unassigned rules remain tombstones; removed provider -rules remain in the self-contained snapshot. +## Reviewed MMD Ore Sources + +OreSpawn ships a deliberately narrow compatibility catalog. BaseMetals, +ModernMetals, BaseGems, BaseMinerals, FantasyMetals and Advantage-family ores +may participate as ordinary interchangeable outputs when their material is +unambiguous. NetherMetals and EndMetals stay in their respective dimension +domains. DenseMetals is enrichment, not an interchangeable output, and +BaseSciences has no ordinary ore-source role. + +Reviewed fresh-world conflicts start Balanced with all eligible output blocks. +Mineralogy, then BaseMinerals, then ElectricAdvantage is the initial placement +and Single-mode priority for sulfur; BaseMinerals, then ElectricAdvantage is the +corresponding lithium priority. No other conflict is automatically consolidated +without an explicit catalog row. Exact Ore Dictionary `oreX` inference recognizes the curated +sulfur/sulphur and aluminum/aluminium aliases but deliberately keeps Niter and +Saltpeter distinct. Multiple unrelated entries are marked for review rather +than fuzzy-matched from block names. + +Provider definitions that are disabled for placement remain managed +output-only candidates. This permits a sibling mod's block to participate in a +Balanced, Single, or Custom material group without contributing a second +frequency budget. Duplicate rule or alias references to the same registry ID +and metadata are displayed once. + +Ore Dictionary membership does not prove that OreSpawn controls a mod's native +generator. Such candidates are displayed as external generation and are never +disabled. Providers should keep native generation enabled until takeover is +active, then let their OreSpawn rules participate normally. + +Existing worlds normally merge newly introduced provider rule IDs but do not +overwrite world edits. A Java provider may opt out with +`mergeNewEntriesIntoExistingWorlds(false)` when its rules capture structural +add-on configuration that must begin only in newly created worlds. Disabled and +unassigned rules remain tombstones; removed provider rules remain in the +self-contained snapshot. Biome providers can add Forge biomes normally, then declare where those biomes belong through `biome_palettes`. The overlay wraps the dimension's existing diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 09f03e1a..2069a178 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -51,7 +51,7 @@ Examples: | Minecraft | Loader | Target | Example full version | | --- | --- | ---: | --- | -| 1.10.2 | Forge | `110021` | `4.0.16.110021` | +| 1.10.2 | Forge | `110021` | `4.1.0.110021` | | 1.12.2 | Forge | `112021` | `4.0.6.112021` | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | diff --git a/docs/examples/examplemod-orespawn.json b/docs/examples/examplemod-orespawn.json index d850d2ef..6590127c 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": { @@ -32,6 +32,7 @@ "ores": { "examplemod:ore/tin": { "block": "examplemod:tin_ore", + "material": "orespawn:tin", "outputs": [ { "block": "examplemod:tin_ore", "weight": 90 }, { "block": "examplemod:rich_tin_ore", "weight": 10, "min_y": 0, "max_y": 24 } @@ -44,6 +45,7 @@ "min_y": 0, "max_y": 96, "frequency": 6.0, + "placement_channel": "orespawn:standard", "quantity": 8, "pattern": { "type": "orespawn:clusters", diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json index cabcfee1..a6b9f2d9 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,17 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": { + "orespawn:sulfur": { + "display_name": "Sulfur", + "ore_dictionary_entries": ["oreSulfur", "oreSulphur"] + }, + "orespawn:aluminum": { + "display_name": "Aluminum", + "ore_dictionary_entries": ["oreAluminum", "oreAluminium"] + } + }, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json index 93f51e30..f23002ca 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,17 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": { + "orespawn:sulfur": { + "display_name": "Sulfur", + "ore_dictionary_entries": ["oreSulfur", "oreSulphur"] + }, + "orespawn:aluminum": { + "display_name": "Aluminum", + "ore_dictionary_entries": ["oreAluminum", "oreAluminium"] + } + }, + "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..41739283 100644 --- a/docs/schemas/orespawn-provider.schema.json +++ b/docs/schemas/orespawn-provider.schema.json @@ -4,9 +4,10 @@ "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 }, + "merge_new_entries_into_existing_worlds": { "type": "boolean" }, "rocks": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/rock" } }, "ores": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/ore" } }, "fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/fluidDeposit" } }, 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 87cefe57..9584dad2 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" }, @@ -148,6 +150,12 @@ "minimum": 0.0, "maximum": 1.0 }, + "background_generation_scale": { + "type": "number", + "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 }, @@ -184,6 +192,7 @@ "required": ["enabled"], "properties": { "block": { "$ref": "#/$defs/registryId" }, + "material": { "$ref": "#/$defs/registryId" }, "metadata": { "$ref": "#/$defs/metadata" }, "enabled": { "type": "boolean" }, "source_mod": { "type": "string" }, @@ -223,6 +232,55 @@ ], "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": { "$ref": "#/$defs/metadata" }, + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "placement_channel": { "$ref": "#/$defs/registryId" }, + "ore_dictionary": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "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", "ore_dictionary_entries"], + "properties": { + "display_name": { "type": "string", "minLength": 1, "maxLength": 64 }, + "ore_dictionary_entries": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^ore[A-Z][A-Za-z0-9_]*$", "maxLength": 128 } } + }, + "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 aacbd2ac..f16c8ae7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,8 +25,15 @@ curseforge_project_id=245586 mod_id=orespawn mod_name=MMD OreSpawn +mod_artifact=OreSpawn +mod_vendor=Minecraft Mod Development mod_license=LGPL-2.1 -mod_version=4.0.16.110021 +mod_license_name=GNU Lesser General Public License, Version 2.1 +mod_license_url=https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt +mod_version=4.1.0.110021 mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. +mod_url=https://github.com/MinecraftModDevelopmentMods/OreSpawn +mod_scm_connection=scm:git:https://github.com/MinecraftModDevelopmentMods/OreSpawn.git +mod_scm_developer_connection=scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/OreSpawn.git diff --git a/gradle/README.md b/gradle/README.md new file mode 100644 index 00000000..8b42eda8 --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,36 @@ +# Build logic + +`build.gradle` describes the Forge 1.10 project: its dependencies, Java 8 +output, development runs, processed resources and ordinary jars. The applied +scripts keep the longer test, release and Eclipse workflows close to the tasks +they own. They are part of this repository, not a separate Gradle plugin. +Editable release identity (name, vendor, artifact name, license and project +links) lives in `gradle.properties`; compatibility-sensitive group and artifact +values are still checked by the release audit. + +| Script | What belongs here | +| --- | --- | +| `verification/support.gradle` | Sealed old-mod fixtures, test-only Mineralogy oracle and the Forge-run helper. | +| `verification/legacy-and-migration.gradle` | Fresh/reload surface worlds, Mineralogy 3 migration and opt-in OS1/OS3 binary checks. | +| `verification/client-and-benchmark.gradle` | Rendered client check and opt-in worldgen benchmark. | +| `release/artifacts.gradle` | Reobfuscated jar, release audits and SHA-256 checksums. | +| `release/publishing.gradle` | Maven coordinates, POM and credential guards. | +| `ide/eclipse.gradle` | Processed Eclipse resources, integration launches and production-classpath checks. | + +The root applies the scripts in dependency order. `oreSpawnBuild` shares a few +project values with release and Eclipse tasks; `oreSpawnVerification` shares +fixture paths and the Forge-run helper; `oreSpawnRelease` shares the audited +jar and file names with publishing. Keep a new task in its owning script and +keep existing task names stable for CI and documented commands. + +The routine offline gate is `clean check build javadoc verifyReleaseArtifacts +writeReleaseChecksums`. Run `clientIntegrationProcess` separately to exercise +the editor and a rendered fresh/reloaded world, and `genEclipseRuns +verifyEclipseProductionClasspath` for normal Eclipse launches. The OS1/OS3 +ABI check and worldgen benchmark are opt-in. The benchmark needs +`-PbenchmarkRunDir=...`; it is not part of every build. + +Forge 1.10's Mineralogy 3 migration gate is **not** obsolete: it runs under +`check` and protects the Cyano world-profile handoff. Unlike the 1.12 build, +this branch has no Gradle packaged-Forge runtime task. The exact reobfuscated +jar is tested separately in a disposable official Forge 12 installation. diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..2b24379d --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,230 @@ +def buildValues = project.ext.oreSpawnBuild +def trackedDocumentationFiles = buildValues.trackedDocumentationFiles +def documentationRelativePath = buildValues.documentationRelativePath + +// The named launches are the human-facing integration gates in Eclipse. +// Keep the older task name for anyone invoking it directly; the launches it +// writes below all point at this Forge 1.10 checkout. +task syncForge12EclipseIntegrationLaunches { + group = 'ide' + doLast { + def writeGradleLaunch = { String fileName, String displayName, String arguments -> + File launch = file(fileName) + String escapedArguments = arguments + .replace('&', '&') + .replace('"', '"') + launch.setText(""" + + + + + + + + +""", 'UTF-8') + } + + writeGradleLaunch('OreSpawn_Surface_FreshReload.launch', + 'OreSpawn surface fresh/reload gate', + 'surfaceIntegrationTest --offline --no-daemon') + writeGradleLaunch('OreSpawn_Migration_Fresh.launch', + 'OreSpawn migration fresh gate', + 'migrationIntegrationProcess --offline --no-daemon ' + + '-PmigrationRunDir=build/eclipse-migration-run ' + + '-PmigrationFamily=eclipse-manual -PmigrationPhase=fresh') + writeGradleLaunch('OreSpawn_Migration_Reload.launch', + 'OreSpawn migration reload gate', + 'migrationIntegrationProcess --offline --no-daemon ' + + '-PmigrationRunDir=build/eclipse-migration-run ' + + '-PmigrationFamily=eclipse-manual -PmigrationPhase=reload') + writeGradleLaunch('OreSpawn_Client_FreshReload.launch', + 'OreSpawn client world and editor fresh/reload gate', + 'clientIntegrationProcess --offline --no-daemon') + } +} + +tasks.named('genEclipseRuns') { + finalizedBy syncForge12EclipseIntegrationLaunches +} + +// Eclipse owns bin/main, so copy processed resources into it without deleting +// Eclipse's compiled classes or exposing test resources to normal launches. +def prepareEclipseResources = tasks.register('prepareEclipseResources') { + group = 'ide' + description = 'Copies Gradle-processed production resources into Eclipse merged output.' + dependsOn tasks.named('processResources') + doLast { + // Eclipse owns bin/main, so copy into it without declaring the + // directory as a Gradle output or deleting compiled classes. + project.copy { + from(layout.buildDirectory.dir('resources/main')) + into(layout.projectDirectory.dir('bin/main')) + } + } +} + +// ForgeGradle 2 generated these launch names with the removed GradleStart +// bootstrap. They can survive an in-place Buildship migration and remain the +// last-run Eclipse configuration even after ForgeGradle 7 generates runClient +// and runServer. Remove only the exact obsolete launch types; leave any +// independently maintained launch with the same file name untouched. +def obsoleteForgeGradle2EclipseLaunches = [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' +] + +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + } + synchronizationTasks 'isolateEclipseProductionRuns' +} + +tasks.register('configureEclipseBuildship') { + group = 'ide' + description = 'Creates the Buildship project preferences used by ForgeGradle 7 imports.' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + Map requiredPreferences = [ + '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' + ] + requiredPreferences.each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, + 'Generated by configureEclipseBuildship; keep Eclipse and command-line caches aligned.') + } + } +} + +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + description = 'Marks ordinary ForgeGradle 7 Eclipse launches as production-only.' + dependsOn tasks.named('genEclipseRuns') + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + + doLast { + obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile()) { + String contents = launch.getText('UTF-8') + String obsoleteMainType = + "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"" + if (contents.contains(obsoleteMainType) && !launch.delete()) { + throw new GradleException("Could not remove obsolete ForgeGradle 2 launch ${name}") + } + } + } + + ['runClient.launch', 'runServer.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) { + throw new GradleException("ForgeGradle did not generate ${name}") + } + String contents = launch.getText('UTF-8') + if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + String marker = '' + String attribute = + ' ' + contents = contents.replace(marker, attribute + '\n' + marker) + } + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} + +// A menu opening is not enough: ordinary launches must use production code +// only, while the named integration launches remain available for testing. +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + description = 'Verifies that ordinary generated Eclipse launches exclude tests, fixtures, and probe mods.' + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + + doLast { + File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') + if (!buildshipPreferences.isFile()) { + throw new GradleException('Missing Eclipse Buildship project preferences') + } + File eclipseResources = file('bin/main') + List trackedDocs = trackedDocumentationFiles() + if (trackedDocs.size() != 21) { + throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") + } + trackedDocs.each { File source -> + String relative = documentationRelativePath(source) + File eclipseCopy = new File(eclipseResources, "META-INF/orespawn/docs/${relative}") + if (!eclipseCopy.isFile() + || !java.util.Arrays.equals(source.bytes, eclipseCopy.bytes)) { + throw new GradleException("Eclipse documentation differs for ${relative}") + } + } + [ + 'mcmod.info', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + File required = new File(eclipseResources, relative) + if (!required.isFile()) { + throw new GradleException( + "Eclipse production output is missing processed resource ${relative}") + } + } + String eclipseMetadata = new File(eclipseResources, 'mcmod.info').getText('UTF-8') + if (!eclipseMetadata.contains("\"version\": \"${project.version}\"") + || !eclipseMetadata.contains("\"mcversion\": \"${minecraft_version}\"")) { + throw new GradleException('Eclipse mcmod.info retains unexpanded build placeholders') + } + List launchFiles = fileTree(project.projectDir) { + include 'runClient.launch' + include 'runServer.launch' + include '.eclipse/runClient.launch' + include '.eclipse/runServer.launch' + }.files as List + if (launchFiles.size() < 2) { + throw new GradleException('ForgeGradle did not generate ordinary client/server Eclipse launches') + } + obsoleteForgeGradle2EclipseLaunches.each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains( + "org.eclipse.jdt.launching.MAIN_TYPE\" value=\"${mainClass}\"")) { + throw new GradleException( + "Obsolete ForgeGradle 2 ${mainClass} launch remains at ${name}") + } + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'migrationIntegrationTest', 'clientIntegrationTest', + 'os1AbiFixture', 'os3AbiFixture', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'OreSourceEditorProbeBridge', + 'junit-', 'opentest4j-', 'junit-platform-', 'Mineralogy-1.10.2-3.3.8.26.jar' + ] + launchFiles.each { launch -> + String contents = launch.getText('UTF-8').replace('\\', '/') + List present = forbidden.findAll { contents.contains(it) } + if (!present.isEmpty()) { + throw new GradleException("${launch.name} exposes test code/dependencies: ${present}") + } + if (!contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('value="true"')) { + throw new GradleException("${launch.name} does not exclude test code") + } + if (!contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${launch.name} targets the wrong Eclipse project") + } + if (launch.name.toLowerCase().contains('server') + && (!contents.contains('nogui') || contents.contains('--nogui'))) { + throw new GradleException("${launch.name} does not use Forge 1.10's bare nogui argument") + } + } + } +} diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..092780cd --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,370 @@ +import groovy.json.JsonSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +def buildValues = project.ext.oreSpawnBuild +def expectedMavenGroup = buildValues.mavenGroup +def expectedMavenArtifact = buildValues.mavenArtifact +def expectedMavenCoordinate = buildValues.mavenCoordinate +def archiveTextSuffixes = buildValues.archiveTextSuffixes +def trackedDocumentationFiles = buildValues.trackedDocumentationFiles +def documentationRelativePath = buildValues.documentationRelativePath + +// Forge needs a reobfuscated jar; the mapped jar in libs-dev is only for +// local development. All release audits below inspect the shipped copy. +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") +} + +def apiJar = tasks.register('apiJar', Jar) { + dependsOn tasks.named('classes') + archiveClassifier = 'api' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + from sourceSets.main.output + include 'zone/moddev/mc/orespawn/api/**' + include 'com/mcmoddev/orespawn/api/**' + include 'com/mojang/serialization/**' + include 'cyano/orespawn/**' + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-api', + 'Implementation-Version': project.version, + 'OreSpawn-API-Version' : '1' + ]) + } +} + +tasks.register('deobfJar') { + group = 'build' + description = 'Builds the local deobfuscated development jar under build/libs-dev.' + dependsOn tasks.named('jar') +} + +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { + artifacts.clear() + } + artifacts { + add(configurationName, releaseJar) + } +} + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn apiJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def expectedReleaseFiles = providers.provider { + def prefix = "${base.archivesName.get()}-${project.version}" + [ + "${prefix}.jar", + "${prefix}-sources.jar", + "${prefix}-javadoc.jar" + ] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +// Catch wrong target numbers, API or schemas before checking any jar contents. +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' + + doLast { + if (project.mod_version != '4.1.0.110021' + || project.mod_group != expectedMavenGroup) { + throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") + } + if (project.minecraft_version != '1.10.2' + || project.forge_version != '12.18.3.2511' + || project.mapping_channel != 'stable' + || project.mapping_version != '29-1.10.2') { + throw new GradleException('Unexpected Minecraft, Forge, or mappings target') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '8' || project.gradle_java_version != '17') { + throw new GradleException('Unexpected dispatcher target metadata') + } + if (project.group.toString() != expectedMavenGroup + || project.mod_artifact != expectedMavenArtifact + || base.archivesName.get() != expectedMavenArtifact + || project.curseforge_project_id != '245586') { + throw new GradleException('Unexpected Maven or CurseForge publication identity') + } + + [ + 'src/main/java/zone/moddev/mc/orespawn/OreSpawn.java', + 'src/main/java/cyano/orespawn/OreSpawn.java', + 'src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.1.0.110021')) { + throw new GradleException("Authoritative release location does not contain 4.1.0.110021: ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[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 schema versions must remain 8/7') + } + def providerSchema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(providerSchema.properties.schema_version.enum as List).containsAll([4, 5])) { + throw new GradleException('Provider schema must support schema versions 4 and 5') + } + } +} + +// Read the files that would actually ship: names, manifests, bytecode, +// bundled guides and any accidentally included test fixture. +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + description = 'Audits the exact three distributable jars and their release-critical contents.' + dependsOn tasks.named('verifyReleaseConfiguration') + 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 } + List actual = jars.collect { it.name } + List expected = expectedReleaseFiles.get().sort() + if (actual != expected) { + throw new GradleException("Expected exactly ${expected}, found ${actual}") + } + + jars.each { candidate -> + if (candidate.length() == 0L) { + throw new GradleException("Empty release artifact: ${candidate.name}") + } + ZipFile candidateZip = new ZipFile(candidate) + try { + candidateZip.entries().findAll { entry -> + !entry.isDirectory() + && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean containsCarriageReturn = candidateZip.getInputStream(entry).withCloseable { input -> + input.bytes.any { value -> value == 13 } + } + if (containsCarriageReturn) { + throw new GradleException( + "${candidate.name}!/${entry.name} does not use canonical LF line endings") + } + } + ['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/', + 'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/', + 'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'OreSourceEditorProbeBridge', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden -> + if (candidateZip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden entry matching ${forbidden}") + } + } + } finally { + candidateZip.close() + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + ZipFile zip = new ZipFile(mainJar) + try { + List names = zip.entries().collect { it.name } + [ + 'mcmod.info', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'zone/moddev/mc/orespawn/api/OreGenerationContext.class', + 'zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', + 'META-INF/orespawn/docs/schemas/orespawn-global.schema.json', + 'META-INF/orespawn/docs/schemas/orespawn-world.schema.json', + 'AGENTS.md' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + List trackedDocs = trackedDocumentationFiles() + if (trackedDocs.size() != 21) { + throw new GradleException("Expected 21 tracked documentation files, found ${trackedDocs.size()}") + } + trackedDocs.each { File source -> + String relative = documentationRelativePath(source) + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals(source.bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Packaged documentation differs for ${relative}") + } + } + + String metadata = zip.getInputStream(zip.getEntry('mcmod.info')) + .getText(StandardCharsets.UTF_8.name()) + def parsed = new JsonSlurper().parseText(metadata) + def mod = parsed instanceof List ? parsed.first() : parsed + if (mod.modid != 'orespawn' || mod.version != project.mod_version + || mod.mcversion != project.minecraft_version) { + throw new GradleException('Packaged mcmod.info version or target is incorrect') + } + + String packagedAccessTransformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List packagedAccessTransformerRules = packagedAccessTransformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRuntimeAccessTransformerRules = [ + 'public-f net.minecraft.world.WorldProvider field_76578_c', + 'public-f net.minecraft.world.gen.ChunkProviderOverworld field_186001_t' + ] + if (packagedAccessTransformerRules != expectedRuntimeAccessTransformerRules) { + throw new GradleException('Packaged access transformer was not remapped to the runtime SRG rules') + } + + 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('Specification-Vendor') != project.mod_vendor + || manifest.getValue('Implementation-Vendor') != project.mod_vendor + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null + || manifest.getValue('Timestamp') != null) { + throw new GradleException('Release manifest identity/API/FMLAT 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) != header.length) { + throw new GradleException("Cannot inspect bytecode header for ${entry.name}") + } + } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 52) { + throw new GradleException("${entry.name} uses Java class major ${major}, expected 52") + } + } + } finally { + zip.close() + } + + File sources = new File(libs, expectedReleaseFiles.get()[1]) + new ZipFile(sources).withCloseable { sourceZip -> + if (sourceZip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + File javadocs = new File(libs, expectedReleaseFiles.get()[2]) + new ZipFile(javadocs).withCloseable { javadocZip -> + if (javadocZip.getEntry('index.html') == null) { + throw new GradleException('Javadoc jar is missing index.html') + } + } + } +} + +// The release dispatcher uses this small, deterministic manifest to identify +// the exact three files validated above. +tasks.register('writeReleaseChecksums') { + group = 'verification' + description = 'Writes SHA-256 checksums for the audited release jars.' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + inputs.files(providers.provider { + expectedReleaseFiles.get().collect { name -> + layout.buildDirectory.file("libs/${name}").get().asFile + } + }) + 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') + } +} + +// CI can hand publication an immutable prepared bundle. Recheck it instead of +// silently rebuilding different jars while uploading. +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + description = 'Audits a previously built immutable release bundle before remote Maven publication.' + + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required for prepared artifact publication') + } + File prepared = file(preparedReleaseDir.get()) + if (!prepared.isDirectory()) { + throw new GradleException("Prepared release directory does not exist: ${prepared}") + } + List expected = expectedReleaseFiles.get().sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') } + .sort { it.name } + if (jars.collect { it.name } != expected || jars.any { it.length() == 0L }) { + throw new GradleException("Prepared release must contain exactly the non-empty jars ${expected}") + } + File checksums = new File(prepared, 'SHA256SUMS') + File changelog = new File(prepared, 'CHANGELOG.txt') + if (!checksums.isFile() || !changelog.isFile()) { + throw new GradleException('Prepared release is missing SHA256SUMS or CHANGELOG.txt') + } + List actualChecksums = 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() + List declaredChecksums = checksums.readLines('UTF-8') + .findAll { !it.trim().isEmpty() } + .sort() + if (actualChecksums != declaredChecksums) { + throw new GradleException('Prepared release checksums do not match the immutable jars') + } + } +} + +ext.oreSpawnRelease = [ + jar: releaseJar, + expectedFiles: expectedReleaseFiles, + preparedDirectory: preparedReleaseDir +].asImmutable() diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..4529097a --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,118 @@ +import groovy.xml.XmlSlurper + +def buildValues = project.ext.oreSpawnBuild +def releaseValues = project.ext.oreSpawnRelease +def expectedMavenGroup = buildValues.mavenGroup +def expectedMavenArtifact = buildValues.mavenArtifact +def expectedReleaseFiles = releaseValues.expectedFiles +def preparedReleaseDir = releaseValues.preparedDirectory +def releaseJar = releaseValues.jar + +// Local builds may configure publication without credentials. The publish +// tasks themselves still reject a missing or local upload destination. +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 = project.mod_group + artifactId = project.mod_artifact + 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 = project.mod_name + description = project.mod_description + url = project.mod_url + licenses { + license { + name = project.mod_license_name + url = project.mod_license_url + } + } + scm { + connection = project.mod_scm_connection + developerConnection = project.mod_scm_developer_connection + url = project.mod_url + } + } + } + } + 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' + description = 'Prevents Maven publication from targeting a local or incomplete repository.' + 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') + } + String target = providers.environmentVariable('MAVEN_UPLOAD_URL').get() + if (target.startsWith('file:')) { + throw new GradleException('Maven release publication must use a remote repository') + } + } +} + +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + if (preparedReleaseDir.isPresent()) { + dependsOn tasks.named('verifyPreparedReleaseArtifacts') + } else { + dependsOn tasks.named('verifyReleaseArtifacts') + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn tasks.named('verifyDocumentationParity') +} diff --git a/gradle/verification/client-and-benchmark.gradle b/gradle/verification/client-and-benchmark.gradle new file mode 100644 index 00000000..2af7c6d0 --- /dev/null +++ b/gradle/verification/client-and-benchmark.gradle @@ -0,0 +1,146 @@ +def configureFromForgeRun = project.ext.oreSpawnVerification.configureFromForgeRun + +// The client probe exercises the editor and an actual fresh/save/reload world. +// It is built separately so none of its classes enter a public OreSpawn jar. +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +task compileClientIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + options.encoding = 'UTF-8' +} + +task clientIntegrationTestModJar(type: Jar, dependsOn: 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') +} + +tasks.register('preparePackagedClientProbe') { + group = 'verification' + description = 'Builds the reobfuscated client probe used only with a disposable packaged Forge runtime.' + dependsOn packagedClientProbeJar +} + +def clientIntegrationRunDirectory = file("${buildDir}/client-integration-run") +task prepareClientIntegrationTest(dependsOn: clientIntegrationTestModJar) { + doLast { + delete clientIntegrationRunDirectory + clientIntegrationRunDirectory.mkdirs() + copy { + from clientIntegrationTestModJar.archiveFile + into new File(clientIntegrationRunDirectory, 'mods') + } + new File(clientIntegrationRunDirectory, 'options.txt').setText( + 'fullscreen:false\nforceUnicodeFont:false\nguiScale:2\n' + + 'renderDistance:4\nshowSubtitles:false\n', 'UTF-8') + } +} + +task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegrationTest) { + group = 'verification' + workingDir clientIntegrationRunDirectory + systemProperty 'clientprobe.enabled', 'true' + configureFromForgeRun(delegate, 'runClient') + doLast { + File marker = new File(clientIntegrationRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException("Client integration completion marker is missing: ${marker}") + } + Properties result = new Properties(); marker.withInputStream { result.load(it) } + if (result.getProperty('reload_rendered') != 'true' + || result.getProperty('world_settings_opened') != 'true' + || result.getProperty('mods_directory_rendered') != 'true' + || result.getProperty('directory_stack_validated') != 'true' + || result.getProperty('ore_sources_validated') != 'true' + || result.getProperty('ore_sources_layout_validated') != 'true' + || result.getProperty('long_editor_roundtrip') != 'true' + || Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) { + throw new GradleException("Client integration result is incomplete: ${marker}") + } + File latest = new File(clientIntegrationRunDirectory, 'logs/fml-client-latest.log') + if (latest.isFile() && (latest.text.contains('Encountered an unexpected exception') + || latest.text.contains('Reported exception thrown!'))) { + throw new GradleException("Client integration logged a runtime failure: ${latest}") + } + List allowedDevelopmentErrors = [ + 'The binary patch set is missing', + 'FML appears to be missing any signature data', + 'Unable to read a class file correctly', + 'problem reading the entry META-INF/versions/11/', + 'Trying to activate the universal filled bucket too late. Call it statically in your Mods class. Mod: Base Metals' + ] + List unaccountedErrors = latest.readLines('UTF-8').findAll { line -> + (line.contains('/ERROR]') || line.contains('/FATAL]')) + && !allowedDevelopmentErrors.any { line.contains(it) } + } + if (!unaccountedErrors.isEmpty()) { + throw new GradleException("Client integration logged unaccounted errors: ${unaccountedErrors}") + } + File crashDirectory = new File(clientIntegrationRunDirectory, 'crash-reports') + if (crashDirectory.isDirectory() && crashDirectory.listFiles().length > 0) { + throw new GradleException("Client integration produced crash reports: ${crashDirectory}") + } + } +} + +// Benchmarks are opt-in: routine builds do not generate large timed worlds. +if (project.hasProperty('benchmarkRunDir')) { + def benchmarkRunDirectory = file(project.property('benchmarkRunDir')) + task prepareWorldgenBenchmark { + doLast { + delete benchmarkRunDirectory + benchmarkRunDirectory.mkdirs() + new File(benchmarkRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(benchmarkRunDirectory, 'server.properties').setText( + "online-mode=false\nlevel-name=world\nlevel-type=${project.findProperty('benchmarkLevelType') ?: 'default'}\n" + + "level-seed=${project.findProperty('benchmarkSeed') ?: '-4965128775892001975'}\n" + + 'generate-structures=false\nspawn-animals=false\nspawn-monsters=false\n' + + 'max-tick-time=-1\n', 'UTF-8') + } + } + task benchmarkIntegrationProcess(type: JavaExec, dependsOn: prepareWorldgenBenchmark) { + group = 'verification' + workingDir benchmarkRunDirectory + systemProperty 'orespawn.worldgenBenchmarkMode', project.findProperty('benchmarkMode') ?: 'sky' + systemProperty 'orespawn.worldgenBenchmarkRadius', project.findProperty('benchmarkRadius') ?: '4' + systemProperty 'orespawn.worldgenBenchmarkRepetitions', project.findProperty('benchmarkRepetitions') ?: '3' + systemProperty 'orespawn.worldgenBenchmarkCenterX', project.findProperty('benchmarkCenterX') ?: '256' + systemProperty 'orespawn.worldgenBenchmarkCenterZ', project.findProperty('benchmarkCenterZ') ?: '256' + systemProperty 'orespawn.worldgenBenchmarkCenterStep', project.findProperty('benchmarkCenterStep') ?: '64' + systemProperty 'orespawn.worldgenBenchmarkStopServer', 'true' + systemProperty 'orespawn.worldgenBenchmarkVanillaOres', + project.findProperty('benchmarkVanillaOres') ?: 'false' + systemProperty 'orespawn.worldgenBenchmarkOreAudit', + project.findProperty('benchmarkOreAudit') ?: 'false' + if (project.hasProperty('benchmarkBlockAudit')) { + systemProperty 'orespawn.worldgenBenchmarkBlockAudit', project.property('benchmarkBlockAudit') + } + ['benchmarkBiomeType': 'orespawn.worldgenBenchmarkBiomeType', + 'benchmarkBiome': 'orespawn.worldgenBenchmarkBiome', + 'benchmarkNormalizeBlock': 'orespawn.worldgenBenchmarkNormalizeBlock', + 'benchmarkNormalizeBiome': 'orespawn.worldgenBenchmarkNormalizeBiome'].each { propertyName, systemName -> + if (project.hasProperty(propertyName)) { + systemProperty systemName, project.property(propertyName) + } + } + configureFromForgeRun(delegate, 'runServer') + doLast { + File latest = new File(benchmarkRunDirectory, 'logs/fml-server-latest.log') + String mode = project.findProperty('benchmarkMode') ?: 'sky' + if (!latest.isFile() || !latest.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { + throw new GradleException("Missing ${mode} worldgen benchmark summary: ${latest}") + } + if (latest.text.contains('Encountered an unexpected exception') + || latest.text.contains('Tried to assign a mutable BlockPos')) { + throw new GradleException("Worldgen benchmark logged a runtime failure: ${latest}") + } + } + } +} diff --git a/gradle/verification/legacy-and-migration.gradle b/gradle/verification/legacy-and-migration.gradle new file mode 100644 index 00000000..139c8001 --- /dev/null +++ b/gradle/verification/legacy-and-migration.gradle @@ -0,0 +1,311 @@ +def verification = project.ext.oreSpawnVerification +def legacyFixtureArtifacts = verification.fixtureArtifacts +def legacyFixtureWorlds = verification.fixtureWorlds +def verifyLegacyFixtures = tasks.named('verifyLegacyFixtures') +def configureFromForgeRun = verification.configureFromForgeRun + +// These disposable worlds prove that old saves and binary consumers still +// behave as they did before the build changed. +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 + 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 createSurfaceProcess = { String phase, Object dependency -> + task("surfaceIntegration${phase}Process", type: JavaExec, dependsOn: dependency) { + group = 'verification' + workingDir surfaceIntegrationRunDirectory + systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase() + configureFromForgeRun(delegate, 'runServer') + } +} + +def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) +surfaceIntegrationFreshProcess.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}") + } +} +def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) + +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}") + } + if (result.getProperty('ore_sources_reset_verified') != 'true') { + throw new GradleException("Ore-source editor/profile/generation integration was not verified: ${marker}") + } + if (result.getProperty('biome_override_verified') != 'true') { + throw new GradleException("Biome editor/profile/new-terrain integration was not verified: ${marker}") + } + if (!(result.getProperty('dynamic_fluid_placements') ?: '0').isInteger() + || result.getProperty('dynamic_fluid_placements').toInteger() <= 0) { + throw new GradleException("Dynamic fluid-deposit probe did not place any blocks: ${marker}") + } + [ + 'end.raw_natural_sources': '108', + 'end.structure_natural_sources': '108', + 'end.vegetation_natural_sources': '108', + 'end.cave_pockets': '54', + 'end.underwater_pockets': '54', + 'end.raw_bedrock': '9', + 'end.raw_block_entities': '9', + 'end.exposed_snow_converted': '9', + 'end.surface_ice_converted': '9', + 'end.buried_snow_preserved': '9', + 'end.buried_ice_preserved': '9', + 'nether.unconfigured_snow_preserved': '9', + 'nether.unconfigured_ice_preserved': '9' + ].each { key, expected -> + if (result.getProperty(key) != expected) { + throw new GradleException("Unexpected surface integration result ${key}: " + + "expected ${expected}, found ${result.getProperty(key)}") + } + } + File latest = new File(surfaceIntegrationRunDirectory, 'logs/latest.log') + if (!latest.isFile() || latest.text.contains('Tried to assign a mutable BlockPos')) { + throw new GradleException("Surface integration leaked a mutable position into tick data: ${latest}") + } + 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 + +// The migration probe starts from a copy of an old world, then reloads that +// exact copy to catch a second conversion or changed profile. +def migrationIntegrationClasses = file("${buildDir}/migration-integration-fixture/classes") +task compileMigrationIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/migrationIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = migrationIntegrationClasses + options.encoding = 'UTF-8' +} + +task migrationIntegrationTestModJar(type: Jar, dependsOn: compileMigrationIntegrationTestMod) { + archiveFileName = 'migrationprobe.jar' + destinationDirectory = file("${buildDir}/migration-integration-fixture") + from migrationIntegrationClasses + from 'src/migrationIntegrationTest/resources' +} + +if (project.hasProperty('migrationRunDir')) { + def migrationRunDirectory = file(project.property('migrationRunDir')) + task prepareMigrationIntegrationRun(dependsOn: migrationIntegrationTestModJar) { + doLast { + copy { from migrationIntegrationTestModJar.archiveFile; into new File(migrationRunDirectory, 'mods') } + new File(migrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + File serverProperties = new File(migrationRunDirectory, 'server.properties') + Properties values = new Properties() + if (serverProperties.isFile()) serverProperties.withInputStream { values.load(it) } + values.setProperty('spawn-animals', 'false') + values.setProperty('spawn-monsters', 'false') + serverProperties.withOutputStream { values.store(it, 'OreSpawn migration qualification') } + } + } + task migrationIntegrationProcess(type: JavaExec, dependsOn: prepareMigrationIntegrationRun) { + group = 'verification' + workingDir migrationRunDirectory + systemProperty 'orespawn.migrationFamily', project.findProperty('migrationFamily') ?: 'unspecified' + systemProperty 'orespawn.migrationPhase', project.findProperty('migrationPhase') ?: 'fresh' + configureFromForgeRun(delegate, 'runServer') + doLast { + String phase = project.findProperty('migrationPhase') ?: 'fresh' + File marker = new File(migrationRunDirectory, 'world/orespawn4-migration-probe.properties') + if (!marker.isFile()) throw new GradleException("Migration probe did not produce ${marker}") + Properties values = new Properties(); marker.withInputStream { values.load(it) } + if (values.getProperty("${phase}_complete") != 'true') { + throw new GradleException("Migration ${phase} phase did not complete: ${marker}") + } + File latest = new File(migrationRunDirectory, 'logs/latest.log') + if (latest.isFile() && (latest.text.contains('Migration audit failed') + || latest.text.contains('Encountered an unexpected exception'))) { + throw new GradleException("Migration ${phase} phase logged a server failure: ${latest}") + } + } + } +} + +// This 1.10-only gate is live code: existing Mineralogy 3 worlds must keep +// their Cyano settings even when modern provider defaults are installed. +def legacyMineralogyMigrationRunDirectory = file("${buildDir}/legacy-mineralogy-migration-run") +def legacyMineralogyMigrationArchive = new File(legacyFixtureWorlds, + 'sylvester-era-trio-source-v2.zip') + +task prepareLegacyMineralogyMigrationRun(dependsOn: [migrationIntegrationTestModJar, + verifyLegacyFixtures]) { + doLast { + delete legacyMineralogyMigrationRunDirectory + copy { from zipTree(legacyMineralogyMigrationArchive); into legacyMineralogyMigrationRunDirectory } + File mods = new File(legacyMineralogyMigrationRunDirectory, 'mods') + mods.mkdirs() + copy { + from new File(legacyFixtureArtifacts, 'BaseMetals_1.10.2-2.4.0.11.jar') + from new File(legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') + from migrationIntegrationTestModJar.archiveFile + into mods + } + new File(legacyMineralogyMigrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def configureLegacyMineralogyMigrationProcess = { JavaExec process, String phase -> + process.group = 'verification' + process.workingDir legacyMineralogyMigrationRunDirectory + process.systemProperty 'orespawn.migrationFamily', 'legacy-mineralogy-cyano' + process.systemProperty 'orespawn.migrationPhase', phase + configureFromForgeRun(process, 'runServer') + process.doLast { + File marker = new File(legacyMineralogyMigrationRunDirectory, + 'world/orespawn4-migration-probe.properties') + if (!marker.isFile()) throw new GradleException("Legacy Mineralogy migration did not produce ${marker}") + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty("${phase}_complete") != 'true') { + throw new GradleException("Legacy Mineralogy migration ${phase} phase did not complete") + } + } +} + +task legacyMineralogyMigrationFresh(type: JavaExec, dependsOn: prepareLegacyMineralogyMigrationRun) +configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationFresh, 'fresh') + +task legacyMineralogyMigrationReload(type: JavaExec, dependsOn: legacyMineralogyMigrationFresh) +configureLegacyMineralogyMigrationProcess(legacyMineralogyMigrationReload, 'reload') + +task legacyMineralogyMigrationTest(dependsOn: legacyMineralogyMigrationReload) { + group = 'verification' + description = 'Proves an existing Mineralogy 3 world is pinned to Cyano settings across reload.' + doLast { + File marker = new File(legacyMineralogyMigrationRunDirectory, + 'world/orespawn4-migration-probe.properties') + Properties values = new Properties(); marker.withInputStream { values.load(it) } + ['fresh_complete', 'reload_complete', 'legacy_mineralogy_config_sha256', + 'legacy_mineralogy_world_profile_sha256'].each { key -> + if (!values.getProperty(key)) throw new GradleException("Missing legacy Mineralogy evidence ${key}") + } + logger.lifecycle('Existing Mineralogy 3 world retained Cyano profile and exact config/profile hashes across reload') + } +} + +check.dependsOn legacyMineralogyMigrationTest + +// Compile published OS1 and OS3 consumers unchanged. These manual checks +// protect old binary contracts without putting their classes in the release. +def os1AbiFixtureClasses = file("${buildDir}/legacy-abi/os1/classes") +def os3AbiFixtureClasses = file("${buildDir}/legacy-abi/os3/classes") + +task compileOs1AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { + source fileTree('src/os1AbiFixture/java') + classpath = files(sourceSets.main.compileClasspath, + new File(legacyFixtureArtifacts, 'OreSpawn_1.10.2-1.1.0.jar')) + destinationDirectory = os1AbiFixtureClasses + options.encoding = 'UTF-8' +} + +task os1AbiFixtureJar(type: Jar, dependsOn: compileOs1AbiFixture) { + archiveFileName = 'os1abiprobe.jar' + destinationDirectory = file("${buildDir}/legacy-abi/os1") + from os1AbiFixtureClasses + from 'src/os1AbiFixture/resources' +} + +task compileOs3AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { + source fileTree('src/os3AbiFixture/java') + classpath = files(sourceSets.main.compileClasspath, + new File(legacyFixtureArtifacts, 'OreSpawn-1.10.2-3.2.2.104.jar')) + destinationDirectory = os3AbiFixtureClasses + options.encoding = 'UTF-8' +} + +task os3AbiFixtureJar(type: Jar, dependsOn: compileOs3AbiFixture) { + archiveFileName = 'os3abiprobe.jar' + destinationDirectory = file("${buildDir}/legacy-abi/os3") + from os3AbiFixtureClasses + from 'src/os3AbiFixture/resources' +} + +def configureLegacyAbiProcess = { String generation, Task fixtureJar, String markerName -> + File runDirectory = file("${buildDir}/legacy-abi/${generation}/run") + Task prepare = tasks.create("prepare${generation.capitalize()}AbiRun", Copy) { + dependsOn fixtureJar + into new File(runDirectory, 'mods') + from fixtureJar.archiveFile + doFirst { project.delete(runDirectory) } + doLast { + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText( + 'eula=true\nonline-mode=false\nlevel-name=world\nlevel-seed=zsjpxah\n' + + 'spawn-animals=false\nspawn-monsters=false\n', 'UTF-8') + } + } + tasks.create("${generation}AbiIntegrationProcess", JavaExec) { + group = 'verification' + dependsOn prepare + workingDir runDirectory + configureFromForgeRun(delegate, 'runServer') + doLast { + File marker = new File(runDirectory, "world/${markerName}") + if (!marker.isFile() || !marker.text.contains('registered=true')) { + throw new GradleException("${generation.toUpperCase()} binary ABI probe did not complete: ${marker}") + } + File latest = new File(runDirectory, 'logs/latest.log') + if (latest.isFile() && latest.text.contains('Encountered an unexpected exception')) { + throw new GradleException("${generation.toUpperCase()} binary ABI probe logged a server failure") + } + } + } +} + +configureLegacyAbiProcess('os1', os1AbiFixtureJar, 'os1-abi-probe.properties') +configureLegacyAbiProcess('os3', os3AbiFixtureJar, 'os3-abi-probe.properties') + +task legacyAbiIntegrationTest { + group = 'verification' + dependsOn os1AbiIntegrationProcess, os3AbiIntegrationProcess +} + +check.dependsOn legacyAbiIntegrationTest diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..cf89f0d1 --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,167 @@ +import java.security.MessageDigest + +def forge10DevelopmentOutput = project.ext.oreSpawnBuild.developmentOutput + +// These are sealed copies of old mods and saves, not dependencies of the +// shipped jar. Check their hashes before a test can use them. +def legacyFixtureRoot = file("${rootDir}/ci-fixtures") +def legacyFixtureArtifacts = new File(legacyFixtureRoot, 'artifacts') +def legacyFixtureWorlds = new File(legacyFixtureRoot, 'worlds') +def legacyMineralogyOracleJar = new File( + legacyFixtureArtifacts, 'Mineralogy-1.10.2-3.3.8.26.jar') +def legacyFixtureHashes = [ + 'artifacts/OreSpawn_1.10.2-1.1.0.jar': + '91345E4B825AA06E05F470066F467AABA0C7A2804A8DBCA4D3CD1905ED53607C', + 'artifacts/OreSpawn-1.10.2-3.2.2.104.jar': + 'D964148C20175E16CCEFAB02AE43A181A50DC08A1D636A77E59CFE8C8EA8D41A', + 'artifacts/BaseMetals_1.10.2-2.4.0.11.jar': + 'F919CEF794C73F02758BBD967346DBA0B53F6383153C7E29BD79F211CDA8C319', + 'artifacts/Mineralogy-1.10.2-3.3.8.26.jar': + '88A6237C9A0E2C8891718B68C373E741C78B8494F5E68D8093CA9339F3BC4D87', + 'worlds/sylvester-era-trio-source-v2.zip': + '11FC2FA44C43520AA9B21C29B43203EBFE0A84773D2BDCBB4F3C48C490559B07' +] + +task verifyLegacyFixtures { + group = 'verification' + description = 'Verifies the sealed ABI and migration corpus used only by isolated tests.' + inputs.files legacyFixtureHashes.keySet().collect { new File(legacyFixtureRoot, it) } + doLast { + legacyFixtureHashes.each { String relativePath, String expectedHash -> + File fixture = new File(legacyFixtureRoot, relativePath) + if (!fixture.isFile()) { + throw new GradleException("Missing sealed legacy fixture: ${fixture}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + fixture.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) + } + } + String actualHash = digest.digest().encodeHex().toString().toUpperCase() + if (actualHash != expectedHash) { + throw new GradleException("Legacy fixture hash mismatch for ${relativePath}: " + + "expected ${expectedHash}, found ${actualHash}") + } + } + } +} + +// The old Mineralogy jar is needed by parity tests in Eclipse. Keep it on +// the test classpath and make the production-classpath guard prove it stays there. +dependencies { + testImplementation files(legacyMineralogyOracleJar) +} + +tasks.named('compileTestJava', JavaCompile) { + dependsOn verifyLegacyFixtures +} +tasks.named('test', Test) { + dependsOn verifyLegacyFixtures +} + +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' + dependsOn verifyLegacyFixtures + + doLast { + File oracle = legacyMineralogyOracleJar.canonicalFile + def canonicalFiles = { FileCollection classpath -> + classpath.files.collect { it.canonicalFile } as Set + } + Set testCompileFiles = canonicalFiles(sourceSets.test.compileClasspath) + Set testRuntimeFiles = canonicalFiles(sourceSets.test.runtimeClasspath) + if (!testCompileFiles.contains(oracle) || !testRuntimeFiles.contains(oracle)) { + throw new GradleException('The sealed Mineralogy oracle is missing from the test classpath model') + } + + Map productionClasspaths = [ + mainCompile: sourceSets.main.compileClasspath, + mainRuntime: sourceSets.main.runtimeClasspath + ] + productionClasspaths.each { String name, FileCollection classpath -> + if (canonicalFiles(classpath).contains(oracle)) { + throw new GradleException("The sealed Mineralogy oracle leaked into ${name}") + } + } + } +} +tasks.named('check') { + dependsOn tasks.named('verifyLegacyOracleIsolation') +} + +def configureFromForgeRun = { JavaExec process, String runTaskName -> + process.actions.clear() + process.dependsOn { + JavaExec run = tasks.getByName(runTaskName) as JavaExec + run.taskDependencies.getDependencies(run) + } + process.doLast { + JavaExec run = tasks.getByName(runTaskName) as JavaExec + String runConfigurationName = runTaskName.substring(3).uncapitalize() + def runOptions = minecraft.runs.getByName(runConfigurationName) + File originalConfiguredWorkingDir = runOptions.workingDir.get().asFile + File originalWorkingDir = run.workingDir + List originalArgs = new ArrayList<>(run.args) + List originalJvmArgs = new ArrayList<>(run.jvmArgs) + Map originalSystemProperties = new LinkedHashMap<>(run.systemProperties) + Map originalEnvironment = new LinkedHashMap<>(run.environment) + FileCollection originalClasspath = files(run.classpath.files) + String originalMinHeap = run.minHeapSize + String originalMaxHeap = run.maxHeapSize + String originalMainClass = run.mainClass.orNull + try { + File forgeConfig = new File(process.workingDir, 'config/forge.cfg') + if (!forgeConfig.isFile()) { + forgeConfig.parentFile.mkdirs() + forgeConfig.setText('''general { + B:disableVersionCheck=true +} + +version_checking { + B:Global=false +} +''', 'UTF-8') + } + runOptions.workingDir.set(process.workingDir) + run.workingDir(process.workingDir) + run.args(process.args) + run.jvmArgs(process.jvmArgs) + run.systemProperties(process.systemProperties) + run.environment(process.environment) + Set splitMainOutputs = [ + tasks.named('compileJava', JavaCompile).get().destinationDirectory.get().asFile, + sourceSets.main.output.resourcesDir + ] as Set + run.setClasspath(files(forge10DevelopmentOutput, run.classpath.filter { + !splitMainOutputs.contains(it) + })) + run.exec() + } finally { + runOptions.workingDir.set(originalConfiguredWorkingDir) + run.workingDir(originalWorkingDir) + run.setArgs(originalArgs) + run.setJvmArgs(originalJvmArgs) + run.setSystemProperties(originalSystemProperties) + run.setEnvironment(originalEnvironment) + run.setClasspath(originalClasspath) + run.minHeapSize = originalMinHeap + run.maxHeapSize = originalMaxHeap + if (originalMainClass == null) { + run.mainClass.unset() + } else { + run.mainClass.set(originalMainClass) + } + } + } +} + +// Applied scripts do not share their local variables. Expose only the few +// values used by the 1.10 migration and client gates. +ext.oreSpawnVerification = [ + fixtureArtifacts: legacyFixtureArtifacts, + fixtureWorlds: legacyFixtureWorlds, + configureFromForgeRun: configureFromForgeRun +].asImmutable() diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java new file mode 100644 index 00000000..53eb9fd8 --- /dev/null +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java @@ -0,0 +1,43 @@ +package zone.moddev.mc.orespawn.client; + +import com.google.gson.JsonObject; + +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Test-fixture bridge that persists an exact replacement through the real editor model. */ +public final class BiomeEditorProbeBridge { + private BiomeEditorProbeBridge() { } + + public static JsonObject replace(JsonObject root, String dimension, + String source, String target, String unmanaged) { + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + GeologyEditorSession session = new GeologyEditorSession(profile); + BiomeDirectoryModel.Snapshot before = session.biomeDirectory(); + long providerPalettes = before.palettes(dimension).stream() + .filter(palette -> palette.providerOwned).count(); + BiomeDirectoryModel.BiomeEntry external = before.entries(dimension, true).stream() + .filter(entry -> unmanaged.equals(entry.id)).findFirst() + .orElseThrow(() -> new IllegalStateException("Missing unmanaged fixture biome " + unmanaged)); + if (providerPalettes != 2 || external.status != BiomeDirectoryModel.Status.UNMANAGED) { + throw new IllegalStateException("Biome directory did not expose two provider palettes " + + "and one unmanaged biome"); + } + session.replaceBiome(dimension, source, target); + JsonObject saved = session.profile().rootCopy(); + String actual = new GeologyEditorSession(WorldGeologyProfile.fromJson(saved, profile)) + .biomeReplacements(dimension).get(source); + if (!target.equals(actual)) { + throw new IllegalStateException("Biome editor replacement did not round-trip: " + + source + " -> " + actual); + } + BiomeDirectoryModel.BiomeEntry replaced = new GeologyEditorSession( + WorldGeologyProfile.fromJson(saved, profile)).biomeDirectory() + .entries(dimension, true).stream().filter(entry -> source.equals(entry.id)) + .findFirst().orElseThrow(() -> new IllegalStateException("Missing replaced biome " + source)); + if (replaced.status != BiomeDirectoryModel.Status.USER_REPLACED) { + throw new IllegalStateException("Biome directory did not classify the replacement"); + } + return saved; + } +} diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java new file mode 100644 index 00000000..5fc41e6f --- /dev/null +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/OreSourceEditorProbeBridge.java @@ -0,0 +1,116 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.gson.JsonObject; + +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Test-fixture bridge that drives the real pending editor model on a server. */ +public final class OreSourceEditorProbeBridge { + private static final String DOMAIN = "minecraft:overworld"; + private static final String CHANNEL = "orespawn:standard"; + + private OreSourceEditorProbeBridge() { } + + public static JsonObject configure(JsonObject root) { + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + GeologyEditorSession session = new GeologyEditorSession(profile); + + configureBalanced(session, "surfaceprobe:balanced", "surfaceprobe:balanced_a"); + configureSingle(session, "surfaceprobe:single", "surfaceprobe:single_b", + "surfaceprobe:single_a"); + configureCustom(session, "surfaceprobe:custom", "surfaceprobe:custom_a", + "surfaceprobe:custom_b"); + configureBalanced(session, "surfaceprobe:body", "surfaceprobe:body_a"); + + GeologyEditorSession.OreSourceGroup original = group(session, "surfaceprobe:original"); + session.acceptOreSourcePolicy(original.key); + assertGroup(group(session, "surfaceprobe:original"), "keep_separate", 2, + map("surfaceprobe:original_a", 1.0D, "surfaceprobe:original_b", 1.0D)); + return session.profile().rootCopy(); + } + + public static JsonObject reset(JsonObject root) { + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + GeologyEditorSession session = new GeologyEditorSession(profile); + session.resetOreSourcesToDefaults(); + for (String material : new String[] { "surfaceprobe:balanced", "surfaceprobe:single", + "surfaceprobe:custom", "surfaceprobe:original", "surfaceprobe:body" }) { + GeologyEditorSession.OreSourceGroup group = group(session, material); + if (!"keep_separate".equals(group.mode) || group.outputCandidates().size() != 2) { + throw new IllegalStateException("Reset All did not restore Keep Original for " + + material + ": mode=" + group.mode + ", candidates=" + + group.outputCandidates().size()); + } + } + return session.profile().rootCopy(); + } + + private static void configureBalanced(GeologyEditorSession session, String material, + String placement) { + GeologyEditorSession.OreSourceGroup group = group(session, material); + session.setOreSourceOutputMode(group.key, "balanced"); + session.setOreSourcePlacement(group.key, CHANNEL, placement); + assertGroup(group(session, material), "consolidated", 2, + map(material + "_a", 1.0D, material + "_b", 1.0D)); + } + + private static void configureSingle(GeologyEditorSession session, String material, + String selected, String placement) { + GeologyEditorSession.OreSourceGroup group = group(session, material); + session.setOreSourceOutputMode(group.key, "single"); + session.setOreSourceOutput(group.key, selected, true, 1.0D); + session.setOreSourcePlacement(group.key, CHANNEL, placement); + assertGroup(group(session, material), "consolidated", 1, + map(selected, 1.0D)); + } + + private static void configureCustom(GeologyEditorSession session, String material, + String first, String second) { + GeologyEditorSession.OreSourceGroup group = group(session, material); + session.setOreSourceOutputMode(group.key, "custom"); + session.setOreSourceOutput(group.key, first, true, 1.0D); + session.setOreSourceOutput(group.key, second, true, 3.0D); + session.setOreSourcePlacement(group.key, CHANNEL, first); + assertGroup(group(session, material), "consolidated", 2, + map(first, 1.0D, second, 3.0D)); + } + + private static void assertGroup(GeologyEditorSession.OreSourceGroup group, String mode, + int outputCount, Map outputs) { + if (!mode.equals(group.mode) || group.outputs.size() != outputCount + || !outputs.equals(group.outputs)) { + throw new IllegalStateException("Unexpected editor policy for " + group.material + + ": mode=" + group.mode + ", outputs=" + group.outputs); + } + if ("consolidated".equals(mode) + && !group.placements.containsKey(CHANNEL)) { + throw new IllegalStateException("Missing Standard veins placement for " + group.material); + } + } + + private static GeologyEditorSession.OreSourceGroup group(GeologyEditorSession session, + String material) { + for (GeologyEditorSession.OreSourceGroup group : session.oreSourceGroups()) { + if (material.equals(group.material) && DOMAIN.equals(group.domain)) return group; + } + throw new IllegalStateException("Missing editor material group " + material); + } + + private static Map map(String first, double firstWeight, + String second, double secondWeight) { + Map result = map(first, firstWeight); + result.put(second, Double.valueOf(secondWeight)); + return result; + } + + private static Map map(String source, double weight) { + Map result = new LinkedHashMap<>(); + result.put(source, Double.valueOf(weight)); + return result; + } +} diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index 32c4bb63..394c1986 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -5,14 +5,22 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Random; import java.util.Set; import java.util.LinkedHashSet; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -27,6 +35,8 @@ import zone.moddev.mc.orespawn.api.BiomeRegionSize; import zone.moddev.mc.orespawn.api.BiomeReplacementScope; import zone.moddev.mc.orespawn.api.GeologyFamily; +import zone.moddev.mc.orespawn.api.GeologyColumn; +import zone.moddev.mc.orespawn.api.OreGenerationContext; import zone.moddev.mc.orespawn.api.OrePatternType; import zone.moddev.mc.orespawn.api.OreSpawnApi; import zone.moddev.mc.orespawn.api.OreSpawnBiomes; @@ -38,6 +48,8 @@ import zone.moddev.mc.orespawn.api.WorldgenProvider; import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition; import zone.moddev.mc.orespawn.api.WorldgenProvider.TerrainDimensionDefinition; +import zone.moddev.mc.orespawn.client.OreSourceEditorProbeBridge; +import zone.moddev.mc.orespawn.client.BiomeEditorProbeBridge; import zone.moddev.mc.orespawn.worldgen.SurfaceProbeSpringBridge; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; @@ -89,9 +101,21 @@ public final class SurfaceProbeTestMod { private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld"); private static final ResourceLocation BIOME_A = new ResourceLocation(MODID, "surface_a"); private static final ResourceLocation BIOME_B = new ResourceLocation(MODID, "surface_b"); + private static final ResourceLocation BIOME_UNMANAGED = new ResourceLocation(MODID, "unmanaged"); private static final ResourceLocation PROBE_GEOME = new ResourceLocation(MODID, "exact_biome"); private static final ResourceLocation SPRING_ROCK = new ResourceLocation(MODID, "rock/spring_host"); + private static final ResourceLocation STANDARD_CHANNEL = new ResourceLocation("orespawn", "standard"); + private static final long BODY_IDENTITY = 0x51A7F00D1234ABCDL; + private static final int ORE_MIN_CHUNK = 80; + private static final int ORE_MAX_CHUNK = 84; + private static final int RESET_MIN_CHUNK = 88; + private static final int RESET_MAX_CHUNK = 92; + private static final int OVERRIDE_MIN_CHUNK = 68; + private static final int OVERRIDE_MAX_CHUNK = 70; private static final ProbeLiquid DEPOSIT_FLUID = new ProbeLiquid(); + private static final Map SOURCE_BLOCKS = sourceBlocks(); + private static final Map SOURCE_PATTERNS = sourcePatterns(); + private static final List ORE_INVOCATIONS = new ArrayList<>(); private static final BlockPos SPRING_POS = new BlockPos(1128, 32, 1128); private static final IBlockState[] NATURAL_SOURCES = { Blocks.DIRT.getStateFromMeta(0), Blocks.GRASS.getDefaultState(), @@ -120,6 +144,7 @@ public final class SurfaceProbeTestMod { private static final String RAW_CHEST_ITEM_NAME = "surfaceprobe raw block entity sentinel"; private static final Block WEATHER_SNOW_REPLACEMENT = Blocks.WOOL; private static final Block WEATHER_ICE_REPLACEMENT = Blocks.PACKED_ICE; + private static final AtomicInteger EXTENDED_CONTEXT_INVOCATIONS = new AtomicInteger(); private static final ResourceLocation[] BUILT_IN_GEOMES = { new ResourceLocation("orespawn", "stable_craton"), new ResourceLocation("orespawn", "mountain_belt"), @@ -132,14 +157,97 @@ public final class SurfaceProbeTestMod { }; private static final OrePatternType EXTERNAL_PATTERN = OrePatternType.create( - StandardPatternSettings.CODEC, settings -> context -> false) + StandardPatternSettings.CODEC, settings -> context -> { + if (!(context instanceof OreGenerationContext)) { + throw new IllegalStateException("External pattern did not receive OreGenerationContext"); + } + OreGenerationContext generation = (OreGenerationContext) context; + if (generation.worldSeed() != 0L || !OVERWORLD.equals(generation.dimension()) + || generation.chunkX() != (generation.originX() >> 4) + || generation.chunkZ() != (generation.originZ() >> 4)) { + throw new IllegalStateException("External pattern received unstable generation identity"); + } + if (!generation.geologySampler().isPresent()) { + throw new IllegalStateException("Overworld pattern did not receive its geology sampler"); + } + GeologyColumn column = generation.geologySampler().get().sampleColumn( + generation.originX(), generation.originZ(), 64); + if (!generation.dimension().equals(column.dimension())) { + throw new IllegalStateException("Context and sampler dimensions disagree"); + } + EXTENDED_CONTEXT_INVOCATIONS.incrementAndGet(); + return false; + }) .setRegistryName(MODID, "external_probe"); + private static Map sourceBlocks() { + Map result = new LinkedHashMap<>(); + for (String group : Arrays.asList("balanced", "single", "custom", "original", "body")) { + result.put(group, new Block[] { + probeBlock(group + "_a"), probeBlock(group + "_b") + }); + } + return result; + } + + private static Block probeBlock(String name) { + return new Block(net.minecraft.block.material.Material.ROCK).setRegistryName(MODID, name) + .setUnlocalizedName(MODID + "." + name); + } + + private static Map sourcePatterns() { + Map result = new LinkedHashMap<>(); + for (String group : Arrays.asList("balanced", "single", "custom", "original", "body")) { + result.put(group, sourcePattern(group, "body".equals(group))); + } + return result; + } + + private static OrePatternType sourcePattern(String group, boolean bodyIdentity) { + return OrePatternType.create(StandardPatternSettings.CODEC, settings -> context -> { + if (!(context instanceof OreGenerationContext)) { + throw new IllegalStateException("Ore source probe did not receive OreGenerationContext"); + } + OreGenerationContext generation = (OreGenerationContext) context; + List placed = new ArrayList<>(); + int height = generation.maxY() - generation.minY() + 1; + boolean changed = false; + for (int index = 0; index < generation.quantity(); index++) { + int y = generation.minY() + Math.floorMod( + generation.originY() - generation.minY() + index, height); + BlockPos position = new BlockPos(generation.originX(), y, generation.originZ()); + boolean accepted = bodyIdentity + ? generation.tryPlace(position.getX(), position.getY(), position.getZ(), BODY_IDENTITY) + : generation.tryPlace(position.getX(), position.getY(), position.getZ()); + if (accepted) placed.add(position); + changed |= accepted; + } + if (insideOreAudit(generation.chunkX(), generation.chunkZ())) { + synchronized (ORE_INVOCATIONS) { + ORE_INVOCATIONS.add(new OreInvocation(group, generation.chunkX(), + generation.chunkZ(), placed)); + } + } + return changed; + }).setRegistryName(MODID, group + "_source_probe"); + } + + private static boolean insideOreAudit(int chunkX, int chunkZ) { + return insideSquare(chunkX, chunkZ, ORE_MIN_CHUNK, ORE_MAX_CHUNK) + || insideSquare(chunkX, chunkZ, RESET_MIN_CHUNK, RESET_MAX_CHUNK); + } + + private static boolean insideSquare(int chunkX, int chunkZ, int minimum, int maximum) { + return chunkX >= minimum && chunkX <= maximum && chunkZ >= minimum && chunkZ <= maximum; + } + private final BiomeRegistrar registrar = OreSpawnBiomes.registrar(MODID); private final BiomeReference surfaceA = OreSpawnBiomes.blankAndRegister(registrar, "surface_a", properties -> configure(properties, 1.35F, 0.15F)); private final BiomeReference surfaceB = OreSpawnBiomes.blankAndRegister(registrar, "surface_b", properties -> configure(properties, 0.7F, 0.8F)); + private final BiomeReference unmanaged = OreSpawnBiomes.blankAndRegister(registrar, + "unmanaged", properties -> configure(properties, 0.8F, 0.4F)); private final Set preparedTerrain = new LinkedHashSet<>(); public SurfaceProbeTestMod() { @@ -159,16 +267,20 @@ public void registerBiomes(RegistryEvent.Register event) { surfaceB.get().theBiomeDecorator = new ProbeDecorator(); BiomeDictionary.registerBiomeType(surfaceA.get(), BiomeDictionary.Type.HOT, BiomeDictionary.Type.DRY); BiomeDictionary.registerBiomeType(surfaceB.get(), BiomeDictionary.Type.HOT, BiomeDictionary.Type.WET); + // This fixture is the unmanaged entry for the End directory exercised below. + BiomeDictionary.registerBiomeType(unmanaged.get(), BiomeDictionary.Type.END); } @SubscribeEvent public void registerBlocks(RegistryEvent.Register event) { event.getRegistry().register(DEPOSIT_FLUID); + for (Block[] blocks : SOURCE_BLOCKS.values()) event.getRegistry().registerAll(blocks); } @SubscribeEvent public void registerPatterns(RegistryEvent.Register event) { event.getRegistry().register(EXTERNAL_PATTERN); + event.getRegistry().registerAll(SOURCE_PATTERNS.values().toArray(new OrePatternType[0])); } @EventHandler @@ -216,9 +328,16 @@ public void init(FMLInitializationEvent event) { provider.fluidDeposit(new ResourceLocation(MODID, "fluid_deposit/dynamic_tick_probe"), id(DEPOSIT_FLUID), deposit -> deposit.dimension(OVERWORLD, dimension -> dimension.hostBlock(id(Blocks.STONE)))); + provider.ore(new ResourceLocation(MODID, "ore/generation_context_probe"), + id(Blocks.COAL_ORE), ore -> ore.retrogen(true).dimension(OVERWORLD, + dimension -> dimension.yRange(16, 48).attempts(1.0D).quantity(1) + .pattern(new ResourceLocation(MODID, "external_probe"), new JsonObject()) + .hostBlock(id(Blocks.STONE)))); + addOreSourceRules(provider); // This stable palette id makes seed zero select both fixture biomes on // opposite sides of the 1,024-block Tiny-region boundary. addPalette(provider, "end_palette_1", END, false); + addNoOpPalette(provider, "end_palette_2", END); addPalette(provider, "nether_palette_1", NETHER, true); provider.dimensionMaterials(new ResourceLocation(MODID, "materials/end"), END, materials -> materials.snowBlock(id(WEATHER_SNOW_REPLACEMENT)) @@ -228,6 +347,26 @@ public void init(FMLInitializationEvent event) { } } + private static void addOreSourceRules(WorldgenProvider.Builder provider) { + int minimumY = 8; + for (Map.Entry entry : SOURCE_BLOCKS.entrySet()) { + String group = entry.getKey(); + ResourceLocation material = new ResourceLocation(MODID, group); + ResourceLocation pattern = SOURCE_PATTERNS.get(group).getRegistryName(); + for (int index = 0; index < entry.getValue().length; index++) { + String suffix = index == 0 ? "a" : "b"; + ResourceLocation rule = new ResourceLocation(MODID, group + "_" + suffix); + Block output = entry.getValue()[index]; + int bandMin = minimumY; + provider.ore(rule, id(output), ore -> ore.material(material).retrogen(false) + .dimension(OVERWORLD, dimension -> dimension.yRange(bandMin, bandMin + 7) + .attempts(1.0D).quantity(3).pattern(pattern, new JsonObject()) + .placementChannel(STANDARD_CHANNEL).hostBlock(id(Blocks.STONE)))); + } + minimumY += 12; + } + } + private static void addGeology(WorldgenProvider.Builder provider) { provider.geome(PROBE_GEOME, geome -> geome.baseWeight(0.0D) .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D)); @@ -260,6 +399,7 @@ private static void addGeology(WorldgenProvider.Builder provider) { @EventHandler public void serverAboutToStart(FMLServerAboutToStartEvent event) { + String phase = System.getProperty(PHASE_PROPERTY, "").trim(); Path profile = worldRoot(event.getServer()).resolve("serverconfig") .resolve("orespawn-worldgen.json"); JsonObject root; @@ -274,8 +414,10 @@ public void serverAboutToStart(FMLServerAboutToStartEvent event) { JsonObject end = terrain.getAsJsonObject(END.toString()); JsonArray hosts = end.getAsJsonArray("host_blocks"); for (Block block : Arrays.asList(Blocks.AIR, Blocks.WATER, Blocks.BEDROCK, Blocks.CHEST)) { - hosts.add(new JsonPrimitive(id(block).toString())); + addUnique(hosts, id(block).toString()); } + if ("fresh".equals(phase)) root = OreSourceEditorProbeBridge.configure(root); + assertPersistedOreSourceModes(root, false); try (BufferedWriter writer = Files.newBufferedWriter(profile)) { new GsonBuilder().setPrettyPrinting().create().toJson(root, writer); } @@ -287,6 +429,13 @@ public void serverAboutToStart(FMLServerAboutToStartEvent event) { } } + private static void addUnique(JsonArray values, String value) { + for (int index = 0; index < values.size(); index++) { + if (value.equals(values.get(index).getAsString())) return; + } + values.add(new JsonPrimitive(value)); + } + private static void addPalette(WorldgenProvider.Builder provider, String name, ResourceLocation dimension, boolean ceiling) { BiomeSurfaceDefinition a = surface(Blocks.EMERALD_BLOCK, Blocks.QUARTZ_BLOCK, @@ -303,6 +452,17 @@ private static void addPalette(WorldgenProvider.Builder provider, String name, .temperature(-2.0D, 2.0D).downfall(0.0D, 1.0D).surface(b))); } + private static void addNoOpPalette(WorldgenProvider.Builder provider, String name, + ResourceLocation dimension) { + provider.biomePalette(new ResourceLocation(MODID, name), dimension, + palette -> palette.mode(BiomePlacementMode.AUGMENT) + .scope(BiomeReplacementScope.SELECTED_NAMESPACES) + .includeNamespace("unmatchedfixture") + .regionSize(BiomeRegionSize.TINY).coverage(1.0D).fallbackWeight(1.0D) + .biome(BIOME_A, biome -> biome.weight(1.0D)) + .biome(BIOME_B, biome -> biome.weight(1.0D))); + } + private static BiomeSurfaceDefinition surface(Block top, Block filler, Block underwater, Block ceiling) { BiomeSurfaceDefinition.Builder builder = BiomeSurfaceDefinition.builder() @@ -333,14 +493,26 @@ public void serverStarted(FMLServerStartedEvent event) { if (overworld == null || overworld.getSeed() != 0L) { throw new IllegalStateException("surfaceprobe requires seed token zsjpxah (hash zero)"); } + if ("fresh".equals(phase) && EXTENDED_CONTEXT_INVOCATIONS.get() <= 0) { + throw new IllegalStateException("External generation context probe did not run"); + } Path marker = worldRoot(server).resolve(MARKER_NAME); Properties previous = "reload".equals(phase) ? read(marker) : null; if ("fresh".equals(phase) && Files.exists(marker)) { throw new IllegalStateException("Fresh surfaceprobe retained an old marker"); } Map results = new LinkedHashMap<>(); + OreSourceAudit oreSources; + if ("fresh".equals(phase)) { + clearOreInvocations(); + generateSquare(overworld, ORE_MIN_CHUNK, ORE_MAX_CHUNK, false); + oreSources = auditOreSources(overworld, ORE_MIN_CHUNK, ORE_MAX_CHUNK, false); + } else { + oreSources = scanOreSources(overworld, ORE_MIN_CHUNK, ORE_MAX_CHUNK); + } results.put("end", audit(requireWorld(server, 1), false)); results.put("nether", audit(requireWorld(server, -1), true)); + BiomeCount override = verifyBiomeOverride(server, phase, results.get("end")); ResourceLocation spring = auditSpring(overworld, phase); int dynamicFluidPlacements = DEPOSIT_FLUID.placements(); if ("fresh".equals(phase) && dynamicFluidPlacements <= 0) { @@ -351,9 +523,16 @@ public void serverStarted(FMLServerStartedEvent event) { + dynamicFluidPlacements); } Properties current = properties(overworld.getSeed(), results, spring); + oreSources.write(current, "ore_sources."); + current.setProperty("ore_sources.profile_sha256", sha256(worldRoot(server) + .resolve("serverconfig").resolve("orespawn-worldgen.json"))); current.setProperty("dynamic_fluid_placements", "fresh".equals(phase) ? Integer.toString(dynamicFluidPlacements) : previous.getProperty("dynamic_fluid_placements")); + current.setProperty("biome_override.biome_a", Integer.toString(override.biomeA)); + current.setProperty("biome_override.biome_b", Integer.toString(override.biomeB)); + current.setProperty("biome_override_verified", Boolean.toString( + override.biomeA == 0 && override.biomeB == override.total)); if (previous == null) { write(marker, current); } else { @@ -363,6 +542,13 @@ public void serverStarted(FMLServerStartedEvent event) { + previous.getProperty(key) + " but found " + current.getProperty(key)); } } + resetOreSources(server); + clearOreInvocations(); + generateSquare(overworld, RESET_MIN_CHUNK, RESET_MAX_CHUNK, true); + OreSourceAudit reset = auditOreSources(overworld, + RESET_MIN_CHUNK, RESET_MAX_CHUNK, true); + reset.write(previous, "ore_sources.reset."); + previous.setProperty("ore_sources_reset_verified", "true"); previous.setProperty("reload_verified", "true"); write(marker, previous); } @@ -371,6 +557,260 @@ public void serverStarted(FMLServerStartedEvent event) { server.initiateShutdown(); } + private static BiomeCount verifyBiomeOverride(MinecraftServer server, String phase, + Audit original) { + WorldServer end = requireWorld(server, 1); + if ("fresh".equals(phase)) { + Path profile = worldRoot(server).resolve("serverconfig").resolve("orespawn-worldgen.json"); + JsonObject edited = BiomeEditorProbeBridge.replace( + WorldGeologyProfileManager.activeProfile().rootCopy(), END.toString(), + BIOME_A.toString(), BIOME_B.toString(), BIOME_UNMANAGED.toString()); + try (BufferedWriter writer = Files.newBufferedWriter(profile)) { + new GsonBuilder().setPrettyPrinting().create().toJson(edited, writer); + } catch (IOException exception) { + throw new IllegalStateException("Could not persist biome replacement", exception); + } + if (!WorldGeologyProfileManager.reloadActiveProfile()) { + throw new IllegalStateException("Could not reload biome replacement"); + } + BiomeCount unchanged = countBiomes(end, MIN_CHUNK, MAX_CHUNK, false); + if (unchanged.biomeA != original.biomeA || unchanged.biomeB != original.biomeB) { + throw new IllegalStateException("Biome replacement rewrote existing chunks: " + unchanged); + } + generateSquare(end, OVERRIDE_MIN_CHUNK, OVERRIDE_MAX_CHUNK, false); + } + BiomeCount generated = countBiomes(end, OVERRIDE_MIN_CHUNK, OVERRIDE_MAX_CHUNK, true); + if (generated.biomeA != 0 || generated.biomeB != generated.total) { + throw new IllegalStateException("Terminal biome override did not control new terrain: " + + generated); + } + return generated; + } + + private static BiomeCount countBiomes(WorldServer world, int minimum, int maximum, + boolean requireGenerated) { + int a = 0, b = 0, total = 0; + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int chunkZ = minimum; chunkZ <= maximum; chunkZ++) { + for (int chunkX = minimum; chunkX <= maximum; chunkX++) { + Chunk chunk = world.getChunkProvider().provideChunk(chunkX, chunkZ); + if (requireGenerated && !chunk.isLoaded()) { + throw new IllegalStateException("Biome override chunk was not generated at " + + chunkX + "," + chunkZ); + } + for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) { + ResourceLocation id = world.getBiome(cursor.setPos( + (chunkX << 4) + x, 64, (chunkZ << 4) + z)).getRegistryName(); + if (BIOME_A.equals(id)) a++; + else if (BIOME_B.equals(id)) b++; + else throw new IllegalStateException("Unexpected biome in override audit: " + id); + total++; + } + } + } + return new BiomeCount(a, b, total); + } + + private static void assertPersistedOreSourceModes(JsonObject root, boolean reset) { + JsonObject policies = root.getAsJsonObject("ore_source_policies"); + if (policies == null) throw new IllegalStateException("Missing persisted ore-source policies"); + for (String group : SOURCE_BLOCKS.keySet()) { + String key = MODID + ":" + group + "|minecraft:overworld"; + if (!policies.has(key) || !policies.get(key).isJsonObject()) { + throw new IllegalStateException("Missing persisted ore-source policy " + key); + } + JsonObject policy = policies.getAsJsonObject(key); + String expected = reset || "original".equals(group) ? "keep_separate" : "consolidated"; + if (!expected.equals(policy.get("mode").getAsString())) { + throw new IllegalStateException("Unexpected persisted mode for " + group + ": " + policy); + } + if (!reset && "single".equals(group)) { + JsonObject outputs = policy.getAsJsonObject("outputs"); + if (outputs.entrySet().size() != 1 || !outputs.has(MODID + ":single_b")) { + throw new IllegalStateException("Single output did not survive persistence: " + outputs); + } + } + if (!reset && "custom".equals(group)) { + JsonObject outputs = policy.getAsJsonObject("outputs"); + if (outputs.get(MODID + ":custom_a").getAsDouble() != 1.0D + || outputs.get(MODID + ":custom_b").getAsDouble() != 3.0D) { + throw new IllegalStateException("Custom weights did not survive persistence: " + outputs); + } + } + } + } + + private static void resetOreSources(MinecraftServer server) { + Path profile = worldRoot(server).resolve("serverconfig").resolve("orespawn-worldgen.json"); + JsonObject reset = OreSourceEditorProbeBridge.reset( + WorldGeologyProfileManager.activeProfile().rootCopy()); + assertPersistedOreSourceModes(reset, true); + try (BufferedWriter writer = Files.newBufferedWriter(profile)) { + new GsonBuilder().setPrettyPrinting().create().toJson(reset, writer); + } catch (IOException exception) { + throw new IllegalStateException("Could not persist Reset All ore-source profile", exception); + } + if (!WorldGeologyProfileManager.reloadActiveProfile()) { + throw new IllegalStateException("Could not reload Reset All ore-source profile"); + } + assertPersistedOreSourceModes(WorldGeologyProfileManager.activeProfile().rootCopy(), true); + } + + private static void generateSquare(WorldServer world, int minimum, int maximum, boolean reverse) { + if (reverse) { + for (int z = maximum + 1; z >= minimum - 1; z--) { + for (int x = maximum + 1; x >= minimum - 1; x--) world.getChunkProvider().provideChunk(x, z); + } + } else { + for (int z = minimum - 1; z <= maximum + 1; z++) { + for (int x = minimum - 1; x <= maximum + 1; x++) world.getChunkProvider().provideChunk(x, z); + } + } + } + + private static void clearOreInvocations() { + synchronized (ORE_INVOCATIONS) { ORE_INVOCATIONS.clear(); } + } + + private static OreSourceAudit auditOreSources(WorldServer world, int minimum, int maximum, + boolean reset) { + Map> byGroup = new HashMap<>(); + synchronized (ORE_INVOCATIONS) { + for (OreInvocation invocation : ORE_INVOCATIONS) { + if (insideSquare(invocation.chunkX, invocation.chunkZ, minimum, maximum)) { + byGroup.computeIfAbsent(invocation.group, ignored -> new ArrayList<>()).add(invocation); + } + } + } + int chunks = (maximum - minimum + 1) * (maximum - minimum + 1); + for (String group : SOURCE_BLOCKS.keySet()) { + List invocations = byGroup.getOrDefault(group, Collections.emptyList()); + int expected = reset || "original".equals(group) ? chunks * 2 : chunks; + if (invocations.size() != expected) { + throw new IllegalStateException("Expected " + expected + " placement budgets for " + + group + " but recorded " + invocations.size()); + } + Set observed = new LinkedHashSet<>(); + Map selectedInvocations = new HashMap<>(); + int nonEmpty = 0; + for (OreInvocation invocation : invocations) { + Block invocationOutput = null; + for (BlockPos position : invocation.positions) { + Block output = world.getBlockState(position).getBlock(); + if (!contains(SOURCE_BLOCKS.get(group), output)) { + throw new IllegalStateException("Unexpected output " + output + " for " + group + + " at " + position); + } + if (invocationOutput != null && invocationOutput != output) { + throw new IllegalStateException("One ore body mixed outputs for " + group + + " in chunk " + invocation.chunkX + "," + invocation.chunkZ); + } + invocationOutput = output; + observed.add(output); + } + if (invocationOutput != null) { + nonEmpty++; + selectedInvocations.put(invocationOutput, + selectedInvocations.getOrDefault(invocationOutput, 0) + 1); + } + } + if (nonEmpty == 0) { + throw new IllegalStateException("Too few successful ore-source attempts for " + group + + ": " + nonEmpty + "/" + invocations.size()); + } + Block[] outputs = SOURCE_BLOCKS.get(group); + if (!reset && "single".equals(group) + && (!observed.equals(Collections.singleton(outputs[1])))) { + throw new IllegalStateException("Single mode placed unexpected outputs: " + observed); + } + if (!reset && "body".equals(group) && observed.size() != 1) { + throw new IllegalStateException("Stable body identity did not retain one output: " + observed); + } + if ((reset || (!"single".equals(group) && !"body".equals(group))) && observed.size() != 2) { + throw new IllegalStateException(group + " did not exercise both selected outputs: " + observed); + } + if (!reset && "custom".equals(group) + && selectedInvocations.getOrDefault(outputs[1], 0) + <= selectedInvocations.getOrDefault(outputs[0], 0)) { + throw new IllegalStateException("Custom 1:3 weights were not reflected in deterministic " + + "whole-vein selections: " + selectedInvocations); + } + } + if (!reset) assertBodyIdentity(world, byGroup.get("body")); + return scanOreSources(world, minimum, maximum); + } + + private static void assertBodyIdentity(WorldServer world, List invocations) { + Block selected = null; + for (OreInvocation invocation : invocations) { + for (BlockPos position : invocation.positions) { + Block output = world.getBlockState(position).getBlock(); + if (selected == null) selected = output; + else if (selected != output) { + throw new IllegalStateException("Stable body identity selected different outputs across chunks"); + } + } + } + if (selected == null) throw new IllegalStateException("Stable body identity placed no output"); + } + + private static boolean contains(Block[] values, Block value) { + for (Block candidate : values) if (candidate == value) return true; + return false; + } + + private static OreSourceAudit scanOreSources(WorldServer world, int minimum, int maximum) { + List positions = new ArrayList<>(); + Map counts = new LinkedHashMap<>(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int chunkZ = minimum; chunkZ <= maximum; chunkZ++) { + for (int chunkX = minimum; chunkX <= maximum; chunkX++) { + Chunk chunk = world.getChunkProvider().provideChunk(chunkX, chunkZ); + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + for (int y = 0; y <= 80; y++) { + int x = (chunkX << 4) + localX; + int z = (chunkZ << 4) + localZ; + Block block = chunk.getBlockState(cursor.setPos(x, y, z)).getBlock(); + String identity = sourceIdentity(block); + if (identity == null) continue; + positions.add(identity + "@" + x + "," + y + "," + z); + counts.put(identity, Integer.valueOf(counts.getOrDefault(identity, 0) + 1)); + } + } + } + } + } + positions.sort(Comparator.naturalOrder()); + if (positions.isEmpty()) throw new IllegalStateException("Ore-source audit found no probe blocks"); + return new OreSourceAudit(hash(String.join("\n", positions).getBytes(StandardCharsets.UTF_8)), counts); + } + + private static String sourceIdentity(Block block) { + for (Map.Entry entry : SOURCE_BLOCKS.entrySet()) { + for (int index = 0; index < entry.getValue().length; index++) { + if (entry.getValue()[index] == block) return entry.getKey() + (index == 0 ? "_a" : "_b"); + } + } + return null; + } + + private static String sha256(Path path) { + try { return hash(Files.readAllBytes(path)); } + catch (IOException exception) { throw new IllegalStateException("Could not hash " + path, exception); } + } + + private static String hash(byte[] bytes) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes); + StringBuilder result = new StringBuilder(); + for (byte value : digest) result.append(String.format("%02X", value & 0xFF)); + return result.toString(); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + private static WorldServer requireWorld(MinecraftServer server, int dimension) { WorldServer world = server.worldServerForDimension(dimension); if (world == null) { @@ -821,6 +1261,55 @@ int placements() { } } + private static final class OreInvocation { + final String group; + final int chunkX, chunkZ; + final List positions; + + OreInvocation(String group, int chunkX, int chunkZ, List positions) { + this.group = group; + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.positions = Collections.unmodifiableList(new ArrayList<>(positions)); + } + } + + private static final class OreSourceAudit { + final String coordinateHash; + final Map counts; + + OreSourceAudit(String coordinateHash, Map counts) { + this.coordinateHash = coordinateHash; + this.counts = Collections.unmodifiableMap(new LinkedHashMap<>(counts)); + } + + void write(Properties properties, String prefix) { + properties.setProperty(prefix + "coordinate_sha256", coordinateHash); + int total = 0; + for (String group : SOURCE_BLOCKS.keySet()) { + for (String suffix : Arrays.asList("a", "b")) { + String identity = group + "_" + suffix; + int count = counts.getOrDefault(identity, Integer.valueOf(0)).intValue(); + properties.setProperty(prefix + identity, Integer.toString(count)); + total += count; + } + } + properties.setProperty(prefix + "total", Integer.toString(total)); + } + } + + private static final class BiomeCount { + final int biomeA, biomeB, total; + BiomeCount(int biomeA, int biomeB, int total) { + this.biomeA = biomeA; + this.biomeB = biomeB; + this.total = total; + } + @Override public String toString() { + return "BiomeCount{a=" + biomeA + ", b=" + biomeB + ", total=" + total + "}"; + } + } + private static final class Audit { final long dry, wet, filler, geology, ceiling, roof; final int biomeA, biomeB, edges, sentinels; diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java index 1a9003a6..4ba8a455 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -3,6 +3,10 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Properties; @@ -17,6 +21,7 @@ import net.minecraft.client.gui.GuiCreateWorld; import net.minecraft.client.gui.GuiMainMenu; import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; import net.minecraft.util.text.TextFormatting; import net.minecraft.world.GameType; import net.minecraft.world.WorldSettings; @@ -28,6 +33,7 @@ import net.minecraftforge.fml.common.event.FMLInitializationEvent; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import net.minecraftforge.fml.common.gameevent.TickEvent; +import zone.moddev.mc.orespawn.util.JsonCopies; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; /** Build-only client probe. It is compiled and packaged outside every release artifact. */ @@ -45,6 +51,13 @@ public final class ClientProbeTestMod { private int reloadWorldFrames; private int editorFrames; private boolean worldSettingsOpened; + private boolean modsDirectoryRendered; + private boolean directoryStackValidated = + System.getProperty("clientprobe.expectedMods", "").trim().isEmpty(); + private boolean oreSourcesValidated = + System.getProperty("clientprobe.expectedMods", "").trim().isEmpty(); + private boolean oreSourcesLayoutValidated = + System.getProperty("clientprobe.expectedMods", "").trim().isEmpty(); private boolean longEditorRoundTrip; @Mod.EventHandler @@ -64,6 +77,7 @@ public void onScreenInitialized(GuiScreenEvent.InitGuiEvent.Post event) { @SubscribeEvent public void onScreenDrawn(GuiScreenEvent.DrawScreenEvent.Post event) { if (event.getGui() instanceof OreSpawnScreen) editorFrames++; + if (event.getGui() instanceof OreSpawnModsScreen) modsDirectoryRendered = true; } @SubscribeEvent @@ -100,6 +114,7 @@ public void onClientTick(TickEvent.ClientTickEvent event) { if (minecraft.currentScreen instanceof OreSpawnWorldSettingsScreen && editorFrames >= 2) { worldSettingsOpened = true; validateCaptions((OreSpawnWorldSettingsScreen) minecraft.currentScreen); + validateExpectedDirectory(minecraft.currentScreen); validateLongEditorRoundTrip(minecraft, minecraft.currentScreen); nextState(3); } @@ -146,7 +161,10 @@ public void onClientTick(TickEvent.ClientTickEvent event) { } break; case 7: - if (minecraft.world == null && !minecraft.isIntegratedServerRunning() && stateTicks >= 20) { + if (stateTicks >= 20) { + if (minecraft.world != null || minecraft.isIntegratedServerRunning()) { + minecraft.loadWorld(null); + } minecraft.launchIntegratedServer(WORLD_DIRECTORY, "OreSpawn Client Smoke", new WorldSettings(0L, GameType.CREATIVE, false, false, WorldType.DEFAULT)); nextState(8); @@ -160,7 +178,10 @@ public void onClientTick(TickEvent.ClientTickEvent event) { } break; case 9: - if (minecraft.world == null && !minecraft.isIntegratedServerRunning()) { + if (stateTicks >= 20) { + if (minecraft.world != null || minecraft.isIntegratedServerRunning()) { + minecraft.loadWorld(null); + } writeMarker(); minecraft.shutdown(); nextState(10); @@ -174,6 +195,440 @@ public void onClientTick(TickEvent.ClientTickEvent event) { } } + private void validateExpectedDirectory(GuiScreen root) { + String configured = System.getProperty("clientprobe.expectedMods", "").trim(); + if (configured.isEmpty()) return; + List expected = Arrays.asList(configured.split(",")); + List entries = OreSpawnModDirectoryModel.snapshot(); + List actual = new ArrayList<>(); + for (OreSpawnModDirectoryModel.Entry entry : entries) actual.add(entry.modId); + if (!expected.equals(actual)) { + throw new IllegalStateException("Unexpected OreSpawn Mods directory order: expected " + + expected + " but found " + actual); + } + for (OreSpawnModDirectoryModel.Entry entry : entries) { + if (entry.version == null || entry.version.trim().isEmpty() || "?".equals(entry.version)) { + throw new IllegalStateException("Missing Forge mod version for " + entry.modId); + } + if ("basemetals".equals(entry.modId)) { + if (entry.nativeOs4() || entry.legacyLineages().isEmpty()) { + throw new IllegalStateException("Base Metals did not retain its legacy lineage"); + } + } else if ("mineralogy".equals(entry.modId)) { + if (!entry.nativeOs4() || entry.schemaVersion() != 4 || entry.providerRevision() != 3) { + throw new IllegalStateException("Missing Mineralogy OS4 schema 4/provider revision 3: schema=" + + entry.schemaVersion() + ", revision=" + entry.providerRevision()); + } + } else if (!entry.nativeOs4() || entry.schemaVersion() != 5 + || entry.providerRevision() < 1) { + throw new IllegalStateException("Missing native OS4 schema 5/revision for " + entry.modId + + ": schema=" + entry.schemaVersion() + ", revision=" + entry.providerRevision()); + } + boolean shouldConfigure = "realisticdeposits".equals(entry.modId); + if (entry.configurable() != shouldConfigure) { + throw new IllegalStateException("Unexpected cog availability for " + entry.modId); + } + if (shouldConfigure) { + OreSpawnModsScreen directory = new OreSpawnModsScreen(root, entries); + GuiScreen child = entry.extension.createScreen(directory); + if (child == null || !child.getClass().getName().endsWith("RealisticDepositsConfigScreen")) { + throw new IllegalStateException("Realistic Deposits did not create its config screen"); + } + try { + Minecraft minecraft = Minecraft.getMinecraft(); + WorldSettingsExtensionNavigation.open(directory, child); + Method escape = GuiScreen.class.getDeclaredMethod("keyTyped", char.class, int.class); + escape.setAccessible(true); + escape.invoke(child, '\0', org.lwjgl.input.Keyboard.KEY_ESCAPE); + if (minecraft.currentScreen != directory) { + throw new IllegalStateException("Add-on Escape did not return to the Mods directory"); + } + minecraft.displayGuiScreen(root); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not exercise add-on Escape", failure); + } + } + } + if (expected.contains("mineralogy") && expected.contains("electricadvantage")) { + validateExpectedOreSources((OreSpawnWorldSettingsScreen) root); + } + directoryStackValidated = true; + } + + private void validateExpectedOreSources(OreSpawnWorldSettingsScreen root) { + GeologyEditorSession session = editorSession(root); + GeologyEditorSession.OreSourceGroup sulfur = null; + for (GeologyEditorSession.OreSourceGroup group : session.oreSourceGroups()) { + if ("orespawn:sulfur".equals(group.material) + && "minecraft:overworld".equals(group.domain)) sulfur = group; + } + if (sulfur == null) throw new IllegalStateException("Missing curated Sulfur material group"); + if (!"Sulfur".equals(sulfur.displayName) + || !sulfur.oreDictionaryEntries.contains("oreSulfur") + || !sulfur.oreDictionaryEntries.contains("oreSulphur")) { + throw new IllegalStateException("Sulfur aliases were not grouped correctly: " + + sulfur.oreDictionaryEntries); + } + if (!"consolidated".equals(sulfur.mode) || !"balanced".equals(sulfur.outputMode)) { + throw new IllegalStateException("Fresh Sulfur policy was not Balanced: mode=" + + sulfur.mode + ", outputMode=" + sulfur.outputMode); + } + if (sulfur.needsAttention() || OreSourceListScreen.groupRowColor(sulfur) != 0xFFFF55) { + throw new IllegalStateException("Resolved Sulfur policy was not classified yellow: status=" + + sulfur.status + ", attention=" + sulfur.needsAttention()); + } + boolean mineralogy = false; + boolean electricOutputOnly = false; + for (GeologyEditorSession.OreSourceCandidate candidate : sulfur.outputCandidates()) { + if ("mineralogy:sulfur_ore".equals(candidate.registryId)) { + mineralogy = candidate.loaded && candidate.active && !candidate.external; + } + if ("electricadvantage:sulfur_ore".equals(candidate.registryId)) { + electricOutputOnly = candidate.loaded && !candidate.active && !candidate.external; + } + if (("mineralogy:sulfur_ore".equals(candidate.registryId) + || "electricadvantage:sulfur_ore".equals(candidate.registryId)) + && !sulfur.outputs.containsKey(candidate.sourceId)) { + throw new IllegalStateException("Balanced Sulfur output is not selected: " + + candidate.sourceId); + } + } + if (!mineralogy || !electricOutputOnly) { + throw new IllegalStateException("Sulfur outputs were not classified correctly: mineralogy=" + + mineralogy + ", electricOutputOnly=" + electricOutputOnly); + } + validateOreSourcesSingleScreen(root, session); + oreSourcesValidated = true; + } + + private void validateOreSourcesSingleScreen(GuiScreen parent, GeologyEditorSession session) { + Minecraft minecraft = Minecraft.getMinecraft(); + OreSourceListScreen screen = new OreSourceListScreen(parent, session); + screen.setWorldAndResolution(minecraft, 426, 265); + int contentWidth = OreSourceListScreen.contentWidth(426); + int left = (426 - contentWidth) / 2; + int right = left + OreSourceListScreen.leftPaneWidth(426) + 6; + boolean groupList = false; + boolean outputList = false; + boolean inlineAdministration = false; + boolean back = false; + boolean showAll = false; + int showAllWidth = 0; + for (GuiButton widget : screen.buttons) { + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (widget instanceof CompactScrollList && widget.xPosition < right) groupList = true; + if (widget instanceof CompactScrollList && widget.xPosition >= right) outputList = true; + if (widget instanceof TextFieldWidget && widget.xPosition < right) inlineAdministration = true; + if ("Back".equals(caption)) back = true; + if (I18n.format("button.orespawn.show_all").equals(caption)) { + showAll = true; + showAllWidth = widget.width; + } + } + if (!groupList || !outputList || inlineAdministration || back || !showAll + || showAllWidth >= OreSourceListScreen.leftPaneWidth(426) / 2) { + throw new IllegalStateException("Ore Sources was not one compact two-pane screen: groups=" + + groupList + ", outputs=" + outputList + ", inlineAdministration=" + + inlineAdministration + ", back=" + back + ", showAll=" + showAll + + ", showAllWidth=" + showAllWidth + + ", left=" + left + ", right=" + right); + } + + GeologyEditorSession.OreSourceGroup sulfurGroup = null; + for (GeologyEditorSession.OreSourceGroup group : session.oreSourceGroups()) { + if ("orespawn:sulfur".equals(group.material)) sulfurGroup = group; + } + if (sulfurGroup == null) throw new IllegalStateException("Sulfur group disappeared before settings test"); + OreSourceGroupSettingsScreen settings = new OreSourceGroupSettingsScreen( + screen, session, sulfurGroup.key); + settings.setWorldAndResolution(minecraft, 426, 265); + int compactLists = 0; + boolean sulfurName = false; + int lowestListBottom = 0; + int aliasRows = 0; + int placementRows = 0; + for (GuiButton widget : settings.buttons) { + if (widget instanceof CompactScrollList) { + compactLists++; + lowestListBottom = Math.max(lowestListBottom, widget.yPosition + widget.height); + if (widget.yPosition < 100) aliasRows = CompactScrollList.visibleRows(widget.height, 16); + else placementRows = CompactScrollList.visibleRows(widget.height, 16); + } + if (widget instanceof TextFieldWidget + && "Sulfur".equals(((TextFieldWidget) widget).getValue())) sulfurName = true; + } + if (compactLists != 2 || !sulfurName || aliasRows < 5 || placementRows < 3 + || lowestListBottom > 233 + || !OreSourceGroupSettingsScreen.placementChannels(sulfurGroup) + .contains("orespawn:standard")) { + throw new IllegalStateException("Group Settings did not expose aliases and placement rules: lists=" + + compactLists + ", name=" + sulfurName + ", aliasRows=" + aliasRows + + ", placementRows=" + placementRows + ", listBottom=" + lowestListBottom + ", channels=" + + OreSourceGroupSettingsScreen.placementChannels(sulfurGroup)); + } + validateKeepOriginalAcceptance(minecraft, parent, session, sulfurGroup.key); + validateStaleExternalClassification(minecraft, parent, session, sulfurGroup.key); + validateCustomGroupReassignmentLayout(minecraft, parent, session); + oreSourcesLayoutValidated = true; + } + + private void validateCustomGroupReassignmentLayout(Minecraft minecraft, GuiScreen parent, + GeologyEditorSession source) { + GeologyEditorSession customSession = new GeologyEditorSession( + WorldGeologyProfile.fromJson(source.profile().rootCopy(), source.profile())); + customSession.setManageVanillaOres(false); + String customKey = customSession.addOreMaterialGroup(); + String material = customKey.substring(0, customKey.indexOf('|')); + customSession.renameOreMaterialGroup(material, "Precious Stones"); + String originalOwner = customSession.oreDictionaryOwner("oreDiamond"); + if (originalOwner == null || material.equals(originalOwner)) { + throw new IllegalStateException("Could not establish the oreDiamond reassignment control"); + } + OreSourceGroupSettingsScreen settings = new OreSourceGroupSettingsScreen( + parent, customSession, customKey); + settings.setWorldAndResolution(minecraft, 426, 265); + TextFieldWidget alias = null; + Button add = null; + for (GuiButton widget : settings.buttons) { + if (widget instanceof TextFieldWidget + && ((TextFieldWidget) widget).getValue().isEmpty()) alias = (TextFieldWidget) widget; + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (widget instanceof Button && "+".equals(caption)) add = (Button) widget; + } + if (alias == null || add == null) { + throw new IllegalStateException("Custom Group Settings did not expose alias reassignment controls"); + } + alias.setValue("oreDiamond"); + add.press(); + + Button move = null; + Button done = null; + for (GuiButton widget : settings.buttons) { + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (widget instanceof Button + && I18n.format("button.orespawn.ore_source.move").equals(caption)) move = (Button) widget; + if (widget instanceof Button && I18n.format("gui.done").equals(caption)) done = (Button) widget; + } + String message; + try { + Field error = OreSourceGroupSettingsScreen.class.getDeclaredField("error"); + error.setAccessible(true); + message = (String) error.get(settings); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Could not inspect Group Settings validation state", exception); + } + int contentWidth = 406; + int expectedDoneX = 10 + contentWidth + - OreSourceGroupSettingsScreen.doneButtonWidth(contentWidth); + if (move != null || !I18n.format("error.orespawn.ore_source.manage_vanilla_first").equals(message) + || done == null + || done.xPosition != expectedDoneX || done.yPosition != 237 + || OreSourceGroupSettingsScreen.validationMessageWidth(contentWidth) <= 0) { + throw new IllegalStateException("Disabled vanilla management did not block reassignment: move=" + + (move != null) + ", message=" + message + ", done=" + + (done == null ? "missing" : done.xPosition + "," + done.yPosition)); + } + if (!originalOwner.equals(customSession.oreDictionaryOwner("oreDiamond"))) { + throw new IllegalStateException("Blocked vanilla alias move changed oreDiamond ownership"); + } + + customSession.setManageVanillaOres(true); + settings = new OreSourceGroupSettingsScreen(parent, customSession, customKey); + settings.setWorldAndResolution(minecraft, 426, 265); + alias = null; + add = null; + for (GuiButton widget : settings.buttons) { + if (widget instanceof TextFieldWidget + && ((TextFieldWidget) widget).getValue().isEmpty()) alias = (TextFieldWidget) widget; + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (widget instanceof Button && "+".equals(caption)) add = (Button) widget; + } + if (alias == null || add == null) { + throw new IllegalStateException("Managed vanilla alias controls did not reopen"); + } + alias.setValue("oreDiamond"); + add.press(); + move = button(settings, I18n.format("button.orespawn.ore_source.move")); + if (move == null) { + throw new IllegalStateException("Managed vanilla alias move did not request confirmation"); + } + move.press(); + if (!material.equals(customSession.oreDictionaryOwner("oreDiamond"))) { + throw new IllegalStateException("Move did not confirm oreDiamond reassignment"); + } + if (!customSession.addOreMaterialAlias(material, "oreEmerald", true)) { + throw new IllegalStateException("Could not construct the managed Precious Stones group"); + } + GeologyEditorSession.OreSourceGroup customGroup = null; + for (GeologyEditorSession.OreSourceGroup group : customSession.oreSourceGroups()) { + if (customKey.equals(group.key)) customGroup = group; + } + if (customGroup == null || !customGroup.hasManagedPlacementSource() + || !customGroup.needsReview() || customGroup.outputCandidates().size() != 2 + || customGroup.outputs.size() != 2 + || OreSourceListScreen.placementCandidates( + customGroup, "orespawn:standard").size() != 2) { + throw new IllegalStateException("Managed Precious Stones review was not constructed"); + } + OreSourceListScreen outputs = new OreSourceListScreen(parent, customSession); + outputs.setWorldAndResolution(minecraft, 426, 265); + Button mode = button(outputs, I18n.format("option.orespawn.ore_source.output_mode", + I18n.format("mode.orespawn.ore_source.keep_original"))); + Button accept = button(outputs, I18n.format("button.orespawn.ore_source.accept")); + if (mode == null || accept == null) { + throw new IllegalStateException("Managed vanilla review did not retain Keep Original and Accept"); + } + OreSourceListScreen minimum = new OreSourceListScreen(parent, customSession); + minimum.setWorldAndResolution(minecraft, 320, 240); + if (button(minimum, I18n.format("button.orespawn.ore_source.accept")) == null) { + throw new IllegalStateException("Accept was not visible at the minimum supported resolution"); + } + mode.press(); + GeologyEditorSession.OreSourceGroup after = null; + for (GeologyEditorSession.OreSourceGroup group : customSession.oreSourceGroups()) { + if (customKey.equals(group.key)) after = group; + } + String outputError; + try { + Field field = OreSourceListScreen.class.getDeclaredField("error"); + field.setAccessible(true); + outputError = (String) field.get(outputs); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Could not inspect Ore Sources validation state", exception); + } + if (after == null || !"consolidated".equals(after.mode) || after.needsAttention() + || after.outputs.size() != 2 || after.placements.size() != 1 + || OreSourceListScreen.placementCandidates( + after, "orespawn:standard").size() != 2 + || outputError != null + || button(outputs, I18n.format("button.orespawn.ore_source.accept")) != null) { + throw new IllegalStateException("Managed vanilla group did not enter Balanced mode: error=" + + outputError + ", mode=" + (after == null ? "missing" : after.mode)); + } + + OreSourceGroupSettingsScreen dissolve = new OreSourceGroupSettingsScreen( + parent, customSession, customKey); + dissolve.setWorldAndResolution(minecraft, 426, 265); + Button dissolveButton = button(dissolve, + I18n.format("button.orespawn.ore_source.dissolve_group")); + if (dissolveButton == null) { + throw new IllegalStateException("Populated custom group did not expose Dissolve Group"); + } + dissolveButton.press(); + Button confirm = button(dissolve, + I18n.format("button.orespawn.ore_source.confirm_dissolve")); + if (confirm == null || !material.equals(customSession.oreDictionaryOwner("oreDiamond"))) { + throw new IllegalStateException("Dissolve Group did not require a second confirmation"); + } + confirm.press(); + if (!"orespawn:diamond".equals(customSession.oreDictionaryOwner("oreDiamond")) + || !"orespawn:emerald".equals(customSession.oreDictionaryOwner("oreEmerald"))) { + throw new IllegalStateException("Confirmed dissolution did not restore inferred groups"); + } + } + + private static Button button(OreSpawnScreen screen, String caption) { + for (GuiButton widget : screen.buttons) { + String text = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (widget instanceof Button && caption.equals(text)) return (Button) widget; + } + return null; + } + + private void validateKeepOriginalAcceptance(Minecraft minecraft, GuiScreen parent, + GeologyEditorSession source, String key) { + JsonObject root = source.profile().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies").getAsJsonObject(key); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("review_required", true); + policy.addProperty("status", "review_required"); + JsonObject onlyPolicy = new JsonObject(); + onlyPolicy.add(key, policy); + root.add("ore_source_policies", onlyPolicy); + GeologyEditorSession reviewSession = new GeologyEditorSession( + WorldGeologyProfile.fromJson(root, source.profile())); + GeologyEditorSession.OreSourceGroup before = null; + for (GeologyEditorSession.OreSourceGroup group : reviewSession.oreSourceGroups()) { + if (key.equals(group.key)) before = group; + } + if (before == null || !before.needsReview()) { + throw new IllegalStateException("Could not construct pending Keep Original review"); + } + OreSourceListScreen review = new OreSourceListScreen(parent, reviewSession); + review.setWorldAndResolution(minecraft, 426, 265); + Button accept = null; + for (GuiButton widget : review.buttons) { + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (widget instanceof Button + && I18n.format("button.orespawn.ore_source.accept").equals(caption)) { + accept = (Button) widget; + } + } + if (accept == null) throw new IllegalStateException("Pending Keep Original did not show Accept"); + accept.press(); + GeologyEditorSession.OreSourceGroup after = null; + for (GeologyEditorSession.OreSourceGroup group : reviewSession.oreSourceGroups()) { + if (key.equals(group.key)) after = group; + } + if (after == null || after.needsReview() || after.needsAttention() + || !before.mode.equals(after.mode) || !before.outputs.equals(after.outputs) + || !before.placements.equals(after.placements)) { + throw new IllegalStateException("Accept changed Keep Original instead of only clearing review"); + } + } + + private void validateStaleExternalClassification(Minecraft minecraft, GuiScreen parent, + GeologyEditorSession source, String key) { + JsonObject root = source.profile().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies").getAsJsonObject(key); + JsonObject managed = null; + for (com.google.gson.JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("electricadvantage:sulfur_ore".equals(candidate.get("registry_id").getAsString()) + && !candidate.get("external").getAsBoolean()) managed = candidate; + } + if (managed == null) throw new IllegalStateException("Missing managed Electric Advantage output"); + JsonObject external = JsonCopies.copy(managed); + external.addProperty("source_id", "external/electricadvantage:sulfur_ore/0"); + external.addProperty("loaded", false); + external.addProperty("placement_active", false); + external.addProperty("external", true); + policy.getAsJsonArray("candidates").add(external); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "single"); + policy.addProperty("review_required", false); + policy.addProperty("status", "external_generation"); + JsonObject onlyPolicy = new JsonObject(); + onlyPolicy.add(key, policy); + root.add("ore_source_policies", onlyPolicy); + GeologyEditorSession staleSession = new GeologyEditorSession( + WorldGeologyProfile.fromJson(root, source.profile())); + GeologyEditorSession.OreSourceGroup group = staleSession.oreSourceGroups().get(0); + if (group.needsAttention() || !"separate".equals(group.status) + || OreSourceListScreen.groupRowColor(group) != 0xFFFF55) { + throw new IllegalStateException("Stale missing external duplicate remained red: status=" + + group.status + ", attention=" + group.needsAttention()); + } + OreSourceListScreen screen = new OreSourceListScreen(parent, staleSession); + screen.setWorldAndResolution(minecraft, 426, 265); + for (GuiButton widget : screen.buttons) { + String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); + if (I18n.format("button.orespawn.ore_source.accept").equals(caption)) { + throw new IllegalStateException("Resolved stale external duplicate still requested acceptance"); + } + } + } + + private static GeologyEditorSession editorSession(OreSpawnWorldSettingsScreen root) { + try { + Field field = OreSpawnWorldSettingsScreen.class.getDeclaredField("session"); + field.setAccessible(true); + return (GeologyEditorSession) field.get(root); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not inspect the active Ore Sources editor session", failure); + } + } + private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) { for (GuiButton widget : root.buttons) { if (!(widget instanceof Button) || widget instanceof CycleButton) continue; @@ -189,6 +644,7 @@ private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) { private static void validateCaptions(OreSpawnScreen screen) { for (GuiButton widget : screen.buttons) { + if (widget instanceof CogButton || widget instanceof CompactScrollList) continue; String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString); if (caption == null || caption.trim().isEmpty() || caption.contains("options.generic_value") @@ -313,18 +769,20 @@ private static void pressDone(OreSpawnScreen screen) { } private static void stopIntegratedServer(Minecraft minecraft) { - // Match GuiIngameMenu's target-native disconnect path. loadWorld(null) - // coordinates the integrated-server save/stop; installing the replacement - // screen in the same tick prevents EntityRenderer from seeing no world and - // no screen between frames. + // Ask the integrated server to stop while retaining the client world until + // the server and its queued play packets have drained. Clearing the world in + // this tick races Forge 1.10 packet tasks against a null client world. if (minecraft.world != null) minecraft.world.sendQuittingDisconnectingPacket(); - minecraft.loadWorld(null); minecraft.displayGuiScreen(new GuiMainMenu()); } private void writeMarker() throws IOException { Properties values = new Properties(); values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened)); + values.setProperty("mods_directory_rendered", Boolean.toString(modsDirectoryRendered)); + values.setProperty("directory_stack_validated", Boolean.toString(directoryStackValidated)); + values.setProperty("ore_sources_validated", Boolean.toString(oreSourcesValidated)); + values.setProperty("ore_sources_layout_validated", Boolean.toString(oreSourcesLayoutValidated)); values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip)); values.setProperty("editor_routes", Integer.toString(editorRoutes.size())); values.setProperty("editor_classes", editorRoutes.toString()); diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java index 4e1093fd..b5a60e59 100644 --- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java +++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java @@ -84,6 +84,7 @@ import net.minecraftforge.common.DimensionManager; import net.minecraftforge.oredict.OreDictionary; import net.minecraft.item.ItemStack; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; import zone.moddev.mc.orespawn.worldgen.LegacyOs3ProfileMigration; /** @@ -315,6 +316,7 @@ private static void migrateConfigDirectory(Path configDirectory) { LOGGER.error("Could not migrate OS3 global world-generation settings", failure); } Map programmaticSources = API.programmaticSources(); + recordLegacyLineages(legacyDirectory, os1Directory, programmaticSources); if (Files.isRegularFile(bridgeMarker)) { LOGGER.info("OS3 provider migration already completed; retaining migrated files unchanged"); return; @@ -406,6 +408,35 @@ private static void migrateConfigDirectory(Path configDirectory) { } } + private static void recordLegacyLineages(Path legacyDirectory, Path os1Directory, + Map programmaticSources) { + for (String owner : OS1_PROGRAMMATIC.keySet()) { + WorldgenIntegrationManager.recordLegacyLineage(owner, 1); + } + for (String owner : programmaticSources.keySet()) { + WorldgenIntegrationManager.recordLegacyLineage(owner, 3); + } + for (String owner : API.embedded.keySet()) { + WorldgenIntegrationManager.recordLegacyLineage(owner, 3); + } + recordLegacyFiles(legacyDirectory, 3); + recordLegacyFiles(os1Directory, 1); + } + + private static void recordLegacyFiles(Path directory, int generation) { + if (!Files.isDirectory(directory)) return; + try (DirectoryStream files = Files.newDirectoryStream(directory, "*.json")) { + for (Path file : files) { + String owner = file.getFileName().toString().replaceFirst("\\.json$", ""); + if (validModId(owner)) { + WorldgenIntegrationManager.recordLegacyLineage(owner, generation); + } + } + } catch (IOException failure) { + REPORT.add("lineage_scan_failed=" + directory + ":" + failure.getClass().getSimpleName()); + } + } + static JsonObject translateOs1ForTests(String owner, JsonObject source) { return convertOs1Source(owner, source); } @@ -985,7 +1016,7 @@ private static void writeHumanUpgradeReport(Path destination) throws IOException } } List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.110021 Upgrade Report"); + lines.add("OreSpawn 4.1.0.110021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4."); diff --git a/src/main/java/cyano/orespawn/OreSpawn.java b/src/main/java/cyano/orespawn/OreSpawn.java index b150c79f..18d9485d 100644 --- a/src/main/java/cyano/orespawn/OreSpawn.java +++ b/src/main/java/cyano/orespawn/OreSpawn.java @@ -19,7 +19,7 @@ public class OreSpawn { public static OreSpawn INSTANCE; public static final String MODID = "orespawn"; public static final String NAME = "OreSpawn"; - public static final String VERSION = "4.0.16.110021"; + public static final String VERSION = "4.1.0.110021"; public static final List oreSpawnConfigFiles = new ArrayList<>(); public static final List additionalStoneBlocks = new ArrayList<>(); public static boolean disableVanillaOreGen; diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index 761ada97..1c91df6f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -52,7 +52,7 @@ public class OreSpawn { public static final String MODID = "orespawn"; public static final String NAME = "OreSpawn"; - public static final String VERSION = "4.0.16.110021"; + public static final String VERSION = "4.1.0.110021"; private static final Logger LOGGER = LogManager.getLogger(); diff --git a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java index f029d69f..a3d81856 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java @@ -4,7 +4,8 @@ public interface GeologySampler { /** * Classifies one column. The returned column reuses that biome/geome - * classification for all subsequent Y queries. + * classification for all subsequent Y queries. Sampling is read-only and + * does not load or generate the chunk containing the requested column. */ GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY); } 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..72a6080b --- /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.util.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/OrePlacementContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java index 0b8f98ec..e0f17838 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java @@ -4,7 +4,11 @@ import net.minecraftforge.fluids.Fluid; -/** Allocation-free view supplied to a compiled ore pattern for one attempt. */ +/** + * Allocation-free view supplied to a compiled ore pattern for one attempt. + * OreSpawn 4.1 contexts also implement {@link OreGenerationContext}; this base + * interface remains unchanged for binary compatibility with existing patterns. + */ public interface OrePlacementContext { Random random(); @@ -19,10 +23,10 @@ public interface OrePlacementContext { int nodeSize(); /** - * Returns whether this attempt may inspect or replace the position. During initial - * generation this includes Minecraft's already-loaded writable worldgen region, so - * deposits can cross chunk borders. Retrogen deliberately limits it to the chunk - * being updated. + * Returns whether this attempt may inspect or replace the position. Forge 1.10 + * limits both ordinary generation and retrogen to the current chunk. A large + * deterministic pattern can still render the intersecting slice independently in + * every chunk. */ boolean inside(int x, int y, int z); boolean isFluid(int x, int y, int z, Fluid fluid); diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java index b3b88361..3c452b5d 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java @@ -2,19 +2,21 @@ import java.util.Optional; -import zone.moddev.mc.orespawn.OreSpawn; import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; import zone.moddev.mc.orespawn.worldgen.GeomeConfig; import zone.moddev.mc.orespawn.worldgen.WorldIds; import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; import net.minecraft.world.WorldServer; /** Entry point for OreSpawn API version 1. */ public final class OreSpawnApi { public static final int API_VERSION = 1; public static final String IMC_WORLDGEN_PROVIDER = "worldgen_provider_v1"; + private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld"); private OreSpawnApi() { } @@ -46,10 +48,31 @@ public static Optional getActiveProfile(MinecraftServer serv } public static Optional createSampler(WorldServer level) { - if (level == null || WorldGeologyProfileManager.activeServer() != level.getMinecraftServer() - || GeomeConfig.baked(WorldIds.dimension(level)) == null) { + if (!isActiveLevel(level) || samplerConfig(level) == null) { return Optional.empty(); } return Optional.of(OreSpawnGeologySampler.create(level)); } + + static BakedGeomeConfig samplerConfig(WorldServer level) { + if (level == null) { + return null; + } + ResourceLocation dimension = WorldIds.dimension(level); + return OVERWORLD.equals(dimension) ? GeomeConfig.baked() : GeomeConfig.baked(dimension); + } + + static boolean isActiveLevel(WorldServer level) { + MinecraftServer active = WorldGeologyProfileManager.activeServer(); + if (level == null || active == null) { + return false; + } + if (active == level.getMinecraftServer()) { + return true; + } + // Forge 1.10 can populate the initial spawn before WorldServer has exposed + // its server through getMinecraftServer(). The server's dimension table is + // authoritative at that point and still rejects stale or foreign worlds. + return active.worldServerForDimension(level.provider.getDimension()) == level; + } } diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java index 5ba48d47..47579873 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java @@ -6,7 +6,6 @@ import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig; import zone.moddev.mc.orespawn.worldgen.Geology; -import zone.moddev.mc.orespawn.worldgen.GeomeConfig; import zone.moddev.mc.orespawn.worldgen.GeomeGeology; import zone.moddev.mc.orespawn.worldgen.RockFamily; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; @@ -33,7 +32,7 @@ final class OreSpawnGeologySampler implements GeologySampler { private OreSpawnGeologySampler(WorldServer level) { this.level = level; dimension = WorldIds.dimension(level); - config = GeomeConfig.baked(dimension); + config = OreSpawnApi.samplerConfig(level); WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile(); mode = profile.geologyMode(); if (mode == GeologyMode.LEGACY) { @@ -47,7 +46,7 @@ private OreSpawnGeologySampler(WorldServer level) { } static GeologySampler create(WorldServer level) { - if (level == null || WorldGeologyProfileManager.activeServer() != level.getMinecraftServer()) { + if (!OreSpawnApi.isActiveLevel(level)) { throw new IllegalStateException("The level is not part of OreSpawn's active server"); } return new OreSpawnGeologySampler(level); @@ -56,7 +55,10 @@ static GeologySampler create(WorldServer level) { @Override public GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY) { BlockPos position = new BlockPos(blockX, surfaceY, blockZ); - Biome biome = level.getBiome(position); + // Query the dimension's biome provider directly. World#getBiome may obtain a + // chunk and therefore load or generate neighbouring chunks when a large + // extension pattern samples a body crossing chunk boundaries. + Biome biome = level.provider.getBiomeProvider().getBiome(position); ResourceLocation biomeId = WorldIds.biome(biome); if (biomeId == null) biomeId = new ResourceLocation("orespawn", "unregistered_biome"); if (mode == GeologyMode.LEGACY) { 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 736885ca..a62bf209 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java @@ -11,6 +11,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Optional; +import java.util.OptionalDouble; import java.util.Set; import java.util.function.Consumer; @@ -28,11 +30,14 @@ public final class WorldgenProvider { private final String modId; private final int revision; + private final boolean mergeNewEntriesIntoExistingWorlds; private final JsonObject definition; - private WorldgenProvider(String modId, int revision, JsonObject definition) { + private WorldgenProvider(String modId, int revision, boolean mergeNewEntriesIntoExistingWorlds, + JsonObject definition) { this.modId = modId; this.revision = revision; + this.mergeNewEntriesIntoExistingWorlds = mergeNewEntriesIntoExistingWorlds; this.definition = JsonCopies.copy(definition); } @@ -48,7 +53,16 @@ public int revision() { return revision; } - /** Returns a defensive JSON representation matching provider schema 4. */ + /** + * Whether provider entries introduced after a world was created may be merged + * into that world's saved profile. Providers default to the historical + * merge-enabled behaviour. + */ + public boolean mergeNewEntriesIntoExistingWorlds() { + return mergeNewEntriesIntoExistingWorlds; + } + + /** Returns a defensive JSON representation matching provider schema 5. */ public JsonObject toJson() { return JsonCopies.copy(definition); } @@ -56,6 +70,7 @@ public JsonObject toJson() { public static final class Builder { private final String modId; private final int revision; + private boolean mergeNewEntriesIntoExistingWorlds = true; private final LinkedHashMap rocks = new LinkedHashMap<>(); private final LinkedHashMap ores = new LinkedHashMap<>(); private final LinkedHashMap fluidDeposits = @@ -78,6 +93,16 @@ private Builder(String modId, int revision) { this.revision = revision; } + /** + * Controls whether newly declared entries are added to already-created world + * profiles. Set this to false when definitions capture structural add-on + * configuration that must only take effect for new worlds. + */ + public Builder mergeNewEntriesIntoExistingWorlds(boolean value) { + mergeNewEntriesIntoExistingWorlds = value; + return this; + } + public Builder rock(RockDefinition rock) { putUnique(rocks, rock.id(), rock, "rock"); return this; @@ -212,9 +237,11 @@ 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.addProperty("merge_new_entries_into_existing_worlds", + mergeNewEntriesIntoExistingWorlds); root.add("rocks", object(rocks)); root.add("ores", object(ores)); root.add("fluid_deposits", object(fluidDeposits)); @@ -224,7 +251,7 @@ public WorldgenProvider build() { root.add("biome_palettes", object(biomePalettes)); root.add("dimension_materials", object(dimensionMaterials)); root.add("templates", object(templates)); - return new WorldgenProvider(modId, revision, root); + return new WorldgenProvider(modId, revision, mergeNewEntriesIntoExistingWorlds, root); } private void requireOwned(Collection ids, String type) { @@ -350,6 +377,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; @@ -363,6 +391,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; @@ -378,6 +407,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; } + /** Stable semantic 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; } @@ -389,6 +420,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); @@ -414,6 +446,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; @@ -430,6 +463,8 @@ private Builder(ResourceLocation id, ResourceLocation block) { this.block = Objects.requireNonNull(block, "block"); } public Builder enabled(boolean value) { enabled = value; return this; } + /** Declares the semantic material used for source arbitration. */ + 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; } @@ -521,6 +556,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; @@ -537,6 +573,7 @@ public static final class OreDimensionDefinition implements JsonDefinition { private final Set excludedBiomeDictionary; private final Map hostBlockWeights; private final Map hostTagWeights; + private final Double backgroundGenerationScale; private OreDimensionDefinition(Builder builder) { dimension = builder.dimension; @@ -548,6 +585,9 @@ private OreDimensionDefinition(Builder builder) { maxQuantity = builder.maxQuantity; pattern = builder.pattern; patternType = builder.patternType; + 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; @@ -565,6 +605,7 @@ private OreDimensionDefinition(Builder builder) { new LinkedHashSet<>(builder.excludedBiomeDictionary)); hostBlockWeights = immutableMap(builder.hostBlockWeights); hostTagWeights = immutableMap(builder.hostTagWeights); + backgroundGenerationScale = builder.backgroundGenerationScale; } public static Builder builder(ResourceLocation dimension) { return new Builder(dimension); } @@ -579,6 +620,8 @@ private OreDimensionDefinition(Builder builder) { public int maxQuantity() { return maxQuantity; } public OrePattern pattern() { return pattern; } public ResourceLocation patternType() { return patternType; } + /** Stable channel whose placement budget is arbitrated independently. */ + public ResourceLocation placementChannel() { return placementChannel; } public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); } public OreHeightDistribution heightDistribution() { return heightDistribution; } public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; } @@ -595,6 +638,15 @@ private OreDimensionDefinition(Builder builder) { public Set excludedBiomeDictionary() { return excludedBiomeDictionary; } public Map hostBlockWeights() { return hostBlockWeights; } public Map hostTagWeights() { return hostTagWeights; } + /** + * Optional scale applied to other OreSpawn-managed generation and vanilla + * generation for this rule's primary output in this dimension. The rule + * declaring the controller remains unscaled. + */ + public OptionalDouble backgroundGenerationScale() { + return backgroundGenerationScale == null ? OptionalDouble.empty() + : OptionalDouble.of(backgroundGenerationScale.doubleValue()); + } @Override public JsonObject toJson() { @@ -617,6 +669,7 @@ public JsonObject toJson() { configuredPattern.add("settings", JsonCopies.copy(patternSettings)); 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); @@ -632,6 +685,9 @@ public JsonObject toJson() { json.add("excluded_biome_ids", ids(excludedBiomeIds)); json.add("biome_dictionary", strings(biomeDictionary)); json.add("excluded_biome_dictionary", strings(excludedBiomeDictionary)); + if (backgroundGenerationScale != null) { + json.addProperty("background_generation_scale", backgroundGenerationScale); + } return json; } @@ -645,6 +701,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; @@ -661,6 +718,7 @@ public static final class Builder { private final Set excludedBiomeDictionary = new LinkedHashSet<>(); private final Map hostBlockWeights = new LinkedHashMap<>(); private final Map hostTagWeights = new LinkedHashMap<>(); + private Double backgroundGenerationScale; private Builder(ResourceLocation dimension) { this.dimension = Objects.requireNonNull(dimension, "dimension"); } public Builder enabled(boolean value) { enabled = value; return this; } @@ -681,6 +739,11 @@ public Builder pattern(ResourceLocation type, JsonObject settings) { patternSettings = JsonCopies.copy(Objects.requireNonNull(settings, "settings")); return this; } + /** Overrides the placement-budget channel for this dimension rule. */ + 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; } @@ -695,6 +758,14 @@ public Builder pattern(ResourceLocation type, JsonObject settings) { public Builder excludeBiomeDictionary(String value) { excludedBiomeDictionary.add(nonBlank(value)); return this; } + /** + * Scales background generation for the same primary resource and dimension. + * The declaring rule is the controller and is not itself scaled. + */ + public Builder backgroundGenerationScale(double value) { + backgroundGenerationScale = value; + return this; + } public Builder hostBlock(ResourceLocation value, double weight) { hostBlocks.add(value); hostBlockWeights.put(value, replacementWeight(weight)); @@ -712,6 +783,10 @@ public OreDimensionDefinition build() { || minQuantity < 1 || minQuantity > maxQuantity || maxQuantity > 64 || !Double.isFinite(discardChanceOnAirExposure) || discardChanceOnAirExposure < 0.0D || discardChanceOnAirExposure > 1.0D + || (backgroundGenerationScale != null + && (!Double.isFinite(backgroundGenerationScale) + || backgroundGenerationScale < 0.0D + || backgroundGenerationScale > 1.0D)) || spread < 0 || spread > 64 || verticalSpread < 0 || verticalSpread > 64 || nodeSize < 1 || nodeSize > 32) { throw new IllegalStateException("Invalid ore placement values for " + dimension); 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..b632a033 --- /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.GuiScreen; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** Immutable registered action displayed by OreSpawn's world-settings screen. */ +@SideOnly(Side.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 GuiScreen createScreen(GuiScreen 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..c55b9936 --- /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.util.ResourceLocation; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** Client-only registry for add-on screens in OreSpawn's Mods directory. */ +@SideOnly(Side.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.getResourceDomain(); + 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..4ee3841b --- /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.GuiScreen; +import net.minecraftforge.fml.relauncher.Side; +import net.minecraftforge.fml.relauncher.SideOnly; + +/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */ +@FunctionalInterface +@SideOnly(Side.CLIENT) +public interface WorldSettingsScreenFactory { + GuiScreen create(GuiScreen 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..33300ccd --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -0,0 +1,316 @@ +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.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.util.ResourceLocation; +import net.minecraft.world.biome.Biome; +import net.minecraftforge.common.BiomeDictionary; +import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.ModContainer; +import net.minecraftforge.fml.common.registry.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 BiomeDirectoryModel() { } + + static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults, + Iterable dimensions) { + List loaded = new ArrayList<>(); + Map mods = new LinkedHashMap<>(); + for (ModContainer mod : Loader.instance().getActiveModList()) { + mods.put(mod.getModId(), new ModInfo(mod.getModId(), mod.getName(), mod.getVersion())); + } + mods.putIfAbsent("minecraft", new ModInfo("minecraft", "Minecraft", "1.10.2")); + for (Entry entry : ForgeRegistries.BIOMES.getEntries()) { + ModInfo owner = mods.get(entry.getKey().getResourceDomain()); + loaded.add(new LoadedBiome(entry.getKey().toString(), entry.getValue().getBiomeName(), + owner == null ? entry.getKey().getResourceDomain() : owner.name, + owner == null ? "?" : owner.version, routineDimension(entry.getValue()))); + } + return assemble(profile, defaults.biomePalettesCopy(), + defaults.activeProviderIds(), loaded, dimensions); + } + + 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(Biome biome) { + if (BiomeDictionary.isBiomeOfType(biome, BiomeDictionary.Type.NETHER)) return NETHER; + if (BiomeDictionary.isBiomeOfType(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).getResourceDomain(); } + catch (RuntimeException ignored) { return "unknown"; } + } + + private static String friendly(String id) { + String value; + try { value = new ResourceLocation(id).getResourcePath(); } + 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 3bea7644..e6ceea05 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java @@ -1,19 +1,29 @@ package zone.moddev.mc.orespawn.client; +import java.util.ArrayList; import java.util.List; +import org.lwjgl.input.Mouse; + +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Palette; + +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiScreen; import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; +/** Lists every palette in effective profile order for one dimension. */ final class BiomePaletteScreen extends OreSpawnScreen { private final GuiScreen 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(GuiScreen parent, GeologyEditorSession session, String dimension) { - super(new TextComponentTranslation("screen.orespawn.biome_palette")); + super(new TextComponentTranslation("screen.orespawn.biome_palettes")); this.parent = parent; this.session = session; this.dimension = dimension; @@ -21,56 +31,102 @@ final class BiomePaletteScreen extends OreSpawnScreen { @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; - addButton(OreSpawnScreenLayout.button(this, font, left, y, - contentWidth - removeWidth - 5, 20, new TextComponentString(id), - button -> minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, - dimension, id)))); - addButton(new Button(left + contentWidth - removeWidth, y, removeWidth, 20, - new TextComponentTranslation("button.orespawn.remove"), - button -> { session.removeBiomePlacement(dimension, id); rebuildWidgets(); })); - } - Button previous = addButton(new Button(left, controlsY, 45, 20, - new TextComponentString("<"), button -> { page--; rebuildWidgets(); })); - Button next = addButton(new Button(left + 50, controlsY, 45, 20, - new TextComponentString(">"), button -> { page++; rebuildWidgets(); })); - previous.enabled = page > 0; - next.enabled = page + 1 < pageCount; - addButton(OreSpawnScreenLayout.button(this, font, - left + contentWidth - 150, controlsY, 150, 20, - new TextComponentTranslation("button.orespawn.add_biome"), - button -> minecraft.displayGuiScreen(new BiomePickerScreen(this, session, id -> { - session.addBiomePlacement(dimension, id); - minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, dimension, id)); - })))); + 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.displayGuiScreen(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.I18n.format( + "label.orespawn.biome.palette_owner_order", palette.owner, palette.order + 1)); + if (palette.override) lines.add(net.minecraft.client.resources.I18n.format( + "tooltip.orespawn.biome.override_palette")); + return lines; + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList(net.minecraft.client.resources.I18n.format( + 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 TextComponentTranslation(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 TextComponentTranslation(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, DialogTexts.GUI_DONE, button -> onClose())); } - private void rebuildWidgets() { - buttons.clear(); children.clear(); - 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(); buttons.clear(); children.clear(); init(); } + @Override public void onClose() { minecraft.displayGuiScreen(parent); } + @Override + public void handleMouseInput() throws java.io.IOException { + super.handleMouseInput(); + int wheel = Mouse.getEventDWheel(); + if (wheel == 0 || list == null) return; + int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; + int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1; + if (list.mouseWheel(mouseX, mouseY, wheel)) scroll = list.firstIndex(); + } + @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 12, 0xFFFFFF); - drawCenteredString(font, new TextComponentString(dimension), width / 2, 28, 0xCCCCCC); + drawCenteredString(font, title, width / 2, 9, 0xFFFFFF); + drawCenteredString(font, new TextComponentString(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..25cf9082 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java @@ -0,0 +1,163 @@ +package zone.moddev.mc.orespawn.client; + +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.GuiScreen; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextComponentTranslation; + +/** Edits the complete set of controls already supported by one biome palette. */ +final class BiomePaletteSettingsScreen extends OreSpawnScreen { + private final GuiScreen parent; + private final GeologyEditorSession session; + private final String paletteId; + private TextFieldWidget coverage; + private TextFieldWidget fallback; + private TextFieldWidget included; + private TextFieldWidget excluded; + + BiomePaletteSettingsScreen(GuiScreen parent, GeologyEditorSession session, String paletteId) { + super(new TextComponentTranslation("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 TextComponentTranslation("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 TextComponentTranslation("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 TextComponentTranslation("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 TextComponentTranslation("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 TextComponentTranslation("option.orespawn.biome.include_namespaces"))); + included.setValue(join(palette.get("include_namespaces"))); + y += 24; + excluded = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + new TextComponentTranslation("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 TextComponentTranslation("label.orespawn.biome.palette_entries", + session.biomePlacementIdsForPalette(paletteId).size()), button -> { }, + "tooltip.orespawn.biome.palette_entries_read_only")); + info.enabled = false; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.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 TextComponentTranslation(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 ITextComponent modeName(String value) { + return new TextComponentTranslation("value.orespawn.biome_mode." + value); + } + private ITextComponent scopeName(String value) { + return new TextComponentTranslation("value.orespawn.biome_scope." + value); + } + private ITextComponent regionName(String value) { + return new TextComponentTranslation("value.orespawn.preset." + value); + } + + @Override public void onClose() { save(); minecraft.displayGuiScreen(parent); } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, new TextComponentString(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 a2e085a3..50f1faec 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java @@ -15,6 +15,7 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } private final GuiScreen parent; private final GeologyEditorSession session; private final String dimension; + private final String paletteId; private final String biomeId; private Tab tab = Tab.PLACEMENT; private TextFieldWidget weight; @@ -26,10 +27,21 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } BiomePlacementScreen(GuiScreen 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(GuiScreen parent, GeologyEditorSession session, + String dimension, String paletteId, String biomeId) { super(new TextComponentTranslation("screen.orespawn.biome_placement")); this.parent = parent; this.session = session; this.dimension = dimension; + this.paletteId = paletteId; this.biomeId = biomeId; } @@ -48,7 +60,7 @@ protected void init() { selected -> { saveFields(); tab = value; rebuildWidgets(); })); button.enabled = value != tab; } - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); OreSpawnScreenLayout.explain(this, addButton(CycleButton.onOffBuilder( bool(placement, "enabled", true)) .create(left, 64, contentWidth, 20, @@ -62,7 +74,7 @@ protected void init() { half, 20, DialogTexts.GUI_DONE, button -> { saveFields(); onClose(); })); addButton(new Button(left + half + 5, OreSpawnScreenLayout.footerY(height), half, 20, new TextComponentTranslation("button.orespawn.remove"), button -> { - session.removeBiomePlacement(dimension, biomeId); + session.removeBiomePlacementByPalette(paletteId, biomeId); onClose(); })); } @@ -152,7 +164,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..eff26801 --- /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.util.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 1d2969c8..4b0b15db 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java @@ -1,22 +1,45 @@ package zone.moddev.mc.orespawn.client; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; -import com.google.gson.JsonObject; +import org.lwjgl.input.Mouse; + +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.GuiScreen; +import net.minecraft.client.resources.I18n; import net.minecraft.util.text.ITextComponent; import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; -/** Dimension-level entry point for biome placement and world materials. */ +/** Responsive directory for biome placement, exact replacements, and diagnostics. */ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { + private static final int TWO_PANE_MINIMUM = 400; + private static final int CONTENT_TOP = 48; private final GuiScreen 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(GuiScreen parent, GeologyEditorSession session) { - super(new TextComponentTranslation("screen.orespawn.biomes_world_materials")); + super(new TextComponentTranslation("screen.orespawn.biomes")); this.parent = parent; this.session = session; this.dimension = session.availableDimensionIds().get(0); @@ -25,126 +48,296 @@ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { @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, addButton(CycleButton.builder(this::dimensionName) - .withValues(dimensions).withInitialValue(dimension) - .create(left, 34, contentWidth, 20, - new TextComponentTranslation("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, addButton(CycleButton.onOffBuilder(active) - .create(left, y, contentWidth, 20, - new TextComponentTranslation("option.orespawn.biome_palette"), - (button, value) -> setPaletteEnabled(value))), - "tooltip.orespawn.biome.palette_enabled"); - y += 26; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::modeName) - .withValues(Arrays.asList("augment", "replace")).withInitialValue(mode) - .create(left, y, half, 20, - new TextComponentTranslation("option.orespawn.biome_mode"), - (button, value) -> setPalette("mode", value))), - "tooltip.orespawn.biome.mode"); - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::scopeName) - .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) - .withInitialValue(scope) - .create(left + half + 5, y, half, 20, - new TextComponentTranslation("option.orespawn.biome_scope"), - (button, value) -> setPalette("scope", value))), - "tooltip.orespawn.biome.scope"); - y += 26; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::regionName) - .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) - .withInitialValue(size) - .create(left, y, contentWidth, 20, - new TextComponentTranslation("option.orespawn.biome_region_size"), - (button, value) -> setPalette("region_size", value))), - "tooltip.orespawn.biome.region_size"); - y += 30; - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, half, 20, - new TextComponentTranslation("button.orespawn.biome_palette_count", - session.biomePlacementIds(dimension).size()), - button -> minecraft.displayGuiScreen(new BiomePaletteScreen(this, session, dimension)), - "tooltip.orespawn.biome.entries")); - addButton(OreSpawnScreenLayout.explainedButton(this, font, left + half + 5, y, half, 20, - new TextComponentTranslation("button.orespawn.dimension_materials"), - button -> minecraft.displayGuiScreen(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 TextComponentTranslation(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 TextComponentTranslation("button.orespawn.dimension_materials_named", + dimensionName(dimension)), + button -> minecraft.displayGuiScreen( + new DimensionMaterialsScreen(this, session, dimension)), "tooltip.orespawn.biome.dimension_materials")); - y += 26; - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, contentWidth, 20, - new TextComponentTranslation("button.orespawn.geome_influences"), - button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)), - "tooltip.orespawn.biome.geome_influences")); - addButton(new Button(width / 2 - 75, OreSpawnScreenLayout.footerY(height), - 150, 20, DialogTexts.GUI_DONE, button -> onClose())); - } - - private void setPaletteEnabled(boolean enabled) { - JsonObject palette = session.biomePalette(dimension, true); - if (enabled && session.biomePlacementIds(dimension).isEmpty()) { - minecraft.displayGuiScreen(new BiomePickerScreen(this, session, - id -> { - session.addBiomePlacement(dimension, id); - minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, dimension, id)); + if (!compact) initDetail(snapshot, false); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.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 TextComponentTranslation("button.orespawn.back"), button -> { + captureScroll(); compactDetail = false; rebuild(); })); - return; } - palette.addProperty("enabled", enabled); + ITextComponent replacement = selected.replacementTarget == null + ? new TextComponentTranslation("button.orespawn.biome.replace_new_terrain") + : new TextComponentTranslation("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 TextComponentTranslation("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.displayGuiScreen(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 TextComponentTranslation("button.orespawn.biome.palettes", snapshot.palettes(dimension).size()), + button -> minecraft.displayGuiScreen(new BiomePaletteScreen(this, session, dimension)))); + addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop, half, 20, + new TextComponentTranslation(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 TextComponentTranslation("button.orespawn.geome_influences"), + button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)))); + } + + private void chooseReplacement(BiomeEntry selected) { + captureScroll(); + minecraft.displayGuiScreen(new BiomePickerScreen(this, session, target -> { + try { + session.replaceBiome(dimension, selected.id, target); + clearMessage(); + } catch (IllegalArgumentException failure) { + message = failure.getMessage(); + } + minecraft.displayGuiScreen(this); + })); + } + + private void resetSelected(BiomeEntry selected) { + if (!resetArmed) { resetArmed = true; rebuild(); return; } + session.resetBiome(dimension, selected.id); + resetArmed = false; clearMessage(); rebuild(); + } + + private void cycleDimension(Snapshot snapshot) { + dimension = nextDimension(snapshot.dimensions, dimension); + selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; clearMessage(); rebuild(); + } + + private void clearMessage() { + message = null; + } + + private List wrapMessage(String text, int maximumWidth) { + if (text == null || text.isEmpty()) return java.util.Collections.emptyList(); + List wrapped = font.listFormattedStringToWidth(text, Math.max(40, maximumWidth)); + if (wrapped.size() <= 2) return wrapped; + List result = new ArrayList<>(wrapped.subList(0, 2)); + result.set(1, OreSpawnScreenLayout.fit(font, + new TextComponentString(result.get(1) + "..."), Math.max(40, maximumWidth))); + return result; + } + + 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 static BiomeEntry find(List entries, String id) { + if (id != null) for (BiomeEntry entry : entries) if (id.equals(entry.id)) return entry; + return null; + } + + static ITextComponent dimensionName(String dimension) { + switch (dimension) { + case "minecraft:overworld": return new TextComponentTranslation("value.orespawn.dimension.overworld"); + case "minecraft:the_nether": return new TextComponentTranslation("value.orespawn.dimension.the_nether"); + case "minecraft:the_end": return new TextComponentTranslation("value.orespawn.dimension.the_end"); + default: return new TextComponentString(dimension); + } } - private void setPalette(String key, String value) { - session.biomePalette(dimension, true).addProperty(key, value); + private static ITextComponent dimensionSelectorLabel(String dimension) { + return new TextComponentString("< " + dimensionName(dimension).getUnformattedText() + " >"); } - private ITextComponent dimensionName(String value) { - return new TextComponentString(value); + 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 ITextComponent modeName(String value) { - return new TextComponentTranslation("value.orespawn.biome_mode." + value); + 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 ITextComponent scopeName(String value) { - return new TextComponentTranslation("value.orespawn.biome_scope." + value); + 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.format("value.orespawn.biome.status." + entry.status.name().toLowerCase(java.util.Locale.ROOT))); + lines.add(I18n.format("label.orespawn.biome.rule_count", entry.placements.size())); + if (entry.replacementTarget != null) lines.add(I18n.format("label.orespawn.biome.replaced_by", entry.replacementTarget)); + return lines; } - private ITextComponent regionName(String value) { - return new TextComponentTranslation("value.orespawn.preset." + value); + private static List placementTooltip(Placement rule) { + List lines = new ArrayList<>(); + lines.add(rule.paletteId); + lines.add(I18n.format("label.orespawn.biome.palette_owner_order", rule.owner, rule.order + 1)); + if (rule.hasSurface) lines.add(I18n.format(rule.effectiveSurface + ? "label.orespawn.biome.surface_effective" : "label.orespawn.biome.surface_overridden")); + return lines; } - private void rebuildWidgets() { - buttons.clear(); children.clear(); - init(); + private void captureScroll() { + if (biomeList != null) biomeScroll = biomeList.firstIndex(); + if (ruleList != null) ruleScroll = ruleList.firstIndex(); } + private void rebuild() { captureScroll(); buttons.clear(); children.clear(); init(); } + + @Override public void onClose() { minecraft.displayGuiScreen(parent); } + @Override - public void onClose() { - minecraft.displayGuiScreen(parent); + public void handleMouseInput() throws java.io.IOException { + super.handleMouseInput(); + int wheel = Mouse.getEventDWheel(); + if (wheel == 0) return; + int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; + int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1; + if (biomeList != null && biomeList.mouseWheel(mouseX, mouseY, wheel)) biomeScroll = biomeList.firstIndex(); + else if (ruleList != null && ruleList.mouseWheel(mouseX, mouseY, wheel)) ruleScroll = ruleList.firstIndex(); } @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 14, 0xFFFFFF); + 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 TextComponentString(selected.name), x + 3, y, 0xFFFFFF); + drawString(font, new TextComponentString(selected.id), x + 3, y + 11, 0xAAAAAA); + drawString(font, new TextComponentTranslation("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 TextComponentString(detailMessageLines.get(line)), + detailX + 3, detailMessageTop + (line * 10), 0xFF5555); + } + drawString(font, new TextComponentTranslation("label.orespawn.biome.placement_rules"), + detailX + 3, detailRuleTop - 10, 0xCCCCCC); + } 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; + 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; + } + + 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/ClientSetup.java b/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java index e3d0f494..3d391969 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java @@ -16,5 +16,6 @@ public static synchronized void initialize() { // class is registered as an instance. Both world-creation hooks are static, // so the class object is required for the OreSpawn button to be installed. MinecraftForge.EVENT_BUS.register(WorldCreationScreenHandler.class); + MinecraftForge.EVENT_BUS.register(WorldSettingsExtensionNavigation.class); } } 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..bd6c25b5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java @@ -0,0 +1,37 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.util.ResourceLocation; + +/** Small target-native icon button used by the OreSpawn Mods directory. */ +final class CogButton extends Button { + private static final ResourceLocation COG = + new ResourceLocation("orespawn", "textures/gui/cog.png"); + + CogButton(int x, int y, IPressable onPress, Tooltip tooltip) { + super(x, y, 22, 20, "", onPress, tooltip); + } + + @Override + public void drawButton(Minecraft minecraft, int mouseX, int mouseY) { + super.drawButton(minecraft, mouseX, mouseY); + if (!visible) return; + minecraft.getTextureManager().bindTexture(COG); + if (enabled) GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); + else GlStateManager.color(0.45F, 0.45F, 0.45F, 0.8F); + GlStateManager.pushMatrix(); + GlStateManager.translate(xPosition + 11.0F, yPosition + 10.0F, 0.0F); + GlStateManager.scale(1.5F, 1.5F, 1.0F); + drawModalRectWithCustomSizedTexture(-8, -8, + 0.0F, 0.0F, 16, 16, 16.0F, 16.0F); + if (enabled) { + // The source pixels deliberately retain soft edges. A second pass makes + // the enabled icon read clearly against the legacy button texture. + drawModalRectWithCustomSizedTexture(-8, -8, + 0.0F, 0.0F, 16, 16, 16.0F, 16.0F); + } + GlStateManager.popMatrix(); + GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); + } +} 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..360c4a47 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java @@ -0,0 +1,293 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.Collections; +import java.util.List; + +import org.lwjgl.input.Mouse; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; + +/** + * Small Forge 1.10-native 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 int pendingIndex = -1; + private boolean pendingAction; + + 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 drawButton(Minecraft minecraft, int mouseX, int mouseY) { + if (!visible) return; + clampFirstIndex(); + if (dragging) { + if (Mouse.isButtonDown(0)) dragTo(mouseY - dragOffset); + else dragging = false; + } + + drawRect(xPosition, yPosition, xPosition + width, yPosition + height, 0x76000000); + hoveredIndex = rowAt(mouseX, mouseY); + hoveredAction = hoveredIndex >= 0 && isInRowAction(hoveredIndex, mouseX); + FontRenderer font = minecraft.fontRendererObj; + int rows = visibleRows(); + int contentRight = contentRight(); + for (int row = 0; row < rows; row++) { + int index = firstIndex + row; + if (index >= size()) break; + int rowY = yPosition + (row * rowHeight); + boolean hovered = index == hoveredIndex; + if (rowSelected(index)) { + drawRect(xPosition + 1, rowY, contentRight, rowY + rowHeight - 1, 0xA052648C); + } else if (hovered) { + drawRect(xPosition + 1, rowY, contentRight, rowY + rowHeight - 1, 0x80404040); + } + int actionWidth = Math.max(0, rowActionWidth(index)); + int textWidth = Math.max(0, contentRight - xPosition - actionWidth - 8); + String text = fit(font, rowText(index), textWidth); + font.drawStringWithShadow(text, xPosition + 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 mousePressed(Minecraft minecraft, int mouseX, int mouseY) { + if (!visible || !enabled || !contains(mouseX, mouseY)) return false; + pendingIndex = -1; + if (mouseX >= contentRight()) { + pressScrollbar(mouseY); + return true; + } + int index = rowAt(mouseX, mouseY); + if (index < 0) return true; + pendingAction = isInRowAction(index, mouseX); + if (pendingAction || rowEnabled(index)) pendingIndex = index; + return true; + } + + @Override + void press() { + int index = pendingIndex; + pendingIndex = -1; + if (index < 0 || index >= size()) return; + if (pendingAction) onRowActionPressed(index); + else onRowPressed(index); + } + + @Override + public void mouseReleased(int mouseX, int mouseY) { + dragging = false; + super.mouseReleased(mouseX, mouseY); + } + + @Override + 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; + } + + 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 : yPosition + ((index - firstIndex) * rowHeight); + } + + int contentRight() { + return xPosition + width - SCROLLBAR_WIDTH; + } + + private boolean contains(int mouseX, int mouseY) { + return mouseX >= xPosition && mouseX < xPosition + width + && mouseY >= yPosition && mouseY < yPosition + height; + } + + private int rowAt(int mouseX, int mouseY) { + if (!contains(mouseX, mouseY) || mouseX >= contentRight()) return -1; + int index = firstIndex + ((mouseY - yPosition) / 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 < yPosition + ARROW_HEIGHT) { + scrollBy(-1); + return; + } + if (mouseY >= yPosition + 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 - (yPosition + 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 yPosition + 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 = xPosition + width; + drawRect(left, yPosition, right, yPosition + height, 0xB0202020); + FontRenderer font = minecraft.fontRendererObj; + drawCenteredString(font, "^", left + (SCROLLBAR_WIDTH / 2), yPosition, 0xDDDDDD); + drawCenteredString(font, "v", left + (SCROLLBAR_WIDTH / 2), + yPosition + height - ARROW_HEIGHT, 0xDDDDDD); + int thumbY = thumbY(); + int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight()); + drawRect(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(FontRenderer font, String text, int width) { + if (width <= 0) return ""; + if (font.getStringWidth(text) <= width) return text; + String suffix = "..."; + return font.trimStringToWidth(text, + Math.max(0, width - font.getStringWidth(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/DialogTexts.java b/src/main/java/zone/moddev/mc/orespawn/client/DialogTexts.java index 595364bb..b6d29424 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/DialogTexts.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/DialogTexts.java @@ -1,9 +1,14 @@ package zone.moddev.mc.orespawn.client; +import java.util.function.Predicate; + import net.minecraft.block.Block; +import net.minecraft.client.resources.I18n; import net.minecraft.util.text.ITextComponent; import net.minecraft.util.text.TextComponentString; import net.minecraft.util.text.TextComponentTranslation; +import net.minecraftforge.fluids.Fluid; +import net.minecraftforge.fluids.FluidRegistry; /** Shared translated labels for Minecraft 1.10 screens. */ final class DialogTexts { @@ -16,7 +21,51 @@ static TextComponentTranslation blockName(Block block) { } static ITextComponent blockName(Block block, String fallback) { - return block == null ? new TextComponentString(fallback) : blockName(block); + if (block == null) return new TextComponentString(fallback); + return blockName(block, fallback, I18n::hasKey); + } + + static ITextComponent blockName(Block block, String fallback, + Predicate translationExists) { + if (block == null) return new TextComponentString(fallback); + String key = block.getUnlocalizedName() + ".name"; + if (!"tile.null.name".equals(key) && translationExists.test(key)) { + return new TextComponentTranslation(key); + } + Fluid fluid = FluidRegistry.lookupFluidForBlock(block); + String fluidKey = fluid == null ? null : fluid.getUnlocalizedName(); + if (fluidKey != null && translationExists.test(fluidKey)) { + return new TextComponentTranslation(fluidKey); + } + return new TextComponentString(friendlyRegistryName(fallback)); + } + + private static String friendlyRegistryName(String registryId) { + if (registryId == null || registryId.trim().isEmpty()) return "Unknown"; + String path = registryId.trim(); + int colon = path.indexOf(':'); + if (colon >= 0 && colon + 1 < path.length()) path = path.substring(colon + 1); + int slash = path.lastIndexOf('/'); + if (slash >= 0 && slash + 1 < path.length()) path = path.substring(slash + 1); + path = path.replace('_', ' ').replace('-', ' ').replace('.', ' ').trim(); + if (path.isEmpty()) return registryId; + + StringBuilder friendly = new StringBuilder(path.length()); + boolean capitalize = true; + for (int i = 0; i < path.length(); i++) { + char current = path.charAt(i); + if (Character.isWhitespace(current)) { + if (friendly.length() > 0 && friendly.charAt(friendly.length() - 1) != ' ') { + friendly.append(' '); + } + capitalize = true; + } else { + friendly.append(capitalize ? Character.toUpperCase(current) + : Character.toLowerCase(current)); + capitalize = false; + } + } + return friendly.toString(); } private DialogTexts() { diff --git a/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java index 6aa212c0..5ea5cc53 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java @@ -13,7 +13,8 @@ final class DimensionMaterialsScreen extends OreSpawnScreen { private TextFieldWidget deepY; DimensionMaterialsScreen(GuiScreen parent, GeologyEditorSession session, String dimension) { - super(new TextComponentTranslation("screen.orespawn.dimension_materials")); + super(new TextComponentTranslation("screen.orespawn.dimension_materials", + BiomeWorldMaterialsScreen.dimensionName(dimension))); this.parent = parent; this.session = session; this.dimension = dimension; @@ -83,8 +84,12 @@ private void save() { @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 12, 0xFFFFFF); - drawCenteredString(font, new TextComponentString(dimension), width / 2, 30, 0xCCCCCC); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, new TextComponentString(dimension), width / 2, 23, 0xAAAAAA); + drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, + new TextComponentTranslation("label.orespawn.dimension_materials.scope", + BiomeWorldMaterialsScreen.dimensionName(dimension)), width - 16)), + width / 2, 38, 0xFFFF55); super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java index 303fc78f..8f9fcc79 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/FluidDepositListScreen.java @@ -67,9 +67,9 @@ protected void init() { private ITextComponent depositName(String id) { JsonObject deposit = session.fluidDeposit(id); try { - Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation( - GeologyEditorSession.string(deposit, "block", ""))); - if (block != null) return DialogTexts.blockName(block); + String blockId = GeologyEditorSession.string(deposit, "block", ""); + Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation(blockId)); + if (block != null) return DialogTexts.blockName(block, blockId); } catch (RuntimeException ignored) { } return new TextComponentString(id); } 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 3e035bad..5bf6f88b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -5,7 +5,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; @@ -25,6 +27,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.util.ResourceLocation; import net.minecraft.util.math.BlockPos; @@ -34,6 +38,7 @@ import net.minecraft.init.Items; import net.minecraft.item.Item; import net.minecraftforge.fluids.IFluidBlock; +import net.minecraftforge.fluids.FluidRegistry; import net.minecraftforge.fml.common.registry.ForgeRegistries; /** Mutable client-side copy used until the Create World settings are accepted. */ @@ -59,6 +64,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) { @@ -66,11 +73,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"); @@ -93,6 +108,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); } @@ -123,6 +176,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); @@ -345,6 +405,777 @@ 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, "ore_dictionary_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("ore_dictionary") && candidate.get("ore_dictionary").isJsonArray()) { + for (JsonElement name : candidate.getAsJsonArray("ore_dictionary")) { + 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("ore_dictionary_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 oreDictionaryOwner(String alias) { + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + if (stringList(entry.getValue().getAsJsonObject(), "ore_dictionary_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 = oreDictionaryOwner(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.oreDictionary.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) || !validOreDictionaryName(alias)) return false; + String owner = oreDictionaryOwner(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, "ore_dictionary_entries"); + if (!names.contains(alias)) names.add(alias); + Collections.sort(names); + definition.add("ore_dictionary_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, "ore_dictionary_entries"); + if (names.remove(alias)) { + definition.add("ore_dictionary_entries", strings(names)); + rebuildOreSourcePolicies(); + } + } + + void resetOreMaterialGroup(String material) { + if ("orespawn:sulfur".equals(material)) { + setOreMaterialGroup(material, "Sulfur", Arrays.asList("oreSulfur", "oreSulphur")); + } else if ("orespawn:aluminum".equals(material)) { + setOreMaterialGroup(material, "Aluminum", Arrays.asList("oreAluminum", "oreAluminium")); + } + 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), "ore_dictionary_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), "ore_dictionary_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, "ore_dictionary_entries"); + if (!restoredAliases.contains(alias)) restoredAliases.add(alias); + Collections.sort(restoredAliases); + definition.add("ore_dictionary_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("ore_dictionary_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, "ore_dictionary")); + 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 = oreDictionaryOwner(alias); + if (owner != null) materials.add(owner); + else if (validOreDictionaryName(alias)) { + materials.add("orespawn:" + alias.substring(3).toLowerCase(Locale.ROOT)); + } + } + 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 (validOreDictionaryName(alias)) unique.add(alias); + List exact = new ArrayList<>(unique); + Collections.sort(exact); + String token = exact.isEmpty() ? "unknown" + : exact.get(0).substring(3).toLowerCase(Locale.ROOT); + 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 ("oreSulfur".equals(alias) || "oreSulphur".equals(alias)) return "orespawn:sulfur"; + if ("oreAluminum".equals(alias) || "oreAluminium".equals(alias)) return "orespawn:aluminum"; + return "orespawn:" + alias.substring(3).toLowerCase(Locale.ROOT); + } + + 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)) { + if (outputs.entrySet().isEmpty()) { + for (JsonObject candidate : outputCandidateJson(policy)) { + outputs.addProperty(string(candidate, "source_id", ""), 1.0D); + } + } else { + for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) { + 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 oreDictionaryEntries; + 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 oreDictionaryEntries, 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.oreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(oreDictionaryEntries)); + 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() && oreDictionaryEntries.size() == 1 + && outputCandidates().size() == 1; + } + + boolean isCustom() { + return isCustomMaterial(material); + } + + boolean canDeleteEmpty() { + return isCustom() && oreDictionaryEntries.isEmpty(); + } + + boolean canDissolve() { + return isCustom() && !oreDictionaryEntries.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 oreDictionary; + 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 oreDictionary, + 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.oreDictionary = Collections.unmodifiableList(new ArrayList<>(oreDictionary)); + 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)) { @@ -392,6 +1223,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( @@ -714,8 +1698,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) { @@ -751,14 +1737,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() @@ -810,7 +1799,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")) { @@ -823,6 +1813,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()) { @@ -832,7 +1824,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")) { @@ -849,7 +1842,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; } @@ -885,7 +1880,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")) { @@ -903,6 +1899,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()); } @@ -912,7 +1910,9 @@ List validate() { if (biomes.entrySet().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; @@ -933,16 +1933,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()); } } @@ -950,6 +1953,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, "ore_dictionary_entries")) { + if (!validOreDictionaryName(alias)) { + errors.add("Invalid Ore Dictionary entry in " + entry.getKey() + ": " + alias); + } else if (!aliases.add(alias)) { + errors.add("Ore Dictionary entry 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); @@ -1008,6 +2067,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()) { @@ -1019,6 +2083,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; } @@ -1027,6 +2095,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); @@ -1165,9 +2242,9 @@ private static boolean validFluidBlock(String id) { return block != null && block != Blocks.AIR && isFluidBlock(block); } - private static boolean isFluidBlock(Block block) { + static boolean isFluidBlock(Block block) { return block instanceof BlockLiquid || block instanceof IFluidBlock - || block.getDefaultState().getMaterial().isLiquid(); + || FluidRegistry.lookupFluidForBlock(block) != null; } private static boolean validResource(String id) { @@ -1180,6 +2257,53 @@ private static boolean validResource(String id) { } } + private static boolean validOreDictionaryName(String name) { + return name != null && name.length() > 3 && name.length() <= 128 + && name.startsWith("ore") && Character.isUpperCase(name.charAt(3)) + && name.matches("[A-Za-z0-9_]+"); + } + + 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 47c28c42..4a5a8870 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java @@ -100,9 +100,17 @@ protected void init() { previous.enabled = page > 0; next.enabled = 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 = addButton(OreSpawnScreenLayout.button(this, font, + addX - addWidth - 5, controlsY, addWidth, 20, + new TextComponentTranslation("button.orespawn.ore_sources"), + button -> minecraft.displayGuiScreen(new OreSourceListScreen(this, session)))); + sources.visible = tab == MaterialTab.ORES; + OreSpawnScreenLayout.explain(this, sources, "tooltip.orespawn.ore_sources"); Button add = addButton(OreSpawnScreenLayout.button(this, font, - contentLeft + contentWidth - addWidth, controlsY, addWidth, 20, + addX, controlsY, addWidth, 20, new TextComponentTranslation("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..5f00dc9b --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -0,0 +1,394 @@ +package zone.moddev.mc.orespawn.client; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import org.lwjgl.input.Mouse; + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextComponentTranslation; + +/** 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 GuiScreen 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(GuiScreen parent, GeologyEditorSession session, String groupKey) { + super(new TextComponentTranslation("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.displayGuiScreen(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.getStringWidth(I18n.format(groupActionKey)) + 10)) : 0; + int nameLabelWidth = font.getStringWidth( + I18n.format("option.orespawn.ore_source.group_name")) + 8; + + nameField = addButton(new TextFieldWidget(font, contentX + nameLabelWidth, NAME_TOP, + contentWidth - nameLabelWidth - actionWidth - (hasGroupAction ? 5 : 0), 20, + new TextComponentTranslation("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 TextComponentTranslation(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.oreDictionaryEntries; + 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) drawRect(x, y, x + actionWidth, y + rowHeight, 0x805F5F5F); + drawCenteredString(minecraft.fontRendererObj, "-", 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.format("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 TextComponentTranslation("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 TextComponentString(pendingAliasMove == null ? "+" + : I18n.format("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.getStringWidth( + I18n.format("label.orespawn.ore_source.placement_rules")) + 8; + addButton(new Button(helpX, placementLabelY - 4, 16, 16, + new TextComponentString("?"), button -> { }, (button, mouseX, mouseY) -> { + List lines = new ArrayList<>(); + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_rules")); + lines.add(placementStatus(group, channels)); + lines.add(I18n.format("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.format("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.format("tooltip.orespawn.ore_source.placement_rules")); + if ("keep_separate".equals(group.mode)) { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_requires_consolidated")); + } else if (choices.isEmpty()) { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_missing")); + } else if (choices.size() == 1) { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_readonly")); + } else { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_select")); + } + lines.add(I18n.format("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, + DialogTexts.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.displayGuiScreen(parent); + return; + } + if (!group.canDissolve()) return; + if (!pendingDissolve) { + pendingDissolve = true; + error = I18n.format("error.orespawn.ore_source.confirm_dissolve"); + rebuild(); + return; + } + if (session.dissolveOreMaterialGroup(group.material)) minecraft.displayGuiScreen(parent); + } + + private void addAlias(OreSourceGroup group) { + syncName(); + String alias = aliasField == null ? "" : aliasField.getValue().trim(); + String owner = session.oreDictionaryOwner(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.format("error.orespawn.ore_source.manage_vanilla_first"); + rebuild(); + return; + } + if (move != null && !move.equals(pendingAliasMove)) { + pendingDissolve = false; + pendingAliasMove = move; + pendingAliasValue = alias; + error = I18n.format("error.orespawn.ore_source.alias_owned", + OreSourceListScreen.display(owner)); + rebuild(); + return; + } + if (!session.addOreMaterialAlias(group.material, alias, move != null)) { + error = I18n.format("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() { + buttons.clear(); + children.clear(); + 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.format("label.orespawn.ore_source.standard_veins") + : OreSourceListScreen.display(channel); + } + + static String placementSourceLabel(String source, int choices) { + return choices > 1 + ? I18n.format("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.format("label.orespawn.ore_source.no_placement_rules"); + if ("keep_separate".equals(group.mode)) { + return I18n.format("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.format("tooltip.orespawn.ore_source.placement_missing"); + selectable |= choices > 1; + } + return I18n.format(selectable ? "tooltip.orespawn.ore_source.placement_select" + : "tooltip.orespawn.ore_source.placement_readonly"); + } + + @Override + public void handleMouseInput() throws IOException { + int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; + int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1; + int wheel = Mouse.getEventDWheel(); + if (wheel != 0) { + if (aliasList != null) aliasList.mouseWheel(mouseX, mouseY, wheel); + if (placementList != null) placementList.mouseWheel(mouseX, mouseY, wheel); + captureScroll(); + } + super.handleMouseInput(); + } + + @Override + public void onClose() { + syncName(); + captureScroll(); + minecraft.displayGuiScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, new TextComponentTranslation("option.orespawn.ore_source.group_name"), + contentX + 2, NAME_TOP + 6, 0xA0A0A0); + drawString(font, new TextComponentTranslation("label.orespawn.ore_source.aliases"), + contentX + 2, ALIAS_LABEL_TOP, 0xA0A0A0); + drawString(font, new TextComponentTranslation("label.orespawn.ore_source.placement_rules"), + contentX + 2, placementLabelY, 0xFFFFFF); + OreSourceGroup group = selectedGroup(); + if (group != null && group.oreDictionaryEntries.isEmpty()) { + drawCenteredString(font, new TextComponentTranslation("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); + TextComponentString message = new TextComponentString(error); + drawString(font, new TextComponentString(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..281cb6e8 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -0,0 +1,517 @@ +package zone.moddev.mc.orespawn.client; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.lwjgl.input.Mouse; + +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.GuiScreen; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.client.resources.I18n; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextComponentTranslation; + +/** Two-pane material-group editor backed by compact independently scrolling lists. */ +final class OreSourceListScreen extends OreSpawnScreen { + private static final ResourceLocation COG = + new ResourceLocation("orespawn", "textures/gui/cog.png"); + private static final int HORIZONTAL_MARGIN = 10; + private static final int PANE_GAP = 6; + private static final int CONTENT_TOP = 24; + private final GuiScreen 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(GuiScreen parent, GeologyEditorSession session) { + super(new TextComponentTranslation("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.format(resetArmed + ? "button.orespawn.ore_source.confirm_reset_all" + : "button.orespawn.ore_source.reset_all"); + int resetWidth = Math.min(leftPaneWidth, Math.max(54, font.getStringWidth(resetLabel) + 10)); + Button reset = addButton(OreSpawnScreenLayout.button(this, font, + leftPaneX, height - 28, resetWidth, 20, + new TextComponentString(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, + DialogTexts.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.getStringWidth(I18n.format("label.orespawn.ore_source.groups")) + 8; + Button add = addButton(new Button(addX, 4, 16, 16, + new TextComponentString("+"), 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.format(showAll + ? "button.orespawn.ore_source.hide_single" + : "button.orespawn.show_all"); + int showWidth = compactFilterWidth(leftPaneX + leftPaneWidth - showX, + font.getStringWidth(showLabel)); + Button show = addButton(OreSpawnScreenLayout.button(this, font, + showX, 4, showWidth, 16, + new TextComponentString(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(minecraft, x, y, actionWidth, rowHeight, hovered); + } + + @Override protected void onRowActionPressed(int index) { + if (!syncWeights()) return; + captureScroll(); + selectedKey = groups.get(index).key; + minecraft.displayGuiScreen(new OreSourceGroupSettingsScreen( + OreSourceListScreen.this, session, selectedKey)); + } + + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList( + I18n.format("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.format("button.orespawn.ore_source.accept"); + int acceptWidth = group.needsReview() + ? Math.min(Math.max(42, font.getStringWidth(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 TextComponentTranslation("option.orespawn.ore_source.output_mode", + new TextComponentTranslation("mode.orespawn.ore_source." + mode).getFormattedText()), + 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 TextComponentString(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 TextComponentTranslation("option.orespawn.ore_source.weight"))); + weight.setMaxLength(12); + weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); + weight.enabled = 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.format("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.format("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.format("status.orespawn.ore_source.external_short") + : !candidate.loaded ? I18n.format("status.orespawn.ore_source.missing_short") + : !candidate.active ? I18n.format("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.format("label.orespawn.ore_source.owner", candidate.owner, + candidate.ownerVersion.isEmpty() ? "?" : candidate.ownerVersion)); + result.add(I18n.format("label.orespawn.ore_source.registry", candidate.registryId, candidate.metadata)); + result.add(I18n.format("label.orespawn.ore_source.dictionary", + candidate.oreDictionary.isEmpty() ? "-" : String.join(", ", candidate.oreDictionary))); + result.add(I18n.format(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.format("label.orespawn.ore_source.summary", group.outputCandidates().size(), + I18n.format("status.orespawn.ore_source." + group.status))); + result.add(group.oreDictionaryEntries.isEmpty() ? "-" + : String.join(", ", group.oreDictionaryEntries)); + result.add(I18n.format("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(Minecraft minecraft, int x, int y, + int width, int height, boolean hovered) { + if (hovered) drawRect(x, y, x + width, y + height, 0x805F5F5F); + minecraft.getTextureManager().bindTexture(COG); + GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); + GlStateManager.pushMatrix(); + GlStateManager.translate(x + (width / 2.0F), y + (height / 2.0F), 0.0F); + GlStateManager.scale(0.75F, 0.75F, 1.0F); + drawModalRectWithCustomSizedTexture(-8, -8, 0.0F, 0.0F, 16, 16, 16.0F, 16.0F); + drawModalRectWithCustomSizedTexture(-8, -8, 0.0F, 0.0F, 16, 16, 16.0F, 16.0F); + GlStateManager.popMatrix(); + GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); + } + + private void rebuild() { + buttons.clear(); + children.clear(); + 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 handleMouseInput() throws IOException { + int mouseX = Mouse.getEventX() * width / minecraft.displayWidth; + int mouseY = height - Mouse.getEventY() * height / minecraft.displayHeight - 1; + int wheel = Mouse.getEventDWheel(); + if (wheel != 0) { + if (groupList != null) groupList.mouseWheel(mouseX, mouseY, wheel); + if (outputList != null) outputList.mouseWheel(mouseX, mouseY, wheel); + captureScroll(); + } + super.handleMouseInput(); + } + + @Override + public void onClose() { + captureScroll(); + if (syncWeights()) minecraft.displayGuiScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawRect(leftPaneX - 2, CONTENT_TOP - 2, + leftPaneX + leftPaneWidth + 2, paneBottom + 2, 0x70000000); + drawRect(rightPaneX - 2, CONTENT_TOP - 2, + rightPaneX + rightPaneWidth + 2, paneBottom + 2, 0x70000000); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, new TextComponentTranslation("label.orespawn.ore_source.groups"), + leftPaneX + 2, 8, 0xFFFFFF); + if (error != null) { + drawCenteredString(font, new TextComponentString(OreSpawnScreenLayout.fit(font, + new TextComponentString(error), width - 24)), width / 2, height - 39, 0xFF5555); + } + if (selected(orderedGroups()) == null) { + drawCenteredString(font, new TextComponentTranslation("label.orespawn.ore_source.none"), + leftPaneX + (leftPaneWidth / 2), CONTENT_TOP + 10, 0xA0A0A0); + } + 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..f47767a3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java @@ -0,0 +1,123 @@ +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.common.Loader; +import net.minecraftforge.fml.common.ModContainer; + +/** 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 (ModContainer mod : Loader.instance().getActiveModList()) { + loaded.add(new LoadedMod(mod.getModId(), mod.getName(), mod.getVersion())); + } + 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().getResourceDomain(); + 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..67d80c8c --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java @@ -0,0 +1,189 @@ +package zone.moddev.mc.orespawn.client; + +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.GuiScreen; +import net.minecraft.client.resources.I18n; +import net.minecraft.util.text.TextComponentString; +import net.minecraft.util.text.TextComponentTranslation; + +/** Paginated directory of loaded mods known to OreSpawn. */ +final class OreSpawnModsScreen extends OreSpawnScreen { + private static final int ROW_HEIGHT = 42; + private final GuiScreen parent; + private final List entries; + private int page; + private String error; + private int contentLeft; + private int contentWidth; + private int listTop; + private int pageSize; + + OreSpawnModsScreen(GuiScreen parent) { + this(parent, OreSpawnModDirectoryModel.snapshot()); + } + + OreSpawnModsScreen(GuiScreen parent, List entries) { + super(new TextComponentTranslation("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.enabled = entry.configurable(); + } + + Button previous = addButton(new Button(contentLeft, controlsY, 80, 20, + new TextComponentTranslation("button.orespawn.previous"), + button -> { page--; rebuildWidgets(); })); + Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20, + new TextComponentTranslation("button.orespawn.next"), + button -> { page++; rebuildWidgets(); })); + previous.enabled = page > 0; + next.enabled = page + 1 < pageCount; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private void open(Entry entry) { + if (!entry.configurable()) return; + try { + GuiScreen screen = entry.extension.createScreen(this); + if (screen == null) { + error = I18n.format("error.orespawn.mod.screen_missing", entry.modId); + return; + } + WorldSettingsExtensionNavigation.open(this, screen); + } catch (RuntimeException failure) { + error = I18n.format("error.orespawn.mod.screen_failed", entry.modId, + failure.getClass().getSimpleName()); + } + } + + private List tooltip(Entry entry) { + List lines = new ArrayList<>(); + lines.add(I18n.format(entry.configurable() + ? "tooltip.orespawn.mod.configure" : "tooltip.orespawn.mod.unavailable", + entry.displayName)); + lines.add(I18n.format("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.format("label.orespawn.mod.additional_lineage", lineage)); + } + lines.add(I18n.format("label.orespawn.mod.status", + I18n.format("status.orespawn.mod." + entry.statusKey()))); + return lines; + } + + private String integration(Entry entry) { + if (entry.nativeOs4()) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.format("label.orespawn.mod.os4_version", + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.format("label.orespawn.mod.os4"); + } + int legacy = primaryLegacy(entry); + if (legacy > 0) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.format("label.orespawn.mod.legacy_version", legacy, + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.format("label.orespawn.mod.legacy", legacy); + } + return I18n.format("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() { + buttons.clear(); + children.clear(); + 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.displayGuiScreen(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 TextComponentTranslation("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 TextComponentString((page + 1) + " / " + pages), + width / 2, height - 46, 0xA0A0A0); + if (error != null) { + drawCenteredString(font, new TextComponentString(error), width / 2, 27, 0xFF5555); + } + super.render(mouseX, mouseY, partialTick); + } + + private void drawEntry(Entry entry, int y) { + drawRect(contentLeft, y, contentLeft + contentWidth, y + 38, 0x70000000); + int textWidth = contentWidth - 36; + drawString(font, new TextComponentString(trim(entry.displayName, textWidth)), + contentLeft + 6, y + 3, 0xFFFFFF); + drawString(font, new TextComponentString(trim( + I18n.format("label.orespawn.mod.id_version", entry.modId, entry.version), textWidth)), + contentLeft + 6, y + 14, 0xA0A0A0); + String details = I18n.format("label.orespawn.mod.integration_status", integration(entry), + I18n.format("status.orespawn.mod." + entry.statusKey())); + drawString(font, new TextComponentString(trim(details, textWidth)), + contentLeft + 6, y + 25, statusColor(entry.statusKey())); + } + + private String trim(String value, int maximumWidth) { + if (font.getStringWidth(value) <= maximumWidth) return value; + String suffix = "..."; + int width = maximumWidth - font.getStringWidth(suffix); + return font.trimStringToWidth(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 index f553a590..45bbe2fc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java @@ -98,6 +98,17 @@ protected final void renderComponentTooltip(List lines } final void renderStringTooltip(List lines, int mouseX, int mouseY) { - drawHoveringText(lines, mouseX, mouseY, fontRendererObj); + List wrapped = new ArrayList<>(); + int maximumWidth = tooltipWidth(width, mouseX); + for (String line : lines) { + if (line == null || line.isEmpty()) wrapped.add(""); + else wrapped.addAll(fontRendererObj.listFormattedStringToWidth(line, maximumWidth)); + } + drawHoveringText(wrapped, mouseX, mouseY, fontRendererObj); + } + + static int tooltipWidth(int screenWidth, int mouseX) { + int room = Math.max(mouseX - 24, screenWidth - mouseX - 24); + return Math.max(80, Math.min(260, Math.min(screenWidth - 32, room))); } } 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 9c21d7ee..2bef8d9b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java @@ -103,12 +103,10 @@ static void renderExplanations(OreSpawnScreen screen, int mouseX, int mouseY) { && mouseX < explanation.widget.xPosition + explanation.widget.width && mouseY >= explanation.widget.yPosition && mouseY < explanation.widget.yPosition + explanation.widget.height) { - FontRenderer font = Minecraft.getMinecraft().fontRendererObj; String text = explanation.translation ? new TextComponentTranslation(explanation.text).getFormattedText() : explanation.text; - screen.renderStringTooltip(font.listFormattedStringToWidth(text, - Math.max(180, Math.min(310, screen.width - 20))), mouseX, mouseY); + screen.renderStringTooltip(java.util.Collections.singletonList(text), mouseX, mouseY); return; } } 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 4e540796..f664ab6f 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; @@ -106,8 +107,11 @@ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.biomes_world_materials"), button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials")); - addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex), - contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"), + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.mods"), + button -> openMods(), "tooltip.orespawn.main.mods")); + addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++), + columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"), button -> openHelp())); } else { geologyModeButton = OreSpawnScreenLayout.explain(this, @@ -150,12 +154,15 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.materi addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"), button -> openAdvanced(), "tooltip.orespawn.main.advanced")); + addButton(OreSpawnScreenLayout.explainedButton(this, font, right, top + (row * rowIndex++), + columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), + "tooltip.orespawn.main.fluid_editor")); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.mods"), + button -> openMods(), "tooltip.orespawn.main.mods")); addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++), columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"), button -> openHelp())); - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), - contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), - "tooltip.orespawn.main.fluid_editor")); } addButton(OreSpawnScreenLayout.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT, DialogTexts.GUI_DONE, button -> saveAndClose())); @@ -274,6 +281,11 @@ private void saveAndClose() { validationError = new net.minecraft.util.text.TextComponentString(errors.get(0)); return; } + if (session.oreMaterialGroupsChanged() + && !GeomeConfig.persistOreMaterialGroups(session.oreMaterialGroupsCopy())) { + validationError = new TextComponentTranslation("error.orespawn.ore_source.defaults_write"); + return; + } WorldGeologyProfileManager.setPendingNewWorldProfile(session.profile()); minecraft.displayGuiScreen(parent); } @@ -282,9 +294,8 @@ private void syncSession() { WorldGeologyProfile selected = session.profile().withSelection( geologyMode, horizontalSize, verticalThickness, waviness, edgeIrregularity, formationContinuity, placeFluidDeposits); - com.google.gson.JsonObject root = selected.rootCopy(); - root.addProperty("manage_vanilla_ores", manageVanillaOres); - session.applyProfile(selected.withRoot(root)); + session.applyProfile(selected); + session.setManageVanillaOres(manageVanillaOres); } private void openMaterials() { @@ -322,6 +333,11 @@ private void openHelp() { minecraft.displayGuiScreen(new OreSpawnGuideScreen(this)); } + private void openMods() { + syncSession(); + minecraft.displayGuiScreen(new OreSpawnModsScreen(this)); + } + @Override public void onClose() { minecraft.displayGuiScreen(parent); @@ -354,7 +370,7 @@ private ITextComponent fluidControlLabel() { JsonObjectAccess access = new JsonObjectAccess(session.fluidDeposit(ids.get(0))); ResourceLocation blockId = access.resource("block"); Block block = blockId == null ? null : ForgeRegistries.BLOCKS.getValue(blockId); - if (block != null) return DialogTexts.blockName(block); + if (block != null) return DialogTexts.blockName(block, blockId.toString()); } return new TextComponentTranslation("option.orespawn.fluid_deposits"); } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java index fce89288..d644c5dd 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java @@ -50,6 +50,17 @@ void setMaxLength(int length) { field.setMaxStringLength(length); } + void setBounds(int x, int y, int width, int height) { + xPosition = x; + yPosition = y; + this.width = width; + this.height = height; + field.xPosition = x; + field.yPosition = y; + field.width = width; + field.height = height; + } + void func_212954_a(Consumer responder) { field.setGuiResponder(new net.minecraft.client.gui.GuiPageButtonList.GuiResponder() { @Override public void setEntryValue(int id, boolean value) { } 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..ffb6032d --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java @@ -0,0 +1,39 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.client.event.GuiOpenEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; + +/** Keeps an add-on's inherited Escape action inside OreSpawn's screen stack. */ +public final class WorldSettingsExtensionNavigation { + private static GuiScreen directory; + private static GuiScreen extensionScreen; + + private WorldSettingsExtensionNavigation() { + } + + static synchronized void open(GuiScreen parentDirectory, GuiScreen screen) { + directory = parentDirectory; + extensionScreen = screen; + Minecraft.getMinecraft().displayGuiScreen(screen); + } + + @SubscribeEvent + public static synchronized void onScreenOpen(GuiOpenEvent event) { + if (extensionScreen == null) return; + // This is the initial transition from the directory to the add-on. + if (event.getGui() == extensionScreen) return; + Minecraft minecraft = Minecraft.getMinecraft(); + boolean inheritedEscape = event.getGui() == null + || (minecraft.world == null && event.getGui() instanceof GuiMainMenu); + if (minecraft.currentScreen == extensionScreen && inheritedEscape) { + event.setGui(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/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java index 0dde25e6..82cfb426 100644 --- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java @@ -68,6 +68,7 @@ public final class WorldgenIntegrationManager { private static final Map FILE_PROVIDERS = new LinkedHashMap<>(); private static final Map ACTIVE_PROVIDERS = new LinkedHashMap<>(); private static final Map TEMPLATES = new LinkedHashMap<>(); + private static final Map> LEGACY_LINEAGES = new LinkedHashMap<>(); private static final Set INVALID_PROVIDERS = new HashSet<>(); private static final Set FILE_PROVIDER_IDS = new HashSet<>(); private static boolean initialized; @@ -166,15 +167,103 @@ public static synchronized Set activeProviderIds() { return Collections.unmodifiableSet(new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet())); } + /** + * Returns a detached snapshot of the currently loaded provider-owned biome + * defaults. This is an internal editor/reset contract; callers can mutate the + * returned JSON copies without changing registered providers. + */ + 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); + } + } + + /** Records a loaded legacy integration at the point where its source is identified. */ + public static synchronized void recordLegacyLineage(String providerModId, int orespawnGeneration) { + if (providerModId == null || !MOD_ID.matcher(providerModId).matches() + || orespawnGeneration < 1 || orespawnGeneration > 3) { + return; + } + LEGACY_LINEAGES.computeIfAbsent(providerModId, ignored -> new LinkedHashSet<>()) + .add(orespawnGeneration); + } + + /** Returns immutable provider metadata for the client-side mod directory. */ + public static synchronized List providerIntegrations() { + Set providerIds = new LinkedHashSet<>(); + providerIds.addAll(API_PROVIDERS.keySet()); + providerIds.addAll(RESOURCE_PROVIDERS.keySet()); + providerIds.addAll(FILE_PROVIDER_IDS); + providerIds.addAll(ACTIVE_PROVIDERS.keySet()); + providerIds.addAll(INVALID_PROVIDERS); + providerIds.addAll(LEGACY_LINEAGES.keySet()); + List sorted = new ArrayList<>(providerIds); + Collections.sort(sorted); + List result = new ArrayList<>(); + for (String providerId : sorted) { + JsonObject root = FILE_PROVIDERS.get(providerId); + if (root == null) root = RESOURCE_PROVIDERS.get(providerId); + ProviderDefinition active = ACTIVE_PROVIDERS.get(providerId); + if (root == null && active != null) root = active.root; + WorldgenProvider api = API_PROVIDERS.get(providerId); + List legacy = new ArrayList<>(LEGACY_LINEAGES.getOrDefault( + providerId, Collections.emptySet())); + Collections.sort(legacy); + boolean hasApiOrResource = api != null + || RESOURCE_PROVIDERS.containsKey(providerId); + boolean nativeOs4 = hasApiOrResource + || (FILE_PROVIDER_IDS.contains(providerId) && legacy.isEmpty()); + boolean hasProvider = root != null || api != null + || RESOURCE_PROVIDERS.containsKey(providerId) + || FILE_PROVIDER_IDS.contains(providerId); + result.add(new ProviderIntegrationInfo(providerId, nativeOs4, 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(providerId), INVALID_PROVIDERS.contains(providerId), legacy)); + } + return Collections.unmodifiableList(result); + } + /** Merge new provider-owned defaults without overwriting pack or world values. */ public static synchronized boolean mergeProviderDefinitions(JsonObject target) { + return mergeProviderDefinitions(target, false); + } + + /** + * Merge provider definitions into an already-created world's saved profile. + * Providers may opt out so structural configuration is captured only when a + * new world is created. + */ + public static synchronized boolean mergeProviderDefinitionsIntoExistingWorld(JsonObject target) { + return mergeProviderDefinitions(target, true); + } + + private static boolean mergeProviderDefinitions(JsonObject target, boolean existingWorld) { JsonObject manifests = object(target, "providers"); JsonObject legacyOreManifests = object(target, "ore_providers"); boolean changed = false; for (ProviderDefinition provider : ACTIVE_PROVIDERS.values()) { JsonObject manifest = object(manifests, provider.modId); - if (!bool(manifest, "profile_defaults_applied", false) + boolean mergeNewEntries = !existingWorld + || bool(provider.root, "merge_new_entries_into_existing_worlds", true); + if (mergeNewEntries && !bool(manifest, "profile_defaults_applied", false) && provider.root.has("profile_defaults") && provider.root.get("profile_defaults").isJsonObject()) { mergeOverlay(target, provider.root.getAsJsonObject("profile_defaults")); @@ -194,7 +283,7 @@ public static synchronized boolean mergeProviderDefinitions(JsonObject target) { for (Entry entry : providerSection.entrySet()) { String id = entry.getKey(); - if (!targetSection.has(id) && !known.contains(id)) { + if (mergeNewEntries && !targetSection.has(id) && !known.contains(id)) { JsonObject value = JsonCopies.copy(entry.getValue().getAsJsonObject()); if (!"biome_rules".equals(sectionName)) { value.addProperty("source_provider", provider.modId); @@ -202,7 +291,9 @@ public static synchronized boolean mergeProviderDefinitions(JsonObject target) { targetSection.add(id, value); changed = true; } - known.add(id); + if (targetSection.has(id)) { + known.add(id); + } if (targetSection.has(id) && targetSection.get(id).isJsonObject()) { targetSection.getAsJsonObject(id).remove("orphaned_provider"); } @@ -411,14 +502,7 @@ private static void rebuildActiveProviders() { if (INVALID_PROVIDERS.contains(providerId)) { continue; } - JsonObject root = FILE_PROVIDERS.get(providerId); - if (root == null) { - root = RESOURCE_PROVIDERS.get(providerId); - } - if (root == null) { - WorldgenProvider apiProvider = API_PROVIDERS.get(providerId); - root = apiProvider == null ? null : apiProvider.toJson(); - } + JsonObject root = selectedProviderRoot(providerId); if (root == null || !Loader.isModLoaded(providerId)) { continue; } @@ -436,9 +520,19 @@ private static void rebuildActiveProviders() { } } + private static JsonObject selectedProviderRoot(String providerId) { + JsonObject root = FILE_PROVIDERS.get(providerId); + if (root == null) root = RESOURCE_PROVIDERS.get(providerId); + if (root == null) { + WorldgenProvider apiProvider = API_PROVIDERS.get(providerId); + root = apiProvider == null ? null : apiProvider.toJson(); + } + return root; + } + 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", ""))) { @@ -447,6 +541,11 @@ static void validateProvider(String providerId, JsonObject root) { if (integer(root, "provider_revision", -1) < 1) { throw new JsonSyntaxException("provider_revision must be at least 1"); } + if (root.has("merge_new_entries_into_existing_worlds") + && (!root.get("merge_new_entries_into_existing_worlds").isJsonPrimitive() + || !root.getAsJsonPrimitive("merge_new_entries_into_existing_worlds").isBoolean())) { + throw new JsonSyntaxException("merge_new_entries_into_existing_worlds must be boolean"); + } if (schema == 1) { for (String section : new String[] { "rocks", "geomes", "biome_rules", "terrain_dimensions", "fluid_deposits", "biome_palettes", @@ -482,7 +581,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)) { @@ -537,7 +636,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) { @@ -553,30 +656,39 @@ 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); double frequency = decimal(rule, "frequency", -1.0D); double discardChance = decimal(rule, "discard_chance_on_air_exposure", 0.0D); + double backgroundScale = decimal(rule, "background_generation_scale", 1.0D); int[] quantities = validateQuantityRange(rule); int minQuantity = quantities[0]; int maxQuantity = quantities[1]; if (minY < -2048 || maxY > 2048 || minY > maxY || frequency < 0.0D || frequency > 64.0D || !Double.isFinite(discardChance) || discardChance < 0.0D || discardChance > 1.0D + || !Double.isFinite(backgroundScale) || backgroundScale < 0.0D + || backgroundScale > 1.0D || minQuantity < 1 || minQuantity > maxQuantity || maxQuantity > 64) { throw new JsonSyntaxException("invalid ore placement for " + idText + " in " + entry.getKey()); } @@ -1052,6 +1164,59 @@ JsonObject section(String name) { } } + /** Immutable internal-facing metadata used to describe one OreSpawn integration. */ + 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; } + } + + /** Immutable, copy-on-read provider defaults used only by OreSpawn's editor. */ + 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/worldgen/AquiferMaterialSubstitution.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitution.java new file mode 100644 index 00000000..ae359d4f --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitution.java @@ -0,0 +1,226 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.BitSet; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.chunk.ChunkPrimer; +import net.minecraft.world.chunk.IChunkGenerator; +import net.minecraft.world.chunk.storage.ExtendedBlockStorage; +import net.minecraft.world.gen.ChunkProviderOverworld; +import net.minecraftforge.event.terraingen.ChunkGeneratorEvent; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Replaces the native Overworld aquifer fluid after vanilla has built and lit + * the chunk. The primer mask keeps the substitution limited to terrain fluid; + * lakes, springs, and later decorators are deliberately outside it. + */ +final class AquiferMaterialSubstitution { + private static final Logger LOGGER = LogManager.getLogger(); + private static final Map NATIVE_FLUIDS = + Collections.synchronizedMap(new IdentityHashMap()); + private static final Map PLANS = + Collections.synchronizedMap(new IdentityHashMap()); + private static final PendingMasks PENDING = new PendingMasks(); + + private AquiferMaterialSubstitution() { + } + + static void configure(WorldServer world, IBlockState selected) { + IChunkGenerator raw = world.getChunkProvider().chunkGenerator; + if (!(raw instanceof ChunkProviderOverworld)) { + PLANS.remove(raw); + PENDING.remove(raw); + return; + } + + ChunkProviderOverworld generator = (ChunkProviderOverworld) raw; + IBlockState nativeFluid; + synchronized (NATIVE_FLUIDS) { + nativeFluid = NATIVE_FLUIDS.get(generator); + if (nativeFluid == null) { + nativeFluid = generator.oceanBlock; + NATIVE_FLUIDS.put(generator, nativeFluid); + } + } + generator.oceanBlock = nativeFluid; + PLANS.remove(raw); + PENDING.remove(raw); + + if (selected == null || sameState(selected, nativeFluid)) return; + if (lightCompatible(nativeFluid, selected)) { + PLANS.put(raw, new Plan(nativeFluid, selected, true)); + return; + } + + generator.oceanBlock = selected; + PLANS.put(raw, new Plan(nativeFluid, selected, false)); + LOGGER.warn("Dimension '{}' uses aquifer fluid '{}' with opacity/light {}/{} instead of " + + "native '{}' at {}/{}; using the compatible generator path, which may generate more slowly", + WorldIds.dimension(world), selected, selected.getLightOpacity(), selected.getLightValue(), + nativeFluid, nativeFluid.getLightOpacity(), nativeFluid.getLightValue()); + } + + static void capture(ChunkGeneratorEvent.ReplaceBiomeBlocks event) { + if (WorldgenBenchmark.isVanillaBaseline() || event.getWorld() == null) return; + Plan plan = PLANS.get(event.getGenerator()); + if (plan == null || !plan.deferred) return; + BitSet mask = captureMask(event.getPrimer(), plan.nativeFluid, + event.getWorld().getSeaLevel()); + if (!mask.isEmpty()) { + PENDING.put(event.getGenerator(), event.getX(), event.getZ(), + new Pending(plan.nativeFluid, plan.selectedFluid, mask)); + } + } + + static boolean apply(World world, Chunk chunk) { + if (!(world instanceof WorldServer)) return false; + IChunkGenerator generator = ((WorldServer) world).getChunkProvider().chunkGenerator; + Pending pending = PENDING.take(generator, chunk.xPosition, chunk.zPosition); + if (pending == null) return false; + + ExtendedBlockStorage[] sections = chunk.getBlockStorageArray(); + int changed = applyToSections(pending, sections); + if (changed > 0) ChunkAccessCompat.markChanged(chunk); + return changed > 0; + } + + static int applyToSections(Pending pending, ExtendedBlockStorage[] sections) { + int changed = 0; + for (int index = pending.mask.nextSetBit(0); index >= 0; + index = pending.mask.nextSetBit(index + 1)) { + int y = localY(index); + ExtendedBlockStorage section = sections[y >> 4]; + if (section == null) continue; + int x = localX(index); + int z = localZ(index); + IBlockState current = section.get(x, y & 15, z); + if (!sameState(current, pending.nativeFluid)) continue; + section.set(x, y & 15, z, pending.selectedFluid); + changed++; + } + return changed; + } + + static BitSet captureMask(ChunkPrimer primer, IBlockState nativeFluid, int seaLevel) { + BitSet result = new BitSet(16 * 16 * 256); + int maximumY = Math.max(0, Math.min(256, seaLevel)); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < maximumY; y++) { + if (sameState(primer.getBlockState(x, y, z), nativeFluid)) { + result.set(index(x, y, z)); + } + } + } + } + return result; + } + + static boolean lightCompatible(IBlockState nativeFluid, IBlockState selectedFluid) { + return nativeFluid.getLightOpacity() == selectedFluid.getLightOpacity() + && nativeFluid.getLightValue() == selectedFluid.getLightValue(); + } + + static int index(int x, int y, int z) { + return (x << 12) | (z << 8) | y; + } + + static int localX(int index) { return (index >> 12) & 15; } + static int localY(int index) { return index & 255; } + static int localZ(int index) { return (index >> 8) & 15; } + + static void clear() { + synchronized (NATIVE_FLUIDS) { + for (Map.Entry entry : NATIVE_FLUIDS.entrySet()) { + entry.getKey().oceanBlock = entry.getValue(); + } + NATIVE_FLUIDS.clear(); + } + PLANS.clear(); + PENDING.clear(); + } + + static int pendingCount() { return PENDING.size(); } + + private static boolean sameState(IBlockState first, IBlockState second) { + return first == second || (first != null && first.equals(second)); + } + + private static final class Plan { + final IBlockState nativeFluid; + final IBlockState selectedFluid; + final boolean deferred; + + Plan(IBlockState nativeFluid, IBlockState selectedFluid, boolean deferred) { + this.nativeFluid = nativeFluid; + this.selectedFluid = selectedFluid; + this.deferred = deferred; + } + } + + static final class Pending { + final IBlockState nativeFluid; + final IBlockState selectedFluid; + final BitSet mask; + + Pending(IBlockState nativeFluid, IBlockState selectedFluid, BitSet mask) { + this.nativeFluid = nativeFluid; + this.selectedFluid = selectedFluid; + this.mask = (BitSet) mask.clone(); + } + } + + static final class PendingMasks { + private final Map masks = new ConcurrentHashMap<>(); + + void put(IChunkGenerator generator, int x, int z, Pending pending) { + masks.put(new ChunkKey(generator, x, z), pending); + } + + Pending take(IChunkGenerator generator, int x, int z) { + return masks.remove(new ChunkKey(generator, x, z)); + } + + void remove(IChunkGenerator generator) { + for (ChunkKey key : masks.keySet()) { + if (key.generator == generator) masks.remove(key); + } + } + + void clear() { masks.clear(); } + int size() { return masks.size(); } + } + + private static final class ChunkKey { + final IChunkGenerator generator; + final int x; + final int z; + + ChunkKey(IChunkGenerator generator, int x, int z) { + this.generator = generator; + this.x = x; + this.z = z; + } + + @Override public int hashCode() { + return ((31 * System.identityHashCode(generator)) + x) * 31 + z; + } + + @Override public boolean equals(Object value) { + if (this == value) return true; + if (!(value instanceof ChunkKey)) return false; + ChunkKey other = (ChunkKey) value; + return generator == other.generator && x == other.x && z == other.z; + } + } +} 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 5dfd8e8f..a94d2396 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java @@ -25,7 +25,6 @@ import net.minecraft.block.Block; import net.minecraft.init.Blocks; import net.minecraft.block.state.IBlockState; -import net.minecraft.world.gen.ChunkProviderOverworld; import net.minecraft.world.chunk.IChunkGenerator; import net.minecraftforge.fluids.IFluidBlock; import net.minecraftforge.fml.common.registry.ForgeRegistries; @@ -35,11 +34,10 @@ /** 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 baked = Collections.emptyMap(); - private static final Map ORIGINAL_DEFAULT_FLUIDS = - new IdentityHashMap<>(); private static final Set WARNED_DEEP_FLUID_DIMENSIONS = new LinkedHashSet<>(); @@ -91,10 +89,7 @@ static synchronized void apply(WorldServer level, WorldGeologyProfile profile) { static synchronized void clear() { BiomeFeatureInstaller.restoreAll(); - for (Entry entry : ORIGINAL_DEFAULT_FLUIDS.entrySet()) { - setDefaultFluid(entry.getKey(), entry.getValue()); - } - ORIGINAL_DEFAULT_FLUIDS.clear(); + AquiferMaterialSubstitution.clear(); WARNED_DEEP_FLUID_DIMENSIONS.clear(); baked = Collections.emptyMap(); } @@ -114,11 +109,12 @@ private static BakedBiomeWorldgen bake(WorldServer level, JsonObject root) { return new BakedBiomeWorldgen(palettes, surfaces, materials); } - private static List bakePalettes(JsonObject root, + static List bakePalettes(JsonObject root, ResourceLocation dimension, Map surfaces) { JsonObject section = object(root, "biome_palettes"); List result = new ArrayList<>(); - for (Entry paletteEntry : section.entrySet()) { + List> ordered = orderedPaletteEntries(section); + for (Entry paletteEntry : ordered) { if (!paletteEntry.getValue().isJsonObject()) continue; JsonObject json = paletteEntry.getValue().getAsJsonObject(); if (!bool(json, "enabled", true) @@ -178,6 +174,17 @@ private static List bakePalettes(JsonObject root, 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( JsonObject palette, BakedBiomeWorldgen.Entry[] entries) { boolean replace = "replace".equals(string(palette, "mode", "augment")); @@ -274,7 +281,8 @@ private static void installBiomeSource(WorldServer level, BakedBiomeWorldgen con private static void installAquiferMaterials(WorldServer level, DimensionMaterials materials) { IChunkGenerator raw = level.getChunkProvider().chunkGenerator; - if (!(raw instanceof ChunkProviderOverworld)) { + if (!(raw instanceof net.minecraft.world.gen.ChunkProviderOverworld)) { + AquiferMaterialSubstitution.configure(level, null); if (materials != null && materials.defaultFluid != null && WARNED_DEEP_FLUID_DIMENSIONS.add(WorldIds.dimension(level))) { LOGGER.warn("Dimension '{}' requests a generator fluid override that Forge 1.10 does not expose; preserving the profile value", @@ -282,14 +290,10 @@ private static void installAquiferMaterials(WorldServer level, DimensionMaterial } return; } - ChunkProviderOverworld generator = (ChunkProviderOverworld) raw; - IBlockState original = ORIGINAL_DEFAULT_FLUIDS.computeIfAbsent(generator, - ignored -> generator.oceanBlock); - IBlockState selected = materials != null && materials.defaultFluid != null - ? materials.defaultFluid : original; - setDefaultFluid(generator, selected); + IBlockState selected = materials == null ? null : materials.defaultFluid; + AquiferMaterialSubstitution.configure(level, selected); if (materials != null && materials.deepFluid != null - && !materials.deepFluid.equals(selected) + && (selected == null || !materials.deepFluid.equals(selected)) && materials.deepFluidMaxY >= 0 && WARNED_DEEP_FLUID_DIMENSIONS.add(WorldIds.dimension(level))) { LOGGER.warn("Dimension '{}' requests a distinct deep aquifer fluid below Y {}, but Minecraft 1.10.2 only exposes one generator fluid; preserving the profile value and using default_fluid for generation", @@ -297,10 +301,6 @@ private static void installAquiferMaterials(WorldServer level, DimensionMaterial } } - private static void setDefaultFluid(ChunkProviderOverworld generator, IBlockState fluid) { - generator.oceanBlock = fluid; - } - private static Surface surface(JsonObject json) { IBlockState top = state(json, "top_block", false); IBlockState filler = state(json, "filler_block", false); 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 02642706..a906dc4f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -63,6 +63,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", @@ -72,7 +76,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; @@ -98,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); } @@ -163,6 +168,23 @@ public static WorldGeologyProfile globalBaseProfile() { return globalBaseProfile; } + /** Persists only the reusable material-group defaults accepted by the world editor. */ + 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(ResourceLocation dimension) { return terrainDimension(dimension) != null; } @@ -195,13 +217,15 @@ private static JsonObject loadConfig() { int schemaVersion = getInt(root, "schema_version", 1); if (schemaVersion < SCHEMA_VERSION) { JsonObject migrated = schemaVersion <= 1 ? migrateV1(root) : JsonCopies.copy(root); - migrated = migrateToV6(migrated, defaults); + 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) { @@ -664,14 +688,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) : JsonCopies.copy(original); 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, JsonCopies.copy(defaults.get(key))); } @@ -1415,6 +1440,8 @@ private static JsonObject defaultConfig() { root.add("cyano", defaultCyanoConfig()); root.add("fluid_deposits", new JsonObject()); root.add("ores", defaultOreConfig()); + root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults()); + root.add(OreSourcePolicies.SECTION, new JsonObject()); root.add("ore_providers", new JsonObject()); root.add("providers", new JsonObject()); root.add("terrain_dimensions", new JsonObject()); 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 953e0516..aab3b5d4 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -46,6 +46,7 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults) { static JsonObject migrateIfNeeded(Path target, JsonObject defaults, BiFunction> providerRules) { + recordLegacyOwners(target.getParent()); if (Files.exists(target)) return null; Path config = target.getParent(); Path mineralogy = config.resolve("mineralogy-geomes.json"); @@ -102,6 +103,16 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults, return migrated; } + private static void recordLegacyOwners(Path config) { + if (config == null) return; + for (Path path : legacyFiles(config)) { + JsonObject root = readObject(path); + if (root == null || !"2.0".equals(string(root, "version", ""))) continue; + String owner = safe(path.getFileName().toString().replaceFirst("\\.json$", "")); + WorldgenIntegrationManager.recordLegacyLineage(owner, 2); + } + } + private static String migratedRuleId(String owner, String legacyName, JsonObject converted, JsonObject existingRules, BiFunction> providerRules, List report) { 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 413115bc..145648cd 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -125,7 +125,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configDirectory, } List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.110021 Upgrade Report"); + lines.add("OreSpawn 4.1.0.110021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + mineralogyVersion diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java index b36f5c09..f1113d92 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java @@ -65,7 +65,7 @@ private static void writeInitialUpgradeReport(Path configDirectory, boolean forceRetrogen, boolean flatBedrock, boolean retrogenBedrock, int bedrockLayers) throws IOException { String newline = System.lineSeparator(); - String text = "OreSpawn 4.0.16.110021 Upgrade Report" + newline + String text = "OreSpawn 4.1.0.110021 Upgrade Report" + newline + "================================" + newline + newline + "RESULT: Legacy OreSpawn settings were imported into the OS4 profile." + newline + "- Manage vanilla ores: " + manageVanilla + newline 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..16034505 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java @@ -0,0 +1,297 @@ +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.util.ResourceLocation; +import zone.moddev.mc.orespawn.util.JsonCopies; + +/** Stable material names and exact Ore Dictionary aliases used by source arbitration. */ +final class OreMaterialGroups { + static final String SECTION = "ore_material_groups"; + 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", "oreSulfur", "oreSulphur"); + mergeCurated(normalized, ALUMINUM, "Aluminum", "oreAluminum", "oreAluminium"); + deduplicateAliases(normalized); + root.add(SECTION, normalized); + return !before.toString().equals(normalized.toString()); + } + + static JsonObject defaults() { + JsonObject root = new JsonObject(); + mergeCurated(root, SULFUR, "Sulfur", "oreSulfur", "oreSulphur"); + mergeCurated(root, ALUMINUM, "Aluminum", "oreAluminum", "oreAluminium"); + return root; + } + + static Inference infer(JsonObject root, Iterable oreNames) { + Map owners = aliases(root); + Set materials = new LinkedHashSet<>(); + Set exactNames = new LinkedHashSet<>(); + for (String name : oreNames) { + if (!validOreName(name)) continue; + exactNames.add(name); + ResourceLocation material = owners.get(name); + if (material == null) { + String token = name.substring(3).toLowerCase(Locale.ROOT); + 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("ore_dictionary_entries") && value.get("ore_dictionary_entries").isJsonArray()) { + for (JsonElement element : value.getAsJsonArray("ore_dictionary_entries")) { + try { + String name = element.getAsString(); + if (validOreName(name) && !names.contains(name)) names.add(name); + } catch (RuntimeException ignored) { } + } + } + Collections.sort(names); + String display = string(value, "display_name", humanize(id.getResourcePath())); + result.add(new Definition(id, display, names, 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.getResourcePath()), Collections.emptyList(), curated(id)); + } + + static boolean ensureDefinition(JsonObject root, ResourceLocation id, Iterable oreNames) { + List exact = exactOreNames(oreNames); + 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("ore_dictionary_entries", new JsonArray()); + groups.add(id.toString(), value); + changed = true; + } + JsonArray aliases = value.has("ore_dictionary_entries") + && value.get("ore_dictionary_entries").isJsonArray() + ? value.getAsJsonArray("ore_dictionary_entries") : new JsonArray(); + if (!value.has("ore_dictionary_entries") || !value.get("ore_dictionary_entries").isJsonArray()) { + value.add("ore_dictionary_entries", 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 (!validOreName(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 validOreName(String name) { + return name != null && name.length() > 3 && name.length() <= 128 + && name.startsWith("ore") && Character.isUpperCase(name.charAt(3)) + && name.matches("[A-Za-z0-9_]+") ; + } + + 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.getResourcePath()))); + JsonArray names = new JsonArray(); + Set seen = new LinkedHashSet<>(); + if (source.has("ore_dictionary_entries") && source.get("ore_dictionary_entries").isJsonArray()) { + for (JsonElement element : source.getAsJsonArray("ore_dictionary_entries")) { + try { + String name = element.getAsString(); + if (validOreName(name) && seen.add(name)) names.add(new JsonPrimitive(name)); + } catch (RuntimeException ignored) { } + } + } + value.add("ore_dictionary_entries", names); + 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("ore_dictionary_entries", 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("ore_dictionary_entries") && value.get("ore_dictionary_entries").isJsonArray()) { + for (JsonElement element : value.getAsJsonArray("ore_dictionary_entries")) { + try { + String name = element.getAsString(); + if (validOreName(name) && claimed.add(name)) unique.add(new JsonPrimitive(name)); + } catch (RuntimeException ignored) { } + } + } + value.add("ore_dictionary_entries", unique); + } + } + + private static Map aliases(JsonObject root) { + Map result = new LinkedHashMap<>(); + for (Definition definition : definitions(root)) { + for (String name : definition.oreDictionaryEntries) result.putIfAbsent(name, definition.id); + } + 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 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" + : exact.get(0).substring(3).toLowerCase(Locale.ROOT); + 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 exactOreNames(Iterable oreNames) { + Set unique = new LinkedHashSet<>(); + for (String name : oreNames) if (validOreName(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.getResourceDomain()) + || !id.getResourcePath().startsWith("review/")) { + return humanize(id.getResourcePath()); + } + StringBuilder display = new StringBuilder(); + for (String name : exact) { + if (display.length() > 0) display.append(", "); + display.append(humanize(name.substring(3))); + } + return display.length() == 0 ? "Review required" : display.toString(); + } + + static final class Definition { + final ResourceLocation id; + final String displayName; + final List oreDictionaryEntries; + final boolean curated; + + Definition(ResourceLocation id, String displayName, List oreDictionaryEntries, boolean curated) { + this.id = id; + this.displayName = displayName; + this.oreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(oreDictionaryEntries)); + 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..08932753 --- /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.Arrays; +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.block.Block; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.ModContainer; +import net.minecraftforge.fml.common.registry.ForgeRegistries; +import net.minecraftforge.oredict.OreDictionary; + +/** 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 static final Map> PRIORITIES = priorities(); + private static final Set ORDINARY_MMD = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList( + "basemetals", "modernmetals", "basegems", "baseminerals", "fantasymetals", + "electricadvantage", "steamadvantage", "poweradvantage", "mineralogy"))); + + 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.oreNames); + 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("ore_dictionary")); + 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.oreDictionaryEntries, 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.oreDictionaryEntries, 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, ItemStack output) { + ResourceLocation explicit = resource(string(ore, "material", "")); + if (explicit != null) return explicit; + return inferMaterial(oreNames(output)).material; + } + + static ResourceLocation material(JsonObject root, JsonObject ore, ItemStack output) { + ResourceLocation explicit = resource(string(ore, "material", "")); + if (explicit != null) return explicit; + return inferMaterial(root, oreNames(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 oreNames) { + JsonObject root = new JsonObject(); + root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults()); + return inferMaterial(root, oreNames); + } + + static Inference inferMaterial(JsonObject root, Iterable oreNames) { + OreMaterialGroups.Inference inferred = OreMaterialGroups.infer(root, oreNames); + 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); + ItemStack stack = new ItemStack(block, 1, metadata); + Inference inferred = inferMaterial(root, oreNames(stack)); + 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); + } + } + addExternalDictionaryCandidates(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 addExternalDictionaryCandidates(Map groups, JsonObject root) { + if (groups.isEmpty()) return; + Map> namesByMaterial = new HashMap<>(); + for (String name : safeOreNames()) { + 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 oreName : names) { + for (ItemStack stack : safeOres(oreName)) { + Block block = stack == null ? null : Block.getBlockFromItem(stack.getItem()); + ResourceLocation id = block == null ? null : ForgeRegistries.BLOCKS.getKey(block); + if (id == null || block == Blocks.AIR || group.hasBlock(id, stack.getMetadata())) continue; + String owner = id.getResourceDomain(); + 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() + "/" + stack.getMetadata(); + group.add(new Candidate(sourceId, owner, modName(owner), modVersion(owner), id, + stack.getMetadata(), group.material, group.domain, STANDARD, + Collections.singletonList(oreName), loaded(owner), false, true, false, false, + false)); + } + } + } + } + + private 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 (!automatic && 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("ore_dictionary")); + return new Candidate(string(json, "source_id", ""), string(json, "owner", block.getResourceDomain()), + 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) { + List priority = PRIORITIES.get(group.material.getResourcePath()); + if (priority == null || group.managed().size() < 2 || group.hasExternal()) return false; + for (Candidate candidate : group.managed()) if (!priority.contains(candidate.owner)) return false; + return true; + } + + private static Candidate preferred(Group group) { + List priority = PRIORITIES.get(group.material.getResourcePath()); + if (priority != null) { + for (String owner : priority) { + for (Candidate candidate : group.configured()) if (owner.equals(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 Map> priorities() { + Map> result = new HashMap<>(); + result.put("sulfur", Collections.unmodifiableList(Arrays.asList( + "mineralogy", "baseminerals", "electricadvantage"))); + result.put("lithium", Collections.unmodifiableList(Arrays.asList( + "baseminerals", "electricadvantage"))); + return Collections.unmodifiableMap(result); + } + + private static Role role(String owner) { + if ("densemetals".equals(owner)) return Role.ENRICHMENT; + if ("basesciences".equals(owner)) return Role.NONE; + if ("nethermetals".equals(owner)) return Role.NETHER; + if ("endmetals".equals(owner)) return Role.END; + if (ORDINARY_MMD.contains(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.getResourceDomain())); + return owner.trim().toLowerCase(Locale.ROOT); + } + + private static boolean loaded(String owner) { + if ("minecraft".equals(owner) || "orespawn".equals(owner)) return true; + try { return Loader.isModLoaded(owner); } + catch (Throwable ignored) { return false; } + } + + private static String modName(String owner) { + ModContainer container = container(owner); + return container == null ? owner : container.getName(); + } + + private static String modVersion(String owner) { + ModContainer container = container(owner); + return container == null ? "" : container.getVersion(); + } + + private static ModContainer container(String owner) { + try { return Loader.instance().getIndexedModList().get(owner); } + catch (Throwable ignored) { return null; } + } + + private static List oreNames(ItemStack stack) { + List result = new ArrayList<>(); + try { + for (int id : OreDictionary.getOreIDs(stack)) result.add(OreDictionary.getOreName(id)); + } catch (RuntimeException ignored) { } + return result; + } + + private static String[] safeOreNames() { + try { return OreDictionary.getOreNames(); } + catch (RuntimeException ignored) { return new String[0]; } + } + + private static List safeOres(String name) { + try { return OreDictionary.getOres(name, false); } + catch (RuntimeException ignored) { return Collections.emptyList(); } + } + + 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 oreDictionaryEntries; + 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 oreDictionaryEntries, 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.oreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(oreDictionaryEntries)); + 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 oreNames; + 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 oreNames, 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.oreNames = Collections.unmodifiableList(new ArrayList<>(oreNames)); + 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, oreNames, 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 dictionary = new JsonArray(); + for (String name : oreNames) dictionary.add(new JsonPrimitive(name)); + json.add("ore_dictionary", dictionary); + 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; + } + } + + private 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 819cbfbb..fe67fa47 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; import java.util.function.Supplier; @@ -18,8 +19,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.OreDimensionSelector; -import zone.moddev.mc.orespawn.api.OrePlacementContext; +import zone.moddev.mc.orespawn.api.OreGenerationContext; +import zone.moddev.mc.orespawn.api.OreSpawnApi; import zone.moddev.mc.orespawn.init.OreSpawnPatterns; import net.minecraft.util.math.BlockPos; @@ -55,8 +58,15 @@ public final class OreSpawnOreGeneration { private static volatile Map oresByDimension = EMPTY_DIMENSIONS; private static volatile Map> vanillaTakeoverOutputs = Collections.emptyMap(); + private static volatile Map> backgroundGenerationScales = + Collections.emptyMap(); + private static volatile Map> materialBackgroundGenerationScales = + Collections.emptyMap(); private static volatile BakedOre[] selectorOres = NO_ORES; private static volatile Set selectorVanillaTakeoverOutputs = Collections.emptySet(); + private static volatile Map selectorBackgroundGenerationScales = Collections.emptyMap(); + private static volatile Map selectorMaterialBackgroundGenerationScales = + Collections.emptyMap(); private static volatile BakedGeomeConfig geomeConfig; private static volatile GeomeGeology classifier; private static volatile long classifierSeed = Long.MIN_VALUE; @@ -75,8 +85,12 @@ public static void refreshWorldConfig() { BakedOres baked = bakeOres(WorldGeologyProfileManager.activeProfile().rootCopy(), geomeConfig); oresByDimension = baked.byDimension; vanillaTakeoverOutputs = baked.vanillaOutputs; + backgroundGenerationScales = baked.backgroundGenerationScales; + materialBackgroundGenerationScales = baked.materialBackgroundGenerationScales; selectorOres = baked.selectorOres; selectorVanillaTakeoverOutputs = baked.selectorVanillaOutputs; + selectorBackgroundGenerationScales = baked.selectorBackgroundGenerationScales; + selectorMaterialBackgroundGenerationScales = baked.selectorMaterialBackgroundGenerationScales; synchronized (CLASSIFIER_LOCK) { classifier = null; classifierSeed = Long.MIN_VALUE; @@ -90,6 +104,28 @@ static boolean takesOverVanillaOre(ResourceLocation dimension, Block output) { return !WorldgenBenchmark.isVanillaBaseline() && outputs != null && outputs.contains(output); } + static boolean allowsVanillaOre(ResourceLocation dimension, Block output, long worldSeed, + int chunkX, int chunkZ) { + if (takesOverVanillaOre(dimension, output)) return false; + double scale = backgroundScale(dimension, output); + if (scale >= 1.0D) return true; + if (scale <= 0.0D) return false; + ResourceLocation outputId = ForgeRegistries.BLOCKS.getKey(output); + return passesBackgroundScale(dimension, outputId, worldSeed, chunkX, chunkZ, scale); + } + + static boolean passesBackgroundScale(ResourceLocation dimension, ResourceLocation outputId, + long worldSeed, int chunkX, int chunkZ, double scale) { + if (scale >= 1.0D) return true; + if (scale <= 0.0D) return false; + long identity = ((long) dimension.toString().hashCode() << 32) + ^ (outputId == null ? 0L : outputId.toString().hashCode() & 0xFFFFFFFFL); + long chunk = ((long) chunkX & 0xFFFFFFFFL) | (((long) chunkZ & 0xFFFFFFFFL) << 32); + long value = mix(worldSeed, chunk, identity); + double sample = (double) (value >>> 11) * 0x1.0p-53; + return sample < scale; + } + static boolean hasManagedOres(ResourceLocation dimension) { return oresForDimension(dimension).length != 0; } @@ -98,7 +134,8 @@ static boolean needsVanillaOreGate(ResourceLocation dimension) { if (WorldGeologyProfileManager.activeProfile().suppressAllOreFeatures()) return true; Set outputs = vanillaTakeoverOutputs.get(dimension); if (outputs == null && selectorAllows(dimension)) outputs = selectorVanillaTakeoverOutputs; - return outputs != null && !outputs.isEmpty(); + Map scales = scalesForDimension(dimension); + return (outputs != null && !outputs.isEmpty()) || !scales.isEmpty(); } boolean generate(World world, Chunk chunk, Random random) { @@ -112,7 +149,8 @@ boolean generate(World world, Chunk chunk, Random random) { setCenter(scratch.cursor, chunk); Biome biome = world.getBiome(scratch.cursor); ResourceLocation biomeId = WorldIds.biome(biome); - boolean changed = generateChunk(world, chunk, biome, biomeId, dimension, + WorldServer level = world instanceof WorldServer ? (WorldServer) world : null; + boolean changed = generateChunk(level, world, chunk, biome, biomeId, dimension, world.getSeed(), random, ores, false, scratch); OreRetrogenManager.markGenerated(dimension, ChunkAccessCompat.position(chunk)); return changed; @@ -128,7 +166,7 @@ static boolean retrogen(WorldServer level, Chunk chunk) { setCenter(scratch.cursor, chunk); Biome biome = level.getBiome(scratch.cursor); ResourceLocation biomeId = WorldIds.biome(biome); - return generateChunk(null, chunk, biome, biomeId, dimension, level.getSeed(), + return generateChunk(level, null, chunk, biome, biomeId, dimension, level.getSeed(), new Random(seed), ores, true, scratch); } @@ -137,13 +175,14 @@ private static void setCenter(BlockPos.MutableBlockPos cursor, Chunk chunk) { cursor.setPos(chunkPos.getXStart() + 8, 0, chunkPos.getZStart() + 8); } - private static boolean generateChunk(World world, Chunk chunk, Biome biome, + private static boolean generateChunk(WorldServer level, World world, Chunk chunk, Biome biome, ResourceLocation biomeId, ResourceLocation dimension, long worldSeed, Random random, BakedOre[] ores, boolean retrogenOnly, GenerationScratch scratch) { ChunkPos chunkPos = ChunkAccessCompat.position(chunk); int centerX = chunkPos.getXStart() + 8; int centerZ = chunkPos.getZStart() + 8; + Optional geologySampler = scratch.geologySampler(level, dimension); int geome = -1; if (WorldIds.OVERWORLD.equals(dimension)) { geome = classifier(worldSeed).classifyColumn(biome, biomeId, centerX, centerZ, @@ -156,13 +195,18 @@ private static boolean generateChunk(World world, Chunk chunk, Biome biome, if (!ore.acceptsBiome(biome, biomeId)) { continue; } - double frequency = ore.frequency; + ResourceLocation scalingMaterial = ore.materialDeclared ? ore.material : null; + double scale = channelBackgroundScale(scalingMaterial, ore.channel, + materialBackgroundScale(dimension, scalingMaterial), + backgroundScale(dimension, ore.output.getBlock())); + double frequency = scaledManagedFrequency(ore.frequency, ore.backgroundController, scale); if (geome >= 0) { frequency *= ore.geomeWeights[geome]; } 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, geologySampler, scratch); } } if (changed) { @@ -180,8 +224,24 @@ private static long mix(long seed, long chunk, long revision) { return value ^ (value >>> 31); } + static long ordinaryOutputIdentity(ChunkPos chunk, int attemptIndex, + ResourceLocation ruleId, ResourceLocation channel) { + return mix(chunkKey(chunk), attemptIndex, + ((long) ruleId.hashCode() << 32) ^ (channel.hashCode() & 0xFFFFFFFFL)); + } + + static double outputSelectionSample(long worldSeed, ResourceLocation dimension, + ResourceLocation material, ResourceLocation channel, long outputIdentity) { + long value = mix(worldSeed, outputIdentity, + ((long) material.hashCode() << 32) + ^ (channel.hashCode() & 0xFFFFFFFFL) ^ dimension.hashCode()); + return (double) (value >>> 11) * 0x1.0p-53; + } + private static boolean placeAttempt(World world, Chunk chunk, Random random, - BakedOre ore, int geome, + BakedOre ore, int geome, ResourceLocation dimension, long worldSeed, + int attemptIndex, + Optional geologySampler, GenerationScratch scratch) { int minY = Math.max(ore.minY, 0); int maxY = Math.min(ore.maxY, 256 - 1); @@ -192,8 +252,8 @@ private static boolean placeAttempt(World world, Chunk chunk, Random random, int y = ore.heightDistribution.sample(random, minY, maxY); int z = ChunkAccessCompat.position(chunk).getZStart() + random.nextInt(16); int quantity = sampleQuantity(random, ore.minQuantity, ore.maxQuantity); - scratch.patternContext.initialize(world, chunk, random, ore, geome, x, y, z, minY, maxY, - quantity); + scratch.patternContext.initialize(world, chunk, random, ore, geome, dimension, worldSeed, + attemptIndex, geologySampler, x, y, z, minY, maxY, quantity); return ore.pattern.place(scratch.patternContext); } @@ -217,6 +277,25 @@ private static BakedOre[] oresForDimension(ResourceLocation dimension) { return exact != null ? exact : selectorAllows(dimension) ? selectorOres : NO_ORES; } + private static Map scalesForDimension(ResourceLocation dimension) { + Map exact = backgroundGenerationScales.get(dimension); + return exact != null ? exact + : selectorAllows(dimension) ? selectorBackgroundGenerationScales : Collections.emptyMap(); + } + + private static double backgroundScale(ResourceLocation dimension, Block output) { + Double scale = scalesForDimension(dimension).get(output); + return scale == null ? 1.0D : scale.doubleValue(); + } + + private static double materialBackgroundScale(ResourceLocation dimension, ResourceLocation material) { + if (material == null) return 1.0D; + Map exact = materialBackgroundGenerationScales.get(dimension); + if (exact == null && selectorAllows(dimension)) exact = selectorMaterialBackgroundGenerationScales; + Double scale = exact == null ? null : exact.get(material); + return scale == null ? 1.0D : scale.doubleValue(); + } + private static boolean insideChunk(Chunk chunk, int x, int y, int z) { return insideChunk(ChunkAccessCompat.position(chunk), x, y, z); } @@ -234,21 +313,17 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { boolean manageVanillaOres = bool(profile, "manage_vanilla_ores", false); Map> 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"); @@ -266,7 +341,16 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { } int deepOutputMaxY = integer(oreJson, "deep_output_max_y", -1); BakedOutput[] outputs = bakeOutputs(oreJson, outputState); - BakedOreRule rule = new BakedOreRule(output, manageVanillaOres && suppressVanilla); + ResourceLocation ruleId = resource(oreEntry.getKey()); + if (ruleId == null) ruleId = oreId; + outputBundles.put(ruleId.toString(), new BakedOutputBundle( + outputState, deepOutput, deepOutputMaxY, outputs)); + if (!oreEnabled || (nativeGeneration && !manageVanillaOres)) continue; + ResourceLocation material = OreSourcePolicies.material(profile, oreJson, + new ItemStack(output, 1, integer(oreJson, "metadata", 0))); + boolean materialDeclared = resource(string(oreJson, "material", "")) != null; + BakedOreRule rule = new BakedOreRule(ruleId, output, material, materialDeclared, + manageVanillaOres && suppressVanilla); for (Entry dimensionEntry : dimensions.entrySet()) { ResourceLocation dimensionId = resource(dimensionEntry.getKey()); @@ -284,8 +368,9 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (!bool(dimension, "enabled", true)) { continue; } - BakedOre baked = bakeOre(outputState, deepOutput, deepOutputMaxY, outputs, - dimension, config, resolvedTags, retrogen); + BakedOre baked = bakeOre(ruleId, material, materialDeclared, dimensionId, + outputState, deepOutput, + deepOutputMaxY, outputs, dimension, config, resolvedTags, retrogen); if (baked != null) { rule.explicit.put(dimensionId, baked); } else { @@ -304,8 +389,9 @@ 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(outputState, deepOutput, deepOutputMaxY, outputs, - selector, config, resolvedTags, retrogen); + rule.selector = bakeOre(ruleId, material, materialDeclared, selectorId, + outputState, deepOutput, + deepOutputMaxY, outputs, selector, config, resolvedTags, retrogen); if (rule.selector == null) { reportBakeProblem("Ignoring invalid selector rule for OreSpawn-managed ore '{}'", oreEntry.getKey()); @@ -316,27 +402,48 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { Map result = new HashMap<>(); Map> vanillaOutputs = new HashMap<>(); + Map> generationScales = new HashMap<>(); + Map> materialScales = new HashMap<>(); for (ResourceLocation dimension : explicitDimensions) { List combined = new ArrayList<>(); Set suppressed = Collections.newSetFromMap(new IdentityHashMap()); + Map scales = new IdentityHashMap<>(); + Map semanticScales = new HashMap<>(); for (BakedOreRule rule : rules) { BakedOre selected = selectRule(rule.explicit, rule.explicitDimensions, rule.selector, dimension); if (selected != null) { combined.add(selected); if (rule.suppressVanilla) suppressed.add(rule.output); + if (selected.backgroundController) { + mergeBackgroundScale(scales, rule.output, selected.backgroundGenerationScale); + if (rule.materialDeclared) mergeBackgroundScale(semanticScales, + rule.material, selected.backgroundGenerationScale); + } } } + combined = arbitrate(profile, combined, outputBundles); result.put(dimension, combined.toArray(new BakedOre[combined.size()])); vanillaOutputs.put(dimension, Collections.unmodifiableSet(suppressed)); + generationScales.put(dimension, Collections.unmodifiableMap(scales)); + materialScales.put(dimension, Collections.unmodifiableMap(semanticScales)); } List selectorList = new ArrayList<>(); Set selectorOutputs = Collections.newSetFromMap(new IdentityHashMap()); + Map selectorScales = new IdentityHashMap<>(); + Map selectorMaterialScales = new HashMap<>(); for (BakedOreRule rule : rules) { if (rule.selector == null) continue; selectorList.add(rule.selector); if (rule.suppressVanilla) selectorOutputs.add(rule.output); + if (rule.selector.backgroundController) { + mergeBackgroundScale(selectorScales, rule.output, + rule.selector.backgroundGenerationScale); + if (rule.materialDeclared) mergeBackgroundScale(selectorMaterialScales, + rule.material, rule.selector.backgroundGenerationScale); + } } + 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()); @@ -345,8 +452,150 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { immutableVanillaOutputs.put(entry.getKey(), Collections.unmodifiableSet(entry.getValue())); } return new BakedOres(Collections.unmodifiableMap(result), - Collections.unmodifiableMap(immutableVanillaOutputs), selectorResult, - Collections.unmodifiableSet(selectorOutputs)); + Collections.unmodifiableMap(immutableVanillaOutputs), + Collections.unmodifiableMap(generationScales), + Collections.unmodifiableMap(materialScales), selectorResult, + Collections.unmodifiableSet(selectorOutputs), + Collections.unmodifiableMap(selectorScales), + Collections.unmodifiableMap(selectorMaterialScales)); + } + + 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()) { + BakedOre first = entry.getValue().get(0); + 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; + Map weights = positiveWeights(objectOrEmpty(policy, "outputs")); + List sources = new ArrayList<>(); + Set sourceIds = new HashSet<>(); + for (Entry selectedOutput : weights.entrySet()) { + BakedOutputBundle bundle = outputBundles.get(selectedOutput.getKey()); + if (bundle == null) bundle = candidateBundle(policy, selectedOutput.getKey()); + if (bundle != null && sourceIds.add(selectedOutput.getKey())) { + sources.add(new BakedSource(selectedOutput.getKey(), bundle, + selectedOutput.getValue().doubleValue())); + } + } + if (sources.isEmpty()) { + replacements.put(key, Collections.emptyList()); + continue; + } + Map available = new LinkedHashMap<>(); + for (BakedOre candidate : entry.getValue()) available.put(candidate.ruleId, candidate.channel); + Map placements = selectedPlacementRules( + profile, first.material, first.policyDomain, available); + List selected = new ArrayList<>(); + for (Entry placement : placements.entrySet()) { + ResourceLocation channel = placement.getKey(); + ResourceLocation source = placement.getValue(); + for (BakedOre candidate : entry.getValue()) { + if (channel.equals(candidate.channel) && source.equals(candidate.ruleId)) { + selected.add(candidate.withSources(sources)); + break; + } + } + } + replacements.put(key, selected); + } + if (replacements.isEmpty()) return original; + List result = new ArrayList<>(); + Set emitted = new HashSet<>(); + for (BakedOre ore : original) { + String groupKey = ore.material == null ? null + : OreSourcePolicies.key(ore.material, ore.policyDomain); + List replacement = groupKey == null ? null : replacements.get(groupKey); + if (replacement == null) result.add(ore); + else if (emitted.add(groupKey)) result.addAll(replacement); + } + return result; + } + + /** Returns null for the behaviour-preserving separate mode. */ + static Map selectedPlacementRules(JsonObject profile, + ResourceLocation material, ResourceLocation domain, + Map availableSourceChannels) { + if (!profile.has(OreSourcePolicies.SECTION) + || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return null; + JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION); + String key = OreSourcePolicies.key(material, domain); + if (!policies.has(key) || !policies.get(key).isJsonObject()) return null; + JsonObject policy = policies.getAsJsonObject(key); + if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) return null; + Map result = new LinkedHashMap<>(); + for (Entry placement : objectOrEmpty( + policy, "placement_sources").entrySet()) { + ResourceLocation channel = resource(placement.getKey()); + ResourceLocation source; + try { source = resource(placement.getValue().getAsString()); } + catch (RuntimeException invalid) { continue; } + if (channel != null && source != null && channel.equals(availableSourceChannels.get(source))) { + result.put(channel, source); + } + } + return Collections.unmodifiableMap(result); + } + + static int weightedIndex(double sample, double... weights) { + double total = 0.0D; + for (double weight : weights) if (Double.isFinite(weight) && weight > 0.0D) total += weight; + if (total <= 0.0D) return -1; + double choice = weightedChoice(sample, total); + int last = -1; + for (int index = 0; index < weights.length; index++) { + double weight = weights[index]; + if (!Double.isFinite(weight) || weight <= 0.0D) continue; + last = index; + choice -= weight; + if (choice < 0.0D) return index; + } + return last; + } + + private static double weightedChoice(double sample, double total) { + return Math.max(0.0D, Math.min(Math.nextDown(1.0D), sample)) * total; + } + + 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; + IBlockState state = state(block, integer(candidate, "metadata", 0)); + 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) { @@ -361,11 +610,28 @@ private static void reportBakeProblem(String message, Object... arguments) { } } + static double scaledManagedFrequency(double frequency, boolean controller, double scale) { + return controller ? frequency : frequency * scale; + } + + static double channelBackgroundScale(ResourceLocation material, ResourceLocation channel, + double materialScale, double legacyBlockScale) { + if (material == null) return legacyBlockScale; + return OreSourcePolicies.STANDARD.equals(channel) ? materialScale : 1.0D; + } + + static void mergeBackgroundScale(Map scales, T output, double scale) { + Double previous = scales.get(output); + if (previous == null || scale < previous.doubleValue()) scales.put(output, scale); + } + 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(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, + private static BakedOre bakeOre(ResourceLocation ruleId, ResourceLocation material, + boolean materialDeclared, ResourceLocation policyDomain, + IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, JsonObject json, BakedGeomeConfig config, Map> resolvedTags, boolean retrogen) { @@ -414,6 +680,10 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int int nodeSize = boundedInteger(json, "node_size", 4, 1, 32); double discardChanceOnAirExposure = Math.max(0.0D, Math.min(1.0D, decimal(json, "discard_chance_on_air_exposure", 0.0D))); + boolean backgroundController = json.has("background_generation_scale"); + double backgroundGenerationScale = backgroundController + ? Math.max(0.0D, Math.min(1.0D, + decimal(json, "background_generation_scale", 1.0D))) : 1.0D; double[] geomeWeights = new double[config.geomeCount()]; java.util.Arrays.fill(geomeWeights, 1.0D); @@ -430,12 +700,15 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int Set includedDictionaryBiomes = resolveBiomeDictionary(json, "biome_dictionary"); Set excludedDictionaryBiomes = resolveBiomeDictionary(json, "excluded_biome_dictionary"); - return new BakedOre(output, deepOutput, deepOutputMaxY, outputs, + return new BakedOre(ruleId, material, materialDeclared, policyDomain, + OreSourcePolicies.placementChannel(json), + output, deepOutput, deepOutputMaxY, outputs, minY, maxY, Math.min(64.0D, frequency), minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, hostStates, familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds, - includedDictionaryBiomes, excludedDictionaryBiomes, retrogen); + includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, + backgroundController, backgroundGenerationScale); } static boolean hasHostTargets(Map blocks, @@ -625,6 +898,11 @@ private static IBlockState state(Block block, int metadata) { } private static final class BakedOre { + final ResourceLocation ruleId; + final ResourceLocation material; + final boolean materialDeclared; + final ResourceLocation policyDomain; + final ResourceLocation channel; final IBlockState output; final IBlockState deepOutput; final int deepOutputMaxY; @@ -649,8 +927,36 @@ private static final class BakedOre { final Set includedDictionaryBiomes; final Set excludedDictionaryBiomes; final boolean retrogen; + final boolean backgroundController; + final double backgroundGenerationScale; + final BakedOutputBundle outputBundle; + final BakedSource[] arbitrationSources; + + BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean materialDeclared, + ResourceLocation policyDomain, ResourceLocation channel, + IBlockState output, IBlockState 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, Map hostStates, + int familyMask, double[] geomeWeights, + Set includedBiomeIds, Set excludedBiomeIds, + Set includedDictionaryBiomes, Set excludedDictionaryBiomes, + boolean retrogen, boolean backgroundController, + double backgroundGenerationScale) { + this(ruleId, material, materialDeclared, policyDomain, channel, + output, deepOutput, deepOutputMaxY, outputs, + minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution, + discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, hostStates, + familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds, + includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, backgroundController, + backgroundGenerationScale, new BakedSource[0]); + } - BakedOre(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, + private BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean materialDeclared, + ResourceLocation policyDomain, ResourceLocation channel, + IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, int minY, int maxY, double frequency, int minQuantity, int maxQuantity, CompiledOrePattern pattern, OreHeightDistribution heightDistribution, double discardChanceOnAirExposure, @@ -659,7 +965,13 @@ private static final class BakedOre { int familyMask, double[] geomeWeights, Set includedBiomeIds, Set excludedBiomeIds, Set includedDictionaryBiomes, Set excludedDictionaryBiomes, - boolean retrogen) { + boolean retrogen, boolean backgroundController, + double backgroundGenerationScale, BakedSource[] arbitrationSources) { + this.ruleId = ruleId; + this.material = material; + this.materialDeclared = materialDeclared; + this.policyDomain = policyDomain; + this.channel = channel; this.output = output; this.deepOutput = deepOutput; this.deepOutputMaxY = deepOutputMaxY; @@ -684,20 +996,33 @@ private static final class BakedOre { this.includedDictionaryBiomes = includedDictionaryBiomes; this.excludedDictionaryBiomes = excludedDictionaryBiomes; this.retrogen = retrogen; + this.backgroundController = backgroundController; + this.backgroundGenerationScale = backgroundGenerationScale; + this.outputBundle = new BakedOutputBundle(output, deepOutput, deepOutputMaxY, outputs); + this.arbitrationSources = arbitrationSources; } - IBlockState outputAt(int y, Random random) { - if (y <= deepOutputMaxY) return deepOutput; + BakedOre withSources(List sources) { + return new BakedOre(ruleId, material, materialDeclared, policyDomain, channel, + output, deepOutput, deepOutputMaxY, outputs, + minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution, + discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, hostStates, + familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds, + includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, backgroundController, + backgroundGenerationScale, sources.toArray(new BakedSource[sources.size()])); + } + + BakedOutputBundle outputSource(long worldSeed, ResourceLocation dimension, long identity) { + if (arbitrationSources.length == 0) return outputBundle; 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; + for (BakedSource source : arbitrationSources) total += source.weight; + double choice = weightedChoice(outputSelectionSample( + worldSeed, dimension, material, channel, identity), total); + for (BakedSource source : arbitrationSources) { + choice -= source.weight; + if (choice < 0.0D) return source.bundle; } - return output; + return arbitrationSources[arbitrationSources.length - 1].bundle; } boolean accepts(IBlockState state, Random random, BakedGeomeConfig config) { @@ -721,18 +1046,67 @@ boolean acceptsBiome(Biome biome, ResourceLocation biomeId) { } private static final class BakedOreRule { + final ResourceLocation ruleId; final Block output; + final ResourceLocation material; + final boolean materialDeclared; final boolean suppressVanilla; final Map explicit = new HashMap<>(); final Set explicitDimensions = new HashSet<>(); BakedOre selector; - BakedOreRule(Block output, boolean suppressVanilla) { + BakedOreRule(ResourceLocation ruleId, Block output, ResourceLocation material, + boolean materialDeclared, + boolean suppressVanilla) { + this.ruleId = ruleId; this.output = output; + this.material = material; + this.materialDeclared = materialDeclared; this.suppressVanilla = suppressVanilla; } } + private static final class BakedSource { + final String sourceId; + final BakedOutputBundle bundle; + final double weight; + + BakedSource(String sourceId, BakedOutputBundle bundle, double weight) { + this.sourceId = sourceId; + this.bundle = bundle; + this.weight = weight; + } + } + + private static final class BakedOutputBundle { + final IBlockState output; + final IBlockState deepOutput; + final int deepOutputMaxY; + final BakedOutput[] outputs; + + BakedOutputBundle(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, + BakedOutput[] outputs) { + this.output = output; + this.deepOutput = deepOutput; + this.deepOutputMaxY = deepOutputMaxY; + this.outputs = outputs; + } + + IBlockState 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 IBlockState state; final double weight; @@ -753,20 +1127,33 @@ boolean acceptsY(int y) { private static final class BakedOres { static final BakedOres EMPTY = new BakedOres(EMPTY_DIMENSIONS, Collections.emptyMap(), - NO_ORES, Collections.emptySet()); + Collections.emptyMap(), Collections.emptyMap(), NO_ORES, Collections.emptySet(), + Collections.emptyMap(), Collections.emptyMap()); final Map byDimension; final Map> vanillaOutputs; + final Map> backgroundGenerationScales; + final Map> materialBackgroundGenerationScales; final BakedOre[] selectorOres; final Set selectorVanillaOutputs; + final Map selectorBackgroundGenerationScales; + final Map selectorMaterialBackgroundGenerationScales; BakedOres(Map byDimension, - Map> vanillaOutputs, BakedOre[] selectorOres, - Set selectorVanillaOutputs) { + Map> vanillaOutputs, + Map> backgroundGenerationScales, + Map> materialBackgroundGenerationScales, + BakedOre[] selectorOres, Set selectorVanillaOutputs, + Map selectorBackgroundGenerationScales, + Map selectorMaterialBackgroundGenerationScales) { this.byDimension = byDimension; this.vanillaOutputs = vanillaOutputs; + this.backgroundGenerationScales = backgroundGenerationScales; + this.materialBackgroundGenerationScales = materialBackgroundGenerationScales; this.selectorOres = selectorOres; this.selectorVanillaOutputs = selectorVanillaOutputs; + this.selectorBackgroundGenerationScales = selectorBackgroundGenerationScales; + this.selectorMaterialBackgroundGenerationScales = selectorMaterialBackgroundGenerationScales; } } @@ -774,6 +1161,10 @@ private static final class GenerationScratch { final BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); final PatternContext patternContext = new PatternContext(cursor); private double[] geomeValues = new double[0]; + private WorldServer samplerLevel; + private ResourceLocation samplerDimension; + private BakedGeomeConfig samplerConfig; + private Optional geologySampler = Optional.empty(); double[] geomeValues(int count) { if (geomeValues.length != count) { @@ -781,9 +1172,22 @@ private static final class GenerationScratch { } return geomeValues; } + + Optional geologySampler(WorldServer level, ResourceLocation dimension) { + BakedGeomeConfig activeConfig = WorldIds.OVERWORLD.equals(dimension) + ? GeomeConfig.baked() : GeomeConfig.baked(dimension); + if (level != samplerLevel || !dimension.equals(samplerDimension) + || activeConfig != samplerConfig) { + samplerLevel = level; + samplerDimension = dimension; + samplerConfig = activeConfig; + geologySampler = level == null ? Optional.empty() : OreSpawnApi.createSampler(level); + } + return geologySampler; + } } - 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 World world; @@ -791,30 +1195,52 @@ private static final class PatternContext implements OrePlacementContext { private Random random; private BakedOre ore; private int geome; + private ResourceLocation dimension; + private long worldSeed; + private int chunkX; + private int chunkZ; + private Optional geologySampler = Optional.empty(); private int originX; private int originY; private int originZ; private int minY; private int maxY; private int quantity; + private BakedOutputBundle ordinaryOutput; + private BakedOutputBundle identifiedOutput; + private long identifiedOutputIdentity; + private boolean hasIdentifiedOutput; PatternContext(BlockPos.MutableBlockPos cursor) { this.cursor = cursor; } void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome, + ResourceLocation dimension, long worldSeed, + int attemptIndex, + Optional geologySampler, 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.geome = geome; + this.dimension = dimension; + this.worldSeed = worldSeed; + ChunkPos chunkPos = ChunkAccessCompat.position(chunk); + this.chunkX = chunkPos.chunkXPos; + this.chunkZ = chunkPos.chunkZPos; + this.geologySampler = geologySampler; this.originX = originX; this.originY = originY; this.originZ = originZ; this.minY = minY; this.maxY = maxY; this.quantity = quantity; + long attemptIdentity = ordinaryOutputIdentity(chunkPos, attemptIndex, ore.ruleId, ore.channel); + this.ordinaryOutput = ore.outputSource(worldSeed, dimension, attemptIdentity); + this.identifiedOutput = null; + this.hasIdentifiedOutput = false; } @Override public Random random() { return random; } @@ -827,6 +1253,11 @@ void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome @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() { return geologySampler; } @Override public boolean inside(int x, int y, int z) { @@ -851,6 +1282,20 @@ public boolean isFluid(int x, int y, int z, Fluid fluid) { @Override public boolean tryPlace(int x, int y, int z) { + return tryPlaceWithOutput(x, y, z, ordinaryOutput); + } + + @Override + public boolean tryPlace(int x, int y, int z, long outputIdentity) { + if (!hasIdentifiedOutput || identifiedOutputIdentity != outputIdentity) { + identifiedOutputIdentity = outputIdentity; + identifiedOutput = ore.outputSource(worldSeed, dimension, outputIdentity); + 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.setPos(x, y, z); IBlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor); @@ -860,7 +1305,7 @@ public boolean tryPlace(int x, int y, int z) { && isAdjacentToAir(x, y, z)) { return false; } - IBlockState output = ore.outputAt(y, random); + IBlockState output = outputSource.outputAt(y, random); if (world == null) chunk.setBlockState(cursor, output); else world.setBlockState(cursor.toImmutable(), output, GENERATION_WRITE_FLAGS); return true; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java index bd234d47..bb99cba1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java @@ -14,6 +14,7 @@ import net.minecraft.world.chunk.IChunkProvider; import net.minecraft.world.chunk.IChunkGenerator; import net.minecraftforge.event.terraingen.DecorateBiomeEvent; +import net.minecraftforge.event.terraingen.ChunkGeneratorEvent; import net.minecraftforge.event.terraingen.OreGenEvent; import net.minecraftforge.fml.common.IWorldGenerator; import net.minecraftforge.fml.common.eventhandler.Event; @@ -36,6 +37,11 @@ public final class OreSpawnWorldGenerator implements IWorldGenerator { private OreSpawnWorldGenerator() { } + @SubscribeEvent(priority = EventPriority.LOWEST) + public void captureNativeAquifer(ChunkGeneratorEvent.ReplaceBiomeBlocks event) { + AquiferMaterialSubstitution.capture(event); + } + @SubscribeEvent public void beforeBiomeDecoration(DecorateBiomeEvent.Pre event) { if (WorldgenBenchmark.isVanillaBaseline()) return; @@ -65,8 +71,12 @@ public void filterVanillaOre(OreGenEvent.GenerateMinable event) { return; } Block output = vanillaOutput(event.getType()); - if (output != null && OreSpawnOreGeneration.takesOverVanillaOre(dimension, output)) { - event.setResult(Event.Result.DENY); + if (output != null) { + ChunkPos chunk = new ChunkPos(event.getPos()); + if (!OreSpawnOreGeneration.allowsVanillaOre(dimension, output, + event.getWorld().getSeed(), chunk.chunkXPos, chunk.chunkZPos)) { + event.setResult(Event.Result.DENY); + } } } @@ -87,6 +97,7 @@ private void generateEarly(World world, int chunkX, int chunkZ, Random random) { if (!earlyComplete.add(key)) return; Chunk chunk = world.getChunkProvider().provideChunk(chunkX, chunkZ); // Explicit order is the 1.12 equivalent of LOCAL_MODIFICATIONS. + AquiferMaterialSubstitution.apply(world, chunk); StoneReplacer.FEATURE.generate(world, chunk, random); BiomeSurfaceFeature.FEATURE.generate(world, chunk, random); FluidDepositFeature.FEATURE.generate(world, chunk, random); @@ -103,6 +114,7 @@ private void generateOres(World world, int chunkX, int chunkZ, Random random) { } public void clear() { + AquiferMaterialSubstitution.clear(); earlyComplete.clear(); oreComplete.clear(); legacyComplete.clear(); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java index a0fee4c1..45dd3c79 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java @@ -12,6 +12,8 @@ import net.minecraft.init.Blocks; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; +import net.minecraft.world.WorldServer; +import net.minecraft.world.gen.ChunkProviderOverworld; import net.minecraftforge.event.terraingen.DecorateBiomeEvent; import net.minecraftforge.fml.common.eventhandler.Event; @@ -47,6 +49,10 @@ static boolean accepts(World world, BlockPos pos, IBlockState state) { static void replaceVanillaSpringPass(DecorateBiomeEvent.Decorate event) { if (providerRocks.isEmpty() || event.getResult() == Event.Result.DENY) return; + World world = event.getWorld(); + if (!(world instanceof WorldServer) + || !(((WorldServer) world).getChunkProvider().chunkGenerator + instanceof ChunkProviderOverworld)) return; Block fluid; int attempts; if (event.getType() == DecorateBiomeEvent.Decorate.EventType.LAKE_WATER) { @@ -56,7 +62,7 @@ static void replaceVanillaSpringPass(DecorateBiomeEvent.Decorate event) { } else { return; } - World world = event.getWorld(); java.util.Random random = event.getRand(); + java.util.Random random = event.getRand(); BlockPos origin = event.getPos(); for (int attempt = 0; attempt < attempts; attempt++) { int x = random.nextInt(16) + 8; 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 35be9a40..c07a7f2e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -22,7 +22,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(); @@ -39,6 +39,11 @@ public final class WorldGeologyProfile { private WorldGeologyProfile(JsonObject root, GeologyMode fallbackMode, boolean fallbackFluidDeposits) { this.root = JsonCopies.copy(root); 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); @@ -67,6 +72,7 @@ public static WorldGeologyProfile recommended(boolean placeFluidDeposits) { public static WorldGeologyProfile fromGlobalConfig(JsonObject globalRoot, GeologyMode geologyMode, boolean placeFluidDeposits) { JsonObject root = JsonCopies.copy(globalRoot); + OreSourcePolicies.initialize(root, false); if (!root.has("geology_mode")) { root.addProperty("geology_mode", geologyMode.name().toLowerCase(Locale.ROOT)); } @@ -81,14 +87,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) { + 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, JsonCopies.copy(fallback.root.get(key))); } } + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } @@ -98,6 +105,10 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile copyIfPresent(json, migrated, "geology_mode"); copyIfPresent(json, migrated, "place_crude_oil"); copyIfPresent(json, migrated, "formations"); + // A schema-one file belongs to an existing world. Do not inherit a fresh + // install's automatic consolidation choices from the fallback snapshot. + migrated.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } @@ -260,7 +271,9 @@ public int generationRevision() { return configured; } int hash = 17; - for (String key : new String[] { "ores", "fluid_deposits", "flat_bedrock", "providers" }) { + for (String key : new String[] { "ores", OreMaterialGroups.SECTION, + "ore_source_policies", "fluid_deposits", + "flat_bedrock", "providers" }) { if (root.has(key)) { hash = (31 * hash) + root.get(key).toString().hashCode(); } 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 597e9384..94cd5b18 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -18,7 +18,6 @@ import com.google.gson.JsonSyntaxException; import zone.moddev.mc.orespawn.OreSpawnConfig; import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; -import zone.moddev.mc.orespawn.api.OreSpawnOreIntegration; import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; import net.minecraft.server.MinecraftServer; @@ -105,7 +104,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 = WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged); + changed |= OreSourcePolicies.initialize(merged, true); + if (changed) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); } @@ -127,7 +128,8 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { profile = readProfile(profilePath, fallback); JsonObject merged = profile.rootCopy(); String beforeMerge = merged.toString(); - OreSpawnOreIntegration.mergeProviderOres(merged); + WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged); + OreSourcePolicies.initialize(merged, true); if (!beforeMerge.equals(merged.toString())) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); @@ -158,6 +160,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { profile = fallback.copy(); source = "installed-pack fresh-world"; } + profile = initializeNewWorldPolicies(profile, generatedWorld); writeProfile(profilePath, profile); LOGGER.info("Created OreSpawn world geology profile '{}' from {} settings", profilePath, source); @@ -172,6 +175,17 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { profile.enabledFluidDepositCount(), profile.fluidDepositCount()); } + static WorldGeologyProfile initializeNewWorldPolicies(WorldGeologyProfile profile, + boolean generatedWorld) { + JsonObject initialized = profile.rootCopy(); + // A generated world without an OS4 profile is an upgrade. Its installed-pack + // fallback may already contain fresh-world consolidation defaults, which must + // not alter the world's historical ore frequency implicitly. + if (generatedWorld) initialized.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(initialized, generatedWorld); + return profile.withRoot(initialized); + } + public static void onServerStopped(FMLServerStoppedEvent event) { activeServer = null; activeProfile = null; @@ -265,8 +279,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); @@ -279,6 +295,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/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java index 8c93bea0..42cab62f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java @@ -1,5 +1,6 @@ package zone.moddev.mc.orespawn.worldgen; +import java.security.MessageDigest; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Locale; @@ -109,6 +110,7 @@ public static void onServerStarted(FMLServerStartingEvent event) { + "median_ms={} median_ms_per_chunk={} min_ms={} max_ms={}", MODE, chunks, repetitions, format(median), format(median / chunks), format(sorted[0]), format(sorted[sorted.length - 1])); + auditNormalizedHashes(level, baseCenterX, baseCenterZ, centerStep, radius, repetitions); if (Boolean.getBoolean("orespawn.worldgenBenchmarkStopServer")) { LOGGER.info("ORESPAWN_BENCHMARK stopping server after completed benchmark"); event.getServer().initiateShutdown(); @@ -151,24 +153,35 @@ private static int squareDiameter(int radius) { } private static int[] locateBiomeType(WorldServer level, int centerX, int centerZ) { + String exact = System.getProperty("orespawn.worldgenBenchmarkBiome", "").trim(); + if (!exact.isEmpty()) { + ResourceLocation id = resourceLocation(exact); + Biome biome = id == null ? null + : net.minecraftforge.fml.common.registry.ForgeRegistries.BIOMES.getValue(id); + if (biome == null) throw new IllegalArgumentException("Unknown benchmark biome " + exact); + BlockPos origin = new BlockPos(centerX << 4, level.getSeaLevel(), centerZ << 4); + BlockPos located = level.provider.getBiomeProvider().findBiomePosition( + origin.getX(), origin.getZ(), 16384, + java.util.Collections.singletonList(biome), level.rand); + if (located == null) throw new IllegalStateException("Benchmark could not locate biome " + exact); + int locatedX = located.getX() >> 4; + int locatedZ = located.getZ() >> 4; + LOGGER.info("ORESPAWN_BENCHMARK located biome={} center_chunk_x={} center_chunk_z={}", + exact, locatedX, locatedZ); + return new int[] { locatedX, locatedZ }; + } String configured = System.getProperty("orespawn.worldgenBenchmarkBiomeType", "").trim(); if (configured.isEmpty()) { return new int[] { centerX, centerZ }; } BiomeDictionary.Type type = BiomeDictionary.Type.getType(configured); BlockPos origin = new BlockPos(centerX << 4, level.getSeaLevel(), centerZ << 4); - BlockPos located = null; - double bestDistance = Double.POSITIVE_INFINITY; + java.util.List matchingBiomes = new java.util.ArrayList<>(); for (Biome biome : net.minecraftforge.fml.common.registry.ForgeRegistries.BIOMES.getValues()) { - if (!BiomeDictionary.isBiomeOfType(biome, type)) continue; - BlockPos candidate = level.provider.getBiomeProvider() - .findBiomePosition(origin.getX(), origin.getZ(), 16384, - java.util.Collections.singletonList(biome), level.rand); - if (candidate != null && candidate.distanceSq(origin) < bestDistance) { - located = candidate; - bestDistance = candidate.distanceSq(origin); - } + if (BiomeDictionary.isBiomeOfType(biome, type)) matchingBiomes.add(biome); } + BlockPos located = level.provider.getBiomeProvider().findBiomePosition( + origin.getX(), origin.getZ(), 16384, matchingBiomes, level.rand); if (located == null) { throw new IllegalStateException("Benchmark could not locate biome dictionary type " + configured); } @@ -179,6 +192,98 @@ private static int[] locateBiomeType(WorldServer level, int centerX, int centerZ return new int[] { locatedX, locatedZ }; } + private static void auditNormalizedHashes(WorldServer level, int baseCenterX, int baseCenterZ, + int centerStep, int radius, int repetitions) { + Block[] blocks = blockPair("orespawn.worldgenBenchmarkNormalizeBlock"); + Biome[] biomes = biomePair("orespawn.worldgenBenchmarkNormalizeBiome"); + if (blocks == null && biomes == null) return; + try { + for (int repetition = 0; repetition < repetitions; repetition++) { + MessageDigest blockHash = MessageDigest.getInstance("SHA-256"); + MessageDigest biomeHash = MessageDigest.getInstance("SHA-256"); + long normalizedBlocks = 0L; + long normalizedBiomes = 0L; + int centerX = baseCenterX + (repetition * centerStep); + for (int chunkZ = baseCenterZ - radius; chunkZ <= baseCenterZ + radius; chunkZ++) { + for (int chunkX = centerX - radius; chunkX <= centerX + radius; chunkX++) { + Chunk chunk = level.getChunkProvider().provideChunk(chunkX, chunkZ); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + Biome biome = Biome.getBiome(chunk.getBiomeArray()[(z << 4) | x] & 255); + if (biomes != null && biome == biomes[0]) { + biome = biomes[1]; + normalizedBiomes++; + } + update(biomeHash, Biome.getIdForBiome(biome)); + for (int y = 0; y < 256; y++) { + IBlockState state = chunk.getBlockState(x, y, z); + if (blocks != null && state.getBlock() == blocks[0]) { + state = blocks[1].getDefaultState(); + normalizedBlocks++; + } + update(blockHash, Block.getIdFromBlock(state.getBlock())); + update(blockHash, state.getBlock().getMetaFromState(state)); + } + } + } + } + } + LOGGER.info("ORESPAWN_BENCHMARK_HASH repetition={} normalized_blocks={} block_sha256={} " + + "normalized_biomes={} biome_sha256={}", repetition + 1, + normalizedBlocks, hex(blockHash.digest()), normalizedBiomes, hex(biomeHash.digest())); + } + } catch (Exception failure) { + throw new IllegalStateException("Could not calculate normalized benchmark hashes", failure); + } + } + + private static Block[] blockPair(String property) { + String configured = System.getProperty(property, "").trim(); + if (configured.isEmpty()) return null; + String[] values = configured.split(","); + if (values.length != 2) throw new IllegalArgumentException( + "Benchmark block normalization requires source,target: " + configured); + Block[] result = new Block[2]; + for (int i = 0; i < 2; i++) { + ResourceLocation id = resourceLocation(values[i].trim()); + result[i] = id == null ? null + : net.minecraftforge.fml.common.registry.ForgeRegistries.BLOCKS.getValue(id); + if (result[i] == null) throw new IllegalArgumentException( + "Unknown benchmark normalization block " + values[i].trim()); + } + return result; + } + + private static Biome[] biomePair(String property) { + String configured = System.getProperty(property, "").trim(); + if (configured.isEmpty()) return null; + String[] values = configured.split(","); + if (values.length != 2) throw new IllegalArgumentException( + "Benchmark biome normalization requires source,target: " + configured); + Biome[] result = new Biome[2]; + for (int i = 0; i < 2; i++) { + ResourceLocation id = resourceLocation(values[i].trim()); + result[i] = id == null ? null + : net.minecraftforge.fml.common.registry.ForgeRegistries.BIOMES.getValue(id); + if (result[i] == null) throw new IllegalArgumentException( + "Unknown benchmark normalization biome " + values[i].trim()); + } + return result; + } + + private static void update(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(String.format(Locale.ROOT, "%02x", value & 255)); + return result.toString(); + } + private static void auditOres(WorldServer level, int centerX, int centerZ, int radius, int repetition) { Map audits = new LinkedHashMap<>(); diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang index 28319a7a..ddf85f92 100644 --- a/src/main/resources/assets/orespawn/lang/de_AU.lang +++ b/src/main/resources/assets/orespawn/lang/de_AU.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mischt konfigurierte Biome mit dem ursprüng tooltip.orespawn.biome.scope=Wählen Sie aus, welche vorhandenen Biom-Namespaces ersetzt werden können: alle Biome, nur Minecraft oder ausgewählte Mod-Namespaces. tooltip.orespawn.biome.region_size=Steuert die horizontale Größe von Biom-Platzierungsregionen. Größere Werte erzeugen breitere, weniger häufige Grenzen. tooltip.orespawn.biome.entries=Öffnen Sie die Biomeinträge dieser Dimension, um Gewichte, Klimagrenzen, Ähnlichkeitsregeln und Oberflächenmaterialien zu konfigurieren. -tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie dimensionsweite Grundwasserleiterflüssigkeiten sowie Schnee- und Eisersatz. +tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie Grundwasserleiterflüssigkeit, Schnee und Eis in jedem Biom des neu erzeugten Terrains dieser Dimension. Bestehende Chunks werden nie geändert. tooltip.orespawn.biome.geome_influences=Ordnen Sie installierte Biome relativen Geome-Gewichten zu, die von Sky Geology verwendet werden. tooltip.orespawn.biome.similar_biomes=Diese Ausgabe nur zulassen, wenn das ursprüngliche Biom mit einer dieser IDs übereinstimmt. Eine leere Liste erlaubt jedes Biom innerhalb der Klimagrenzen. tooltip.orespawn.biome.required_similar_biomes=Wie ähnliche Biome, aber diese Ausgabe ist deaktiviert, wenn eines der aufgelisteten Biome nicht installiert ist. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Ersetzen Sie die Blöcke direkt unter der ob tooltip.orespawn.biome.underwater_block=Ersetzen Sie den freiliegenden Unterwasseroberflächenblock des Bioms. Nicht gesetzt behält den normalen Block bei. tooltip.orespawn.biome.ceiling_block=Ersetzen Sie den Deckenflächenblock des Bioms in Abmessungen, die Decken erzeugen. Nicht festgelegt behält den normalen Block bei. tooltip.orespawn.biome.filler_depth=Anzahl der Ebenen unter dem oberen Block, die den Füllblock verwenden. Gültiger Bereich: 0 bis 16. -tooltip.orespawn.material.default_fluid=Wählen Sie die normale Grundwasserleiterflüssigkeit, die unterhalb des Meeresspiegels verwendet wird. Nicht festgelegt behält die ursprüngliche Flüssigkeit von Minecraft bei. +tooltip.orespawn.material.default_fluid=Ersetzt die normale Grundwasserleiterflüssigkeit unter dem Meeresspiegel in jedem Biom dieser Dimension. Nicht festgelegt behält die ursprüngliche Generatorflüssigkeit bei. Bestehende Chunks bleiben unverändert; Flüssigkeiten mit ungewöhnlicher Beleuchtung können langsamer generieren. tooltip.orespawn.material.deep_aquifer_fluid=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.deep_aquifer_y=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.snow_block=Ersetzen Sie Vanilleschnee, der in dieser Dimension in der Nähe der Oberfläche platziert wird. Nicht gesetzt hält normalen Schnee. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biompalette screen.orespawn.choose_biome=Installiertes Biom auswählen screen.orespawn.biome_placement=Biomeinstellungen screen.orespawn.biome_references=Ähnliche Biome -screen.orespawn.dimension_materials=Weltmaterialien -button.orespawn.biomes_world_materials=Biome und Weltmaterialien... +screen.orespawn.dimension_materials=%s-Materialien +button.orespawn.biomes_world_materials=Biome... button.orespawn.biome_palette_count=Biome: %s button.orespawn.dimension_materials=Weltmaterialien... +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.geome_influences=Geomeinflüsse... button.orespawn.add_biome=Biom hinzufügen... button.orespawn.similar_biomes=Ähnlich: %s @@ -355,3 +357,154 @@ 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. +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=Erzlexikon: %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=Ore-Dictionary-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 Ore-Dictionary-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 Ore-Dictionary-Erznamen 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 Ore-Dictionary-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 Erzlexikon-Eintrag seiner ursprünglichen Gruppe zuzuordnen. +error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. +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=Ore-Dictionary-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.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang index 28319a7a..ddf85f92 100644 --- a/src/main/resources/assets/orespawn/lang/de_DE.lang +++ b/src/main/resources/assets/orespawn/lang/de_DE.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mischt konfigurierte Biome mit dem ursprüng tooltip.orespawn.biome.scope=Wählen Sie aus, welche vorhandenen Biom-Namespaces ersetzt werden können: alle Biome, nur Minecraft oder ausgewählte Mod-Namespaces. tooltip.orespawn.biome.region_size=Steuert die horizontale Größe von Biom-Platzierungsregionen. Größere Werte erzeugen breitere, weniger häufige Grenzen. tooltip.orespawn.biome.entries=Öffnen Sie die Biomeinträge dieser Dimension, um Gewichte, Klimagrenzen, Ähnlichkeitsregeln und Oberflächenmaterialien zu konfigurieren. -tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie dimensionsweite Grundwasserleiterflüssigkeiten sowie Schnee- und Eisersatz. +tooltip.orespawn.biome.dimension_materials=Konfigurieren Sie Grundwasserleiterflüssigkeit, Schnee und Eis in jedem Biom des neu erzeugten Terrains dieser Dimension. Bestehende Chunks werden nie geändert. tooltip.orespawn.biome.geome_influences=Ordnen Sie installierte Biome relativen Geome-Gewichten zu, die von Sky Geology verwendet werden. tooltip.orespawn.biome.similar_biomes=Diese Ausgabe nur zulassen, wenn das ursprüngliche Biom mit einer dieser IDs übereinstimmt. Eine leere Liste erlaubt jedes Biom innerhalb der Klimagrenzen. tooltip.orespawn.biome.required_similar_biomes=Wie ähnliche Biome, aber diese Ausgabe ist deaktiviert, wenn eines der aufgelisteten Biome nicht installiert ist. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Ersetzen Sie die Blöcke direkt unter der ob tooltip.orespawn.biome.underwater_block=Ersetzen Sie den freiliegenden Unterwasseroberflächenblock des Bioms. Nicht gesetzt behält den normalen Block bei. tooltip.orespawn.biome.ceiling_block=Ersetzen Sie den Deckenflächenblock des Bioms in Abmessungen, die Decken erzeugen. Nicht festgelegt behält den normalen Block bei. tooltip.orespawn.biome.filler_depth=Anzahl der Ebenen unter dem oberen Block, die den Füllblock verwenden. Gültiger Bereich: 0 bis 16. -tooltip.orespawn.material.default_fluid=Wählen Sie die normale Grundwasserleiterflüssigkeit, die unterhalb des Meeresspiegels verwendet wird. Nicht festgelegt behält die ursprüngliche Flüssigkeit von Minecraft bei. +tooltip.orespawn.material.default_fluid=Ersetzt die normale Grundwasserleiterflüssigkeit unter dem Meeresspiegel in jedem Biom dieser Dimension. Nicht festgelegt behält die ursprüngliche Generatorflüssigkeit bei. Bestehende Chunks bleiben unverändert; Flüssigkeiten mit ungewöhnlicher Beleuchtung können langsamer generieren. tooltip.orespawn.material.deep_aquifer_fluid=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.deep_aquifer_y=In Minecraft 1.10.2 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten. tooltip.orespawn.material.snow_block=Ersetzen Sie Vanilleschnee, der in dieser Dimension in der Nähe der Oberfläche platziert wird. Nicht gesetzt hält normalen Schnee. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biompalette screen.orespawn.choose_biome=Installiertes Biom auswählen screen.orespawn.biome_placement=Biomeinstellungen screen.orespawn.biome_references=Ähnliche Biome -screen.orespawn.dimension_materials=Weltmaterialien -button.orespawn.biomes_world_materials=Biome und Weltmaterialien... +screen.orespawn.dimension_materials=%s-Materialien +button.orespawn.biomes_world_materials=Biome... button.orespawn.biome_palette_count=Biome: %s button.orespawn.dimension_materials=Weltmaterialien... +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.geome_influences=Geomeinflüsse... button.orespawn.add_biome=Biom hinzufügen... button.orespawn.similar_biomes=Ähnlich: %s @@ -355,3 +357,154 @@ 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. +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=Erzlexikon: %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=Ore-Dictionary-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 Ore-Dictionary-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 Ore-Dictionary-Erznamen 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 Ore-Dictionary-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 Erzlexikon-Eintrag seiner ursprünglichen Gruppe zuzuordnen. +error.orespawn.ore_source.alias=Verwende einen exakten Ore-Dictionary-Erznamen wie oreSulfur. +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=Ore-Dictionary-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.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang index ca45e95f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_CA.lang +++ b/src/main/resources/assets/orespawn/lang/en_CA.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette 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 -button.orespawn.biomes_world_materials=Biomes & World Materials... +screen.orespawn.dimension_materials=%s Materials +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -355,3 +357,154 @@ 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. +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=Ore Dictionary: %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=Ore Dictionary entry +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 Ore Dictionary entries +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 Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary 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 Ore Dictionary entry to its original group. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +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=Ore Dictionary entries +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.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang index ca45e95f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_EN.lang +++ b/src/main/resources/assets/orespawn/lang/en_EN.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette 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 -button.orespawn.biomes_world_materials=Biomes & World Materials... +screen.orespawn.dimension_materials=%s Materials +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -355,3 +357,154 @@ 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. +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=Ore Dictionary: %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=Ore Dictionary entry +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 Ore Dictionary entries +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 Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary 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 Ore Dictionary entry to its original group. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +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=Ore Dictionary entries +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.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang index ca45e95f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_GB.lang +++ b/src/main/resources/assets/orespawn/lang/en_GB.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette 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 -button.orespawn.biomes_world_materials=Biomes & World Materials... +screen.orespawn.dimension_materials=%s Materials +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -355,3 +357,154 @@ 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. +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=Ore Dictionary: %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=Ore Dictionary entry +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 Ore Dictionary entries +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 Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary 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 Ore Dictionary entry to its original group. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +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=Ore Dictionary entries +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.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang index ca45e95f..d4da74c0 100644 --- a/src/main/resources/assets/orespawn/lang/en_PT.lang +++ b/src/main/resources/assets/orespawn/lang/en_PT.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original bi tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette 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 -button.orespawn.biomes_world_materials=Biomes & World Materials... +screen.orespawn.dimension_materials=%s Materials +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -355,3 +357,154 @@ 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. +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=Ore Dictionary: %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=Ore Dictionary entry +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 Ore Dictionary entries +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 Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary 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 Ore Dictionary entry to its original group. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +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=Ore Dictionary entries +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.lang b/src/main/resources/assets/orespawn/lang/en_US.lang index 847f14dc..ea25544d 100644 --- a/src/main/resources/assets/orespawn/lang/en_US.lang +++ b/src/main/resources/assets/orespawn/lang/en_US.lang @@ -37,13 +37,13 @@ tooltip.orespawn.rock.depth_spread=How gradually the rock's depth preference fal tooltip.orespawn.rock.min_y=Lowest Y where this rock may replace terrain. Valid range: 0 to 255; it must not exceed Maximum Y. tooltip.orespawn.rock.max_y=Highest Y where this rock may replace terrain. Valid range: 0 to 255; it must not be below Minimum Y. tooltip.orespawn.rock.ore_replaceable=Allow OreSpawn-managed ores to replace this rock when it is selected as a host family. -tooltip.orespawn.biome.dimension=Select the dimension whose biome-placement and world-material settings are shown. +tooltip.orespawn.biome.dimension=Select the dimension whose biomes, placement rules, replacements, and world materials are shown. Show All remains limited to biomes associated with this dimension. tooltip.orespawn.biome.palette_enabled=Enable provider-supplied biome placement in this dimension. Turning it off preserves the saved biome entries. tooltip.orespawn.biome.mode=Augment mixes configured biomes with the original biome. Replace chooses only from eligible configured biomes. tooltip.orespawn.biome.scope=Choose which existing biome namespaces may be replaced: all biomes, Minecraft only, or selected mod namespaces. tooltip.orespawn.biome.region_size=Controls the horizontal size of biome-placement regions. Larger values create broader, less frequent boundaries. tooltip.orespawn.biome.entries=Open this dimension's biome entries to configure weights, climate limits, similarity rules, and surface materials. -tooltip.orespawn.biome.dimension_materials=Configure dimension-wide aquifer fluids plus snow and ice replacements. +tooltip.orespawn.biome.dimension_materials=Configure aquifer fluid, snow, and ice across every biome in newly generated terrain for this dimension. Existing chunks are never rewritten. tooltip.orespawn.biome.geome_influences=Map installed biomes to relative geome weights used by Sky geology. tooltip.orespawn.biome.similar_biomes=Allow this output only when the original biome matches one of these IDs. An empty list allows any biome within the climate limits. tooltip.orespawn.biome.required_similar_biomes=Like Similar Biomes, but this output is disabled if any listed biome is not installed. @@ -56,7 +56,7 @@ tooltip.orespawn.biome.filler_block=Replace the blocks immediately below the top tooltip.orespawn.biome.underwater_block=Replace the biome's exposed underwater surface block. Not set keeps the normal block. tooltip.orespawn.biome.ceiling_block=Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block. tooltip.orespawn.biome.filler_depth=Number of layers below the top block that use Filler Block. Valid range: 0 to 16. -tooltip.orespawn.material.default_fluid=Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid. +tooltip.orespawn.material.default_fluid=Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly. tooltip.orespawn.material.deep_aquifer_fluid=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.deep_aquifer_y=Unavailable in Minecraft 1.10.2; the stored value is retained for later versions. tooltip.orespawn.material.snow_block=Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Biome Palette 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 -button.orespawn.biomes_world_materials=Biomes & World Materials... +screen.orespawn.dimension_materials=%s Materials +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes: %s button.orespawn.dimension_materials=World Materials... +button.orespawn.dimension_materials_named=%s Materials... +label.orespawn.dimension_materials.scope=Applies to every biome in newly generated %s terrain. button.orespawn.geome_influences=Geome Influences... button.orespawn.add_biome=Add Biome... button.orespawn.similar_biomes=Similar: %s @@ -355,3 +357,154 @@ 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. +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=Ore Dictionary: %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=Ore Dictionary entry +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 Ore Dictionary entries +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 Ore Dictionary ore name. Press Move again to confirm reassignment from another group. +tooltip.orespawn.ore_source.reset_group=Restore this curated group name and Ore Dictionary 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 Ore Dictionary entry to its original group. +error.orespawn.ore_source.alias=Use an exact Ore Dictionary ore name such as oreSulfur. +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=Ore Dictionary entries +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.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang index c314efd0..1954b63f 100644 --- a/src/main/resources/assets/orespawn/lang/es_ES.lang +++ b/src/main/resources/assets/orespawn/lang/es_ES.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mezcla los biomas configurados con el bioma tooltip.orespawn.biome.scope=Elija qué espacios de nombres de biomas existentes pueden reemplazarse: todos los biomas, solo Minecraft o espacios de nombres mod seleccionados. tooltip.orespawn.biome.region_size=Controla el tamaño horizontal de las regiones de ubicación de biomas. Los valores más grandes crean límites más amplios y menos frecuentes. tooltip.orespawn.biome.entries=Abra las entradas del bioma de esta dimensión para configurar pesos, límites climáticos, reglas de similitud y materiales de superficie. -tooltip.orespawn.biome.dimension_materials=Configure fluidos acuíferos en toda la dimensión además de reemplazos de nieve y hielo. +tooltip.orespawn.biome.dimension_materials=Configure el fluido acuífero, la nieve y el hielo en cada bioma del terreno nuevo de esta dimensión. Los chunks existentes nunca se reescriben. tooltip.orespawn.biome.geome_influences=Asigne biomas instalados a pesos relativos de geomas utilizados por Sky geology. tooltip.orespawn.biome.similar_biomes=Permita esta salida solo cuando el bioma original coincida con una de estas ID. Una lista vacía permite cualquier bioma dentro de los límites climáticos. tooltip.orespawn.biome.required_similar_biomes=Como biomas similares, pero esta salida se desactiva si algún bioma listado no está instalado. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Reemplace los bloques inmediatamente debajo tooltip.orespawn.biome.underwater_block=Reemplaza el bloque de superficie submarina expuesto del bioma. No establecido mantiene el bloqueo normal. tooltip.orespawn.biome.ceiling_block=Reemplace el bloque de superficie del techo del bioma en dimensiones que generen techos. No establecido mantiene el bloque normal. tooltip.orespawn.biome.filler_depth=Número de capas debajo del bloque superior que utilizan el bloque de relleno. Rango válido: 0 a 16. -tooltip.orespawn.material.default_fluid=Elija el fluido normal del acuífero utilizado debajo del nivel del mar. No establecido mantiene el fluido original de Minecraft. +tooltip.orespawn.material.default_fluid=Reemplaza el fluido acuífero normal bajo el nivel del mar en cada bioma de esta dimensión. Sin definir conserva el fluido original del generador. Los chunks existentes no cambian; los fluidos con iluminación inusual pueden generarse más lentamente. tooltip.orespawn.material.deep_aquifer_fluid=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.deep_aquifer_y=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.snow_block=Reemplace la nieve vainilla colocada cerca de la superficie en esta dimensión. No fijado mantiene la nieve normal. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Paleta de biomas 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 -button.orespawn.biomes_world_materials=Biomas y materiales del mundo... +screen.orespawn.dimension_materials=Materiales de %s +button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiales del mundo... +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.geome_influences=Influencias de geomas... button.orespawn.add_biome=Añadir bioma... button.orespawn.similar_biomes=Similares: %s @@ -355,3 +357,154 @@ 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. +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=Diccionario de minerales: %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 diccionario de minerales +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 diccionario de minerales +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 diccionario. 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 diccionario 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 diccionario a su grupo original. +error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. +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 diccionario 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.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang index c314efd0..1954b63f 100644 --- a/src/main/resources/assets/orespawn/lang/es_MX.lang +++ b/src/main/resources/assets/orespawn/lang/es_MX.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mezcla los biomas configurados con el bioma tooltip.orespawn.biome.scope=Elija qué espacios de nombres de biomas existentes pueden reemplazarse: todos los biomas, solo Minecraft o espacios de nombres mod seleccionados. tooltip.orespawn.biome.region_size=Controla el tamaño horizontal de las regiones de ubicación de biomas. Los valores más grandes crean límites más amplios y menos frecuentes. tooltip.orespawn.biome.entries=Abra las entradas del bioma de esta dimensión para configurar pesos, límites climáticos, reglas de similitud y materiales de superficie. -tooltip.orespawn.biome.dimension_materials=Configure fluidos acuíferos en toda la dimensión además de reemplazos de nieve y hielo. +tooltip.orespawn.biome.dimension_materials=Configure el fluido acuífero, la nieve y el hielo en cada bioma del terreno nuevo de esta dimensión. Los chunks existentes nunca se reescriben. tooltip.orespawn.biome.geome_influences=Asigne biomas instalados a pesos relativos de geomas utilizados por Sky geology. tooltip.orespawn.biome.similar_biomes=Permita esta salida solo cuando el bioma original coincida con una de estas ID. Una lista vacía permite cualquier bioma dentro de los límites climáticos. tooltip.orespawn.biome.required_similar_biomes=Como biomas similares, pero esta salida se desactiva si algún bioma listado no está instalado. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Reemplace los bloques inmediatamente debajo tooltip.orespawn.biome.underwater_block=Reemplaza el bloque de superficie submarina expuesto del bioma. No establecido mantiene el bloqueo normal. tooltip.orespawn.biome.ceiling_block=Reemplace el bloque de superficie del techo del bioma en dimensiones que generen techos. No establecido mantiene el bloque normal. tooltip.orespawn.biome.filler_depth=Número de capas debajo del bloque superior que utilizan el bloque de relleno. Rango válido: 0 a 16. -tooltip.orespawn.material.default_fluid=Elija el fluido normal del acuífero utilizado debajo del nivel del mar. No establecido mantiene el fluido original de Minecraft. +tooltip.orespawn.material.default_fluid=Reemplaza el fluido acuífero normal bajo el nivel del mar en cada bioma de esta dimensión. Sin definir conserva el fluido original del generador. Los chunks existentes no cambian; los fluidos con iluminación inusual pueden generarse más lentamente. tooltip.orespawn.material.deep_aquifer_fluid=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.deep_aquifer_y=No disponible en Minecraft 1.10.2; el valor guardado se conserva para versiones posteriores. tooltip.orespawn.material.snow_block=Reemplace la nieve vainilla colocada cerca de la superficie en esta dimensión. No fijado mantiene la nieve normal. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Paleta de biomas 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 -button.orespawn.biomes_world_materials=Biomas y materiales del mundo... +screen.orespawn.dimension_materials=Materiales de %s +button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiales del mundo... +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.geome_influences=Influencias de geomas... button.orespawn.add_biome=Añadir bioma... button.orespawn.similar_biomes=Similares: %s @@ -355,3 +357,154 @@ 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. +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=Diccionario de minerales: %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 diccionario de minerales +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 diccionario de minerales +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 diccionario. 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 diccionario 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 diccionario a su grupo original. +error.orespawn.ore_source.alias=Usa un nombre exacto del diccionario de minerales, como oreSulfur. +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 diccionario 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.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang index e6401a63..72a6cb4d 100644 --- a/src/main/resources/assets/orespawn/lang/fr_CA.lang +++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mélange les biomes configurés avec le biom tooltip.orespawn.biome.scope=Choisissez les espaces de noms de biomes existants qui peuvent être remplacés : tous les biomes, Minecraft uniquement ou les espaces de noms de mod sélectionnés. tooltip.orespawn.biome.region_size=Contrôle la taille horizontale des régions de placement de biome. Des valeurs plus élevées créent des limites plus larges et moins fréquentes. tooltip.orespawn.biome.entries=Ouvrez les entrées du biome de cette dimension pour configurer les poids, les limites climatiques, les règles de similarité et les matériaux de surface. -tooltip.orespawn.biome.dimension_materials=Configurez les fluides aquifères à l'échelle de la dimension ainsi que les remplacements de neige et de glace. +tooltip.orespawn.biome.dimension_materials=Configurez le fluide aquifère, la neige et la glace dans chaque biome du nouveau terrain de cette dimension. Les chunks existants ne sont jamais réécrits. tooltip.orespawn.biome.geome_influences=Mapper les biomes installés avec les poids géographiques relatifs utilisés par la géologie du ciel. tooltip.orespawn.biome.similar_biomes=Autoriser cette sortie uniquement lorsque le biome d'origine correspond à l'un de ces identifiants. Une liste vide autorise n'importe quel biome dans les limites climatiques. tooltip.orespawn.biome.required_similar_biomes=Comme Biomes similaires, mais cette sortie est désactivée si au moins un biome répertorié n'est pas installé. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Remplacez les blocs immédiatement sous la s tooltip.orespawn.biome.underwater_block=Remplacez le bloc de surface sous-marine exposé du biome. Non défini conserve le bloc normal. tooltip.orespawn.biome.ceiling_block=Remplacez le bloc de surface de plafond du biome dans les dimensions qui génèrent des plafonds. Non défini conserve le bloc normal. tooltip.orespawn.biome.filler_depth=Nombre de calques sous le bloc supérieur qui utilisent le bloc de remplissage. Plage valide : 0 à 16. -tooltip.orespawn.material.default_fluid=Choisissez le fluide aquifère normal utilisé sous le niveau de la mer. Non défini, conserve le fluide d'origine de Minecraft. +tooltip.orespawn.material.default_fluid=Remplace le fluide aquifère normal sous le niveau de la mer dans chaque biome de cette dimension. Non défini conserve le fluide original du générateur. Les chunks existants restent inchangés; les fluides à éclairage inhabituel peuvent être générés plus lentement. tooltip.orespawn.material.deep_aquifer_fluid=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.deep_aquifer_y=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.snow_block=Remplacez la neige vanille placée près de la surface dans cette dimension. Non pris, garde la neige normale. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Palette de biomes 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 -button.orespawn.biomes_world_materials=Biomes et matériaux du monde... +screen.orespawn.dimension_materials=Matériaux de %s +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes : %s button.orespawn.dimension_materials=Matériaux du monde... +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.geome_influences=Influences des géomes... button.orespawn.add_biome=Ajouter un biome... button.orespawn.similar_biomes=Similaires : %s @@ -355,3 +357,154 @@ 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. +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=Dictionnaire des minerais : %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 dictionnaire de minerais +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 dictionnaire de minerais +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 dictionnaire. 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 dictionnaire 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 dictionnaire dans son groupe d'origine. +error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. +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 dictionnaire de minerais +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.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang index e6401a63..72a6cb4d 100644 --- a/src/main/resources/assets/orespawn/lang/fr_FR.lang +++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment mélange les biomes configurés avec le biom tooltip.orespawn.biome.scope=Choisissez les espaces de noms de biomes existants qui peuvent être remplacés : tous les biomes, Minecraft uniquement ou les espaces de noms de mod sélectionnés. tooltip.orespawn.biome.region_size=Contrôle la taille horizontale des régions de placement de biome. Des valeurs plus élevées créent des limites plus larges et moins fréquentes. tooltip.orespawn.biome.entries=Ouvrez les entrées du biome de cette dimension pour configurer les poids, les limites climatiques, les règles de similarité et les matériaux de surface. -tooltip.orespawn.biome.dimension_materials=Configurez les fluides aquifères à l'échelle de la dimension ainsi que les remplacements de neige et de glace. +tooltip.orespawn.biome.dimension_materials=Configurez le fluide aquifère, la neige et la glace dans chaque biome du nouveau terrain de cette dimension. Les chunks existants ne sont jamais réécrits. tooltip.orespawn.biome.geome_influences=Mapper les biomes installés avec les poids géographiques relatifs utilisés par la géologie du ciel. tooltip.orespawn.biome.similar_biomes=Autoriser cette sortie uniquement lorsque le biome d'origine correspond à l'un de ces identifiants. Une liste vide autorise n'importe quel biome dans les limites climatiques. tooltip.orespawn.biome.required_similar_biomes=Comme Biomes similaires, mais cette sortie est désactivée si au moins un biome répertorié n'est pas installé. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Remplacez les blocs immédiatement sous la s tooltip.orespawn.biome.underwater_block=Remplacez le bloc de surface sous-marine exposé du biome. Non défini conserve le bloc normal. tooltip.orespawn.biome.ceiling_block=Remplacez le bloc de surface de plafond du biome dans les dimensions qui génèrent des plafonds. Non défini conserve le bloc normal. tooltip.orespawn.biome.filler_depth=Nombre de calques sous le bloc supérieur qui utilisent le bloc de remplissage. Plage valide : 0 à 16. -tooltip.orespawn.material.default_fluid=Choisissez le fluide aquifère normal utilisé sous le niveau de la mer. Non défini, conserve le fluide d'origine de Minecraft. +tooltip.orespawn.material.default_fluid=Remplace le fluide aquifère normal sous le niveau de la mer dans chaque biome de cette dimension. Non défini conserve le fluide original du générateur. Les chunks existants restent inchangés; les fluides à éclairage inhabituel peuvent être générés plus lentement. tooltip.orespawn.material.deep_aquifer_fluid=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.deep_aquifer_y=Indisponible dans Minecraft 1.10.2 ; la valeur enregistrée est conservée pour les versions ultérieures. tooltip.orespawn.material.snow_block=Remplacez la neige vanille placée près de la surface dans cette dimension. Non pris, garde la neige normale. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Palette de biomes 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 -button.orespawn.biomes_world_materials=Biomes et matériaux du monde... +screen.orespawn.dimension_materials=Matériaux de %s +button.orespawn.biomes_world_materials=Biomes... button.orespawn.biome_palette_count=Biomes : %s button.orespawn.dimension_materials=Matériaux du monde... +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.geome_influences=Influences des géomes... button.orespawn.add_biome=Ajouter un biome... button.orespawn.similar_biomes=Similaires : %s @@ -355,3 +357,154 @@ 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. +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=Dictionnaire des minerais : %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 dictionnaire de minerais +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 dictionnaire de minerais +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 dictionnaire. 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 dictionnaire 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 dictionnaire dans son groupe d'origine. +error.orespawn.ore_source.alias=Utilisez un nom exact du dictionnaire de minerais, comme oreSulfur. +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 dictionnaire de minerais +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.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang index 14410380..1e5ac494 100644 --- a/src/main/resources/assets/orespawn/lang/ja_JP.lang +++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment は、構成されたバイオームを元 tooltip.orespawn.biome.scope=置換できる既存のバイオーム名前空間を選択します: すべてのバイオーム、Minecraft のみ、または選択した MOD 名前空間。 tooltip.orespawn.biome.region_size=バイオーム配置領域の水平サイズを制御します。値が大きいほど、より広く、頻度の少ない境界が作成されます。 tooltip.orespawn.biome.entries=このディメンションのバイオーム エントリを開いて、重み、気候制限、類似性ルール、および表面マテリアルを構成します。 -tooltip.orespawn.biome.dimension_materials=ディメンション全体の帯水層流体と雪と氷の代替を構成します。 +tooltip.orespawn.biome.dimension_materials=このディメンションで新しく生成される地形のすべてのバイオームに帯水層流体、雪、氷を設定します。既存チャンクは変更されません。 tooltip.orespawn.biome.geome_influences=導入済みバイオームを、Sky 地質で使用するジオムの相対ウェイトに対応付けます。 tooltip.orespawn.biome.similar_biomes=元のバイオームがこれらの ID のいずれかに一致する場合にのみ、この出力を許可します。空のリストでは、気候制限内の任意のバイオームが許可されます。 tooltip.orespawn.biome.required_similar_biomes=類似のバイオームと同様ですが、リストされたバイオームがインストールされていない場合、この出力は無効になります。 @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=上面のすぐ下のブロックを置き tooltip.orespawn.biome.underwater_block=バイオームの露出した水中表面ブロックを交換します。設定しない場合は、通常のブロックが維持されます。 tooltip.orespawn.biome.ceiling_block=バイオームの天井面ブロックを天井を生成する寸法に置き換えます。設定しない場合は、通常のブロックが保持されます。 tooltip.orespawn.biome.filler_depth=フィラー ブロックを使用する最上位ブロックの下のレイヤーの数。有効な範囲: 0 ~ 16。 -tooltip.orespawn.material.default_fluid=海面以下で使用される通常の帯水層流体を選択します。設定しない場合は、Minecraft の元の流体が保持されます。 +tooltip.orespawn.material.default_fluid=このディメンションの全バイオームで海面下の通常の帯水層流体を置換します。未設定ではジェネレーター本来の流体を維持します。既存チャンクは変更されず、特殊な照明の流体は生成が遅くなる場合があります。 tooltip.orespawn.material.deep_aquifer_fluid=Minecraft 1.10.2 では使用できません。保存済みの値は新しいバージョン向けに保持されます。 tooltip.orespawn.material.deep_aquifer_y=Minecraft 1.10.2 では使用できません。保存済みの値は新しいバージョン向けに保持されます。 tooltip.orespawn.material.snow_block=このディメンションのサーフェス近くに配置されたバニラ スノーを置き換えます。設定しない場合は通常の雪のままです。 @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=バイオームパレット screen.orespawn.choose_biome=導入済みバイオームを選択 screen.orespawn.biome_placement=バイオーム設定 screen.orespawn.biome_references=類似バイオーム -screen.orespawn.dimension_materials=ワールド素材 -button.orespawn.biomes_world_materials=バイオームとワールド素材... +screen.orespawn.dimension_materials=%s の素材 +button.orespawn.biomes_world_materials=バイオーム... button.orespawn.biome_palette_count=バイオーム: %s button.orespawn.dimension_materials=ワールド素材... +button.orespawn.dimension_materials_named=%s の素材... +label.orespawn.dimension_materials.scope=新しく生成される %s 地形のすべてのバイオームに適用されます。 button.orespawn.geome_influences=ジオームの影響... button.orespawn.add_biome=バイオームを追加... button.orespawn.similar_biomes=類似: %s @@ -355,3 +357,154 @@ guide.orespawn.materials.title=ワールド素材 guide.orespawn.materials.1=ワールド素材はディメンション全体の基本素材を変更します。ときどき現れる地下ポケットだけが欲しい場合は流体鉱床を使います。 guide.orespawn.materials.2=帯水層の流体は海面より下で使う通常の流体を変更します。深部帯水層は選んだYより下で2種類目の流体を使います。 guide.orespawn.materials.3=雪と氷は地表付近のバニラの雪と通常の氷を置き換えます。「未設定」ならMinecraft標準の素材を変えません。 +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=oreSulfur のような正確な鉱石辞書名を使用してください。 +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.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang index 4160ffb7..21b43106 100644 --- a/src/main/resources/assets/orespawn/lang/ko_KR.lang +++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment는 구성된 생물 군계와 원래 생물 tooltip.orespawn.biome.scope=교체할 수 있는 기존 생물 군계 네임스페이스를 선택합니다(모든 생물 군계, Minecraft만 또는 선택한 모드 네임스페이스). tooltip.orespawn.biome.region_size=생물 군계 배치 영역의 수평 크기를 제어합니다. 값이 클수록 더 넓고 덜 빈번한 경계가 생성됩니다. tooltip.orespawn.biome.entries=이 차원의 생물 군계 항목을 열어 가중치, 기후 한계, 유사성 규칙 및 표면 물질을 구성합니다. -tooltip.orespawn.biome.dimension_materials=차원 전체 대수층 유체와 눈 및 얼음 대체를 구성합니다. +tooltip.orespawn.biome.dimension_materials=이 차원에서 새로 생성되는 지형의 모든 생물 군계에 대수층 유체, 눈, 얼음을 설정합니다. 기존 청크는 변경되지 않습니다. tooltip.orespawn.biome.geome_influences=설치된 생물군계를 Sky 지질에서 사용하는 상대적 지옴 가중치에 연결합니다. tooltip.orespawn.biome.similar_biomes=원래 생물 군계가 이러한 ID 중 하나와 일치하는 경우에만 이 출력을 허용합니다. 빈 목록은 기후 한계 내의 모든 생물 군계를 허용합니다. tooltip.orespawn.biome.required_similar_biomes=유사한 생물 군계와 유사하지만 나열된 생물 군계가 설치되지 않은 경우 이 출력은 비활성화됩니다. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=상단 표면 바로 아래의 블록을 교 tooltip.orespawn.biome.underwater_block=생물 군계의 노출된 수중 표면 블록을 교체합니다. 설정되지 않으면 일반 블록이 유지됩니다. tooltip.orespawn.biome.ceiling_block=천장을 생성하는 차원에서 생물 군계의 천장 표면 블록을 교체합니다. 설정하지 않으면 일반 블록을 유지합니다. tooltip.orespawn.biome.filler_depth=채우기 블록을 사용하는 상단 블록 아래의 레이어 수입니다. 유효 범위: 0 ~ 16. -tooltip.orespawn.material.default_fluid=해수면 아래에서 사용되는 일반 대수층 유체를 선택합니다. 설정하지 않으면 Minecraft의 원래 유체가 유지됩니다. +tooltip.orespawn.material.default_fluid=이 차원의 모든 생물 군계에서 해수면 아래의 일반 대수층 유체를 교체합니다. 설정하지 않으면 생성기의 원래 유체를 유지합니다. 기존 청크는 변경되지 않으며 특수한 조명의 유체는 더 느리게 생성될 수 있습니다. tooltip.orespawn.material.deep_aquifer_fluid=Minecraft 1.10.2에서는 사용할 수 없습니다. 저장된 값은 이후 버전용으로 유지됩니다. tooltip.orespawn.material.deep_aquifer_y=Minecraft 1.10.2에서는 사용할 수 없습니다. 저장된 값은 이후 버전용으로 유지됩니다. tooltip.orespawn.material.snow_block=이 차원의 표면 근처에 배치된 바닐라 눈을 바꿉니다. 설정하지 않으면 정상적인 눈이 유지됩니다. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=생물군계 팔레트 screen.orespawn.choose_biome=설치된 생물군계 선택 screen.orespawn.biome_placement=생물군계 설정 screen.orespawn.biome_references=유사 생물군계 -screen.orespawn.dimension_materials=월드 재료 -button.orespawn.biomes_world_materials=생물군계 및 월드 재료... +screen.orespawn.dimension_materials=%s 재료 +button.orespawn.biomes_world_materials=생물군계... button.orespawn.biome_palette_count=생물군계: %s button.orespawn.dimension_materials=월드 재료... +button.orespawn.dimension_materials_named=%s 재료... +label.orespawn.dimension_materials.scope=새로 생성되는 %s 지형의 모든 생물 군계에 적용됩니다. button.orespawn.geome_influences=지옴 영향... button.orespawn.add_biome=생물군계 추가... button.orespawn.similar_biomes=유사: %s @@ -355,3 +357,154 @@ guide.orespawn.materials.title=월드 재료 guide.orespawn.materials.1=월드 재료는 차원 전체의 기본 재료를 바꿉니다. 가끔 나타나는 지하 주머니만 원한다면 유체 광상을 사용하세요. guide.orespawn.materials.2=대수층 유체는 해수면 아래에서 쓰는 기본 유체를 바꿉니다. 깊은 대수층은 선택한 Y 아래에서 두 번째 유체를 사용합니다. guide.orespawn.materials.3=눈과 얼음은 지표 근처의 바닐라 눈과 일반 얼음을 바꿉니다. 설정 안 함은 Minecraft 기본 재료를 그대로 둡니다. +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=oreSulfur 같은 정확한 광석 사전 이름을 사용하세요. +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.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang index e8daf02f..5ae787d8 100644 --- a/src/main/resources/assets/orespawn/lang/pt_BR.lang +++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Aumentar mistura biomas configurados com o bioma ori tooltip.orespawn.biome.scope=Escolha quais namespaces de biomas existentes podem ser substituídos: todos os biomas, apenas Minecraft ou namespaces de mod selecionados. tooltip.orespawn.biome.region_size=Controla o tamanho horizontal das regiões de posicionamento de biomas. Valores maiores criam limites mais amplos e menos frequentes. tooltip.orespawn.biome.entries=Abra as entradas do bioma desta dimensão para configurar pesos, limites climáticos, regras de similaridade e materiais de superfície. -tooltip.orespawn.biome.dimension_materials=Configure fluidos de aqüíferos em toda a dimensão, além de substituições de neve e gelo. +tooltip.orespawn.biome.dimension_materials=Configure o fluido do aquífero, neve e gelo em cada bioma do novo terreno desta dimensão. Chunks existentes nunca são reescritos. tooltip.orespawn.biome.geome_influences=Mapeie os biomas instalados para os pesos relativos do geoma usados ​​pela geologia Sky. tooltip.orespawn.biome.similar_biomes=Permita esta saída somente quando o bioma original corresponder a um desses IDs. Uma lista vazia permite qualquer bioma dentro dos limites climáticos. tooltip.orespawn.biome.required_similar_biomes=Como Biomas Semelhantes, mas esta saída é desativada se algum bioma listado não estiver instalado. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Substitua os blocos imediatamente abaixo da tooltip.orespawn.biome.underwater_block=Substitua o bloco de superfície subaquático exposto do bioma. Not set mantém o bloco normal. tooltip.orespawn.biome.ceiling_block=Substitua o bloco de superfície do teto do bioma em dimensões que gerem tetos. Não definido mantém o bloco normal. tooltip.orespawn.biome.filler_depth=Número de camadas abaixo do bloco superior que usam Bloco de Preenchimento. Faixa válida: 0 a 16. -tooltip.orespawn.material.default_fluid=Escolha o fluido aquífero normal usado abaixo do nível do mar. Não definido mantém o fluido original de Minecraft. +tooltip.orespawn.material.default_fluid=Substitui o fluido normal do aquífero abaixo do nível do mar em cada bioma desta dimensão. Não definido mantém o fluido original do gerador. Chunks existentes permanecem inalterados; fluidos com iluminação incomum podem gerar mais lentamente. tooltip.orespawn.material.deep_aquifer_fluid=Indisponível no Minecraft 1.10.2; o valor salvo é mantido para versões posteriores. tooltip.orespawn.material.deep_aquifer_y=Indisponível no Minecraft 1.10.2; o valor salvo é mantido para versões posteriores. tooltip.orespawn.material.snow_block=Substitua a neve baunilha colocada perto da superfície nesta dimensão. Não definido mantém a neve normal. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Paleta de biomas 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 -button.orespawn.biomes_world_materials=Biomas e materiais do mundo... +screen.orespawn.dimension_materials=Materiais de %s +button.orespawn.biomes_world_materials=Biomas... button.orespawn.biome_palette_count=Biomas: %s button.orespawn.dimension_materials=Materiais do mundo... +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.geome_influences=Influências de geomas... button.orespawn.add_biome=Adicionar bioma... button.orespawn.similar_biomes=Semelhantes: %s @@ -355,3 +357,154 @@ 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. +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=Dicionário de minérios: %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 dicionário de minérios +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 dicionário de minérios +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 dicionário. Pressione Mover novamente para confirmar a transferência de outro grupo. +tooltip.orespawn.ore_source.reset_group=Restaura o nome e as entradas do dicionário 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 dicionário ao grupo original. +error.orespawn.ore_source.alias=Use um nome exato do dicionário de minérios, como oreSulfur. +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.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang index 4c19ffe7..0cb3aaae 100644 --- a/src/main/resources/assets/orespawn/lang/ru_RU.lang +++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=Augment смешивает настроенные б tooltip.orespawn.biome.scope=Выберите, какие существующие пространства имен биомов могут быть заменены: все биомы, только Minecraft или выбранные пространства имен модов. tooltip.orespawn.biome.region_size=Управляет горизонтальным размером областей размещения биомов. Большие значения создают более широкие и менее частые границы. tooltip.orespawn.biome.entries=Откройте записи биома этого измерения, чтобы настроить веса, климатические ограничения, правила сходства и материалы поверхности. -tooltip.orespawn.biome.dimension_materials=Настройте жидкости водоносного горизонта по всему измерению, а также заменители снега и льда. +tooltip.orespawn.biome.dimension_materials=Настройте жидкость водоносного горизонта, снег и лёд во всех биомах новой местности этого измерения. Существующие чанки не изменяются. tooltip.orespawn.biome.geome_influences=Сопоставьте установленные биомы с относительными весами геомов, используемыми Sky Geology. tooltip.orespawn.biome.similar_biomes=Разрешить этот вывод только в том случае, если исходный биом соответствует одному из этих идентификаторов. Пустой список позволяет использовать любой биом в пределах климатических ограничений. tooltip.orespawn.biome.required_similar_biomes=Как подобные биомы, но этот вывод отключен, если какой-либо указанный биом не установлен. @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=Замените блоки сразу по tooltip.orespawn.biome.underwater_block=Замените блок обнаженной подводной поверхности биома. Если не установлено, сохраняется обычный блок. tooltip.orespawn.biome.ceiling_block=Замените блок поверхности потолка биома в размерах, которые создают потолки. Если не установлено, сохраняется обычный блок. tooltip.orespawn.biome.filler_depth=Количество слоев под верхним блоком, в которых используется блок-заполнитель. Допустимый диапазон: от 0 до 16. -tooltip.orespawn.material.default_fluid=Выберите обычную жидкость водоносного горизонта, используемую ниже уровня моря. Не установлено, сохраняет исходную жидкость Minecraft. +tooltip.orespawn.material.default_fluid=Заменяет обычную жидкость водоносного горизонта ниже уровня моря во всех биомах этого измерения. Если не задано, сохраняется исходная жидкость генератора. Существующие чанки не меняются; жидкости с необычным освещением могут генерироваться медленнее. tooltip.orespawn.material.deep_aquifer_fluid=Недоступно в Minecraft 1.10.2; сохранённое значение остаётся для более новых версий. tooltip.orespawn.material.deep_aquifer_y=Недоступно в Minecraft 1.10.2; сохранённое значение остаётся для более новых версий. tooltip.orespawn.material.snow_block=Замените ванильный снег, расположенный рядом с поверхностью в этом измерении. Не установлено, сохраняет обычный снег. @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=Палитра биомов screen.orespawn.choose_biome=Выбрать установленный биом screen.orespawn.biome_placement=Настройки биома screen.orespawn.biome_references=Похожие биомы -screen.orespawn.dimension_materials=Материалы мира -button.orespawn.biomes_world_materials=Биомы и материалы мира... +screen.orespawn.dimension_materials=Материалы: %s +button.orespawn.biomes_world_materials=Биомы... button.orespawn.biome_palette_count=Биомы: %s button.orespawn.dimension_materials=Материалы мира... +button.orespawn.dimension_materials_named=Материалы: %s... +label.orespawn.dimension_materials.scope=Применяется ко всем биомам новой местности %s. button.orespawn.geome_influences=Влияние геомов... button.orespawn.add_biome=Добавить биом... button.orespawn.similar_biomes=Похожие: %s @@ -355,3 +357,154 @@ guide.orespawn.materials.title=Материалы мира guide.orespawn.materials.1=Материалы мира меняют фоновые материалы во всём измерении. Для редких подземных карманов используйте Месторождения жидкостей. guide.orespawn.materials.2=Жидкость водоносного слоя меняет обычную жидкость ниже уровня моря. Глубокий слой использует вторую жидкость ниже выбранной Y. guide.orespawn.materials.3=Снег и лёд заменяют ванильный снег и обычный лёд у поверхности. Не задано сохраняет обычный материал Minecraft. +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=Используйте точное имя из словаря руд, например oreSulfur. +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.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang index b57e863f..83b2c1aa 100644 --- a/src/main/resources/assets/orespawn/lang/zh_CN.lang +++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang @@ -87,7 +87,7 @@ tooltip.orespawn.biome.mode=增强将配置的生物群落与原始生物群落 tooltip.orespawn.biome.scope=选择可以替换哪些现有生物群落命名空间:所有生物群落、仅限 Minecraft 或选定的 mod 命名空间。 tooltip.orespawn.biome.region_size=控制生物群落放置区域的水平大小。值越大,边界越宽,频率越低。 tooltip.orespawn.biome.entries=打开该维度的生物群系条目以配置权重、气候限制、相似性规则和表面材料。 -tooltip.orespawn.biome.dimension_materials=配置维度范围内的含水层流体以及冰雪替代品。 +tooltip.orespawn.biome.dimension_materials=为此维度新生成地形中的每个生物群系配置含水层流体、雪和冰。现有区块永远不会被重写。 tooltip.orespawn.biome.geome_influences=将已安装的生物群系映射到 Sky 地质所使用的相对地质域权重。 tooltip.orespawn.biome.similar_biomes=仅当原始生物群系与这些 ID 之一匹配时才允许此输出。空列表允许气候限制内的任何生物群落。 tooltip.orespawn.biome.required_similar_biomes=与类似生物群落类似,但如果未安装任何列出的生物群落,则此输出将被禁用。 @@ -100,7 +100,7 @@ tooltip.orespawn.biome.filler_block=替换紧邻顶部表面下方的方块。 tooltip.orespawn.biome.underwater_block=替换生物群系暴露的水下表面块。未设置保留正常块。 tooltip.orespawn.biome.ceiling_block=将生物群落的天花板表面块替换为生成天花板的尺寸。未设置保留正常块。 tooltip.orespawn.biome.filler_depth=使用填充块的顶部块下方的层数。有效范围:0 到 16。 -tooltip.orespawn.material.default_fluid=选择海平面以下使用的正常含水层流体。未设置会保留 Minecraft 的原始流体。 +tooltip.orespawn.material.default_fluid=替换此维度每个生物群系中海平面以下的普通含水层流体。未设置时保留生成器原始流体。现有区块不变;具有特殊光照的流体生成速度可能较慢。 tooltip.orespawn.material.deep_aquifer_fluid=Minecraft 1.10.2 中不可用;已保存的值会保留供更高版本使用。 tooltip.orespawn.material.deep_aquifer_y=Minecraft 1.10.2 中不可用;已保存的值会保留供更高版本使用。 tooltip.orespawn.material.snow_block=替换放置在该维度表面附近的香草雪。未设置保留正常雪。 @@ -301,10 +301,12 @@ screen.orespawn.biome_palette=生物群系调色板 screen.orespawn.choose_biome=选择已安装的生物群系 screen.orespawn.biome_placement=生物群系设置 screen.orespawn.biome_references=相似生物群系 -screen.orespawn.dimension_materials=世界材料 -button.orespawn.biomes_world_materials=生物群系和世界材料... +screen.orespawn.dimension_materials=%s 材料 +button.orespawn.biomes_world_materials=生物群系... button.orespawn.biome_palette_count=生物群系: %s button.orespawn.dimension_materials=世界材料... +button.orespawn.dimension_materials_named=%s 材料... +label.orespawn.dimension_materials.scope=应用于新生成的 %s 地形中的每个生物群系。 button.orespawn.geome_influences=地质域影响... button.orespawn.add_biome=添加生物群系... button.orespawn.similar_biomes=相似: %s @@ -355,3 +357,154 @@ guide.orespawn.materials.title=世界材料 guide.orespawn.materials.1=世界材料会改变整个维度的基础材料。若只想要偶尔出现的地下囊体,请使用流体矿床。 guide.orespawn.materials.2=含水层流体会改变海平面以下使用的普通流体。深层含水层会在所选Y值以下使用第二种流体。 guide.orespawn.materials.3=雪和冰会替换地表附近的原版雪与普通冰。“未设置”会保留Minecraft的普通材料。 +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=请使用准确的矿物词典名称,例如 oreSulfur。 +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/textures/gui/cog.png b/src/main/resources/assets/orespawn/textures/gui/cog.png new file mode 100644 index 00000000..e056ab0e Binary files /dev/null and b/src/main/resources/assets/orespawn/textures/gui/cog.png differ diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 3c95d137..1a85f3ea 100644 --- a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java @@ -44,6 +44,9 @@ class LocalizationParityTest { "de_au.lang", "de_de.lang", "es_es.lang", "es_mx.lang", "fr_ca.lang", "fr_fr.lang", "ja_jp.lang", "ko_kr.lang", "pt_br.lang", "ru_ru.lang", "zh_cn.lang")) + .put("button.orespawn.biomes_world_materials", ImmutableSet.of( + "fr_ca.lang", "fr_fr.lang")) + .put("screen.orespawn.biomes", ImmutableSet.of("fr_ca.lang", "fr_fr.lang")) .put("option.orespawn.mod_filter", ImmutableSet.of( "de_au.lang", "de_de.lang", "fr_ca.lang", "fr_fr.lang")) .put("tab.orespawn.biomes", ImmutableSet.of("fr_ca.lang", "fr_fr.lang")) @@ -84,7 +87,7 @@ class LocalizationParityTest { void everyLocaleMatchesEnglishKeysAndFormatting() throws Exception { JsonObject english = read(LANG_DIR.resolve("en_US.lang")); Set englishKeys = JsonCopies.keys(english); - assertEquals(357, englishKeys.size(), + 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 3d745cc3..d8788177 100644 --- a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -20,11 +20,19 @@ void releaseAndHostedWorkflowContractsRemainTargetNative() throws Exception { properties.load(input); } assertEquals("zone.moddev.mc.orespawn", properties.getProperty("mod_group")); + assertEquals("OreSpawn", properties.getProperty("mod_artifact")); + assertEquals("Minecraft Mod Development", properties.getProperty("mod_vendor")); - String build = new String(Files.readAllBytes(Paths.get("build.gradle")), StandardCharsets.UTF_8); - assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); - assertTrue(build.contains("generatePomFileForMavenJavaPublication")); - assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + String build = readFile("build.gradle"); + String publishing = readFile("gradle/release/publishing.gradle"); + String artifacts = readFile("gradle/release/artifacts.gradle"); + assertTrue(build.contains("apply from: 'gradle/release/publishing.gradle'")); + assertTrue(build.contains("apply from: 'gradle/verification/legacy-and-migration.gradle'")); + assertTrue(publishing.contains("tasks.register('verifyMavenCoordinates')")); + assertTrue(publishing.contains("generatePomFileForMavenJavaPublication")); + assertTrue(publishing.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + assertTrue(build.contains("'Implementation-Vendor' : project.mod_vendor")); + assertTrue(artifacts.contains("manifest.getValue('Implementation-Vendor') != project.mod_vendor")); assertTrue(build.contains("expectedMavenCoordinate")); assertFalse(build.contains("Mavenizer compatibility")); assertTrue(build.contains("args 'nogui'")); @@ -51,8 +59,11 @@ void releaseAndHostedWorkflowContractsRemainTargetNative() throws Exception { } private static String readWorkflow(String name) throws Exception { - return new String(Files.readAllBytes(Paths.get(".github", "workflows", name)), - StandardCharsets.UTF_8); + return readFile(".github/workflows/" + name); + } + + private static String readFile(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); } private static void assertPinnedToolchains(String workflow, int expectedJobs, int expectedPathUses) { diff --git a/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java b/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java index b0d95cfc..4a6e7c86 100644 --- a/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java @@ -111,6 +111,7 @@ class TooltipAlignmentTest { "tooltip.orespawn.main.biomes_materials", "tooltip.orespawn.main.advanced", "tooltip.orespawn.main.fluid_editor", + "tooltip.orespawn.main.mods", "tooltip.orespawn.manage_vanilla_ores", "tooltip.orespawn.fluid_deposits", "tooltip.orespawn.advanced.formations", diff --git a/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java b/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java new file mode 100644 index 00000000..699eb3a7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java @@ -0,0 +1,21 @@ +package zone.moddev.mc.orespawn.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +class GeologySamplerSourceContractTest { + @Test + void samplingUsesTheBiomeProviderWithoutLoadingAChunk() throws Exception { + String source = new String(Files.readAllBytes(Paths.get( + "src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java")), + StandardCharsets.UTF_8); + assertTrue(source.contains("level.provider.getBiomeProvider().getBiome(position)")); + assertFalse(source.contains("level.getBiome(position)")); + } +} 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..e7ff2f18 --- /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.util.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 c4bc6d13..611e6074 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -37,18 +37,20 @@ void terrainHostContractRetainsLegacyNaturalSourceOrder() { } @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("examplemod:ordinary_veins")) .heightDistribution(OreHeightDistribution.BOTTOM_TRIANGLE) .discardChanceOnAirExposure(0.75D) .hostFamily(GeologyFamily.METAMORPHIC) @@ -58,19 +60,22 @@ 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") .getAsJsonObject("examplemod:rock/examplemod/slate").get("block").getAsString()); assertTrue(json.getAsJsonObject("ores").has("examplemod:ore/examplemod/tin_ore")); JsonObject ore = json.getAsJsonObject("ores").getAsJsonObject("examplemod:ore/examplemod/tin_ore"); + assertEquals("orespawn:tin", ore.get("material").getAsString()); assertEquals(2, ore.getAsJsonArray("outputs").size()); assertFalse(ore.get("retrogen").getAsBoolean()); assertEquals(0.75D, ore.getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") .getAsJsonArray("host_blocks").get(0).getAsJsonObject().get("weight").getAsDouble()); assertEquals("bottom_triangle", ore.getAsJsonObject("dimensions") .getAsJsonObject("minecraft:overworld").get("height_distribution").getAsString()); + assertEquals("examplemod:ordinary_veins", ore.getAsJsonObject("dimensions") + .getAsJsonObject("minecraft:overworld").get("placement_channel").getAsString()); assertEquals(0.75D, ore.getAsJsonObject("dimensions") .getAsJsonObject("minecraft:overworld") .get("discard_chance_on_air_exposure").getAsDouble()); @@ -81,6 +86,23 @@ void serializesTypedSchemaFourProvider() { .has("examplemod:rock/examplemod/slate")); } + @Test + void derivesStablePlacementChannelsAndExposesImmutableMaterial() { + WorldgenProvider.OreDimensionDefinition standard = WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")).build(); + WorldgenProvider.OreDimensionDefinition custom = WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")) + .pattern(id("realisticdeposits:stratiform"), new JsonObject()) + .hostBlock(id("minecraft:stone")).build(); + WorldgenProvider.OreDefinition ore = WorldgenProvider.OreDefinition + .builder(id("examplemod:iron"), id("examplemod:iron_ore")) + .material(id("orespawn:iron")).dimension(standard).build(); + + assertEquals(id("orespawn:iron"), ore.material().get()); + assertEquals(id("orespawn:standard"), standard.placementChannel()); + assertEquals(id("realisticdeposits:stratiform"), custom.placementChannel()); + } + @Test void serializesCompleteDeclarativeProviderSurface() { ResourceLocation dimension = id("minecraft:the_end"); @@ -194,6 +216,45 @@ void serializesCodecBackedPatternWithoutLeakingMutableSettings() { assertEquals(12, pattern.getAsJsonObject("settings").get("radius").getAsInt()); } + @Test + void serializesBackgroundControllerAndExistingWorldMergePolicy() { + WorldgenProvider.OreDimensionDefinition placement = WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")) + .hostBlock(id("minecraft:stone")) + .backgroundGenerationScale(0.25D) + .build(); + WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) + .mergeNewEntriesIntoExistingWorlds(false) + .ore(id("examplemod:iron_controller"), id("minecraft:iron_ore"), ore -> ore + .retrogen(false).dimension(placement)) + .build(); + + assertFalse(provider.mergeNewEntriesIntoExistingWorlds()); + assertEquals(0.25D, placement.backgroundGenerationScale().getAsDouble()); + JsonObject json = provider.toJson(); + assertFalse(json.get("merge_new_entries_into_existing_worlds").getAsBoolean()); + assertEquals(0.25D, json.getAsJsonObject("ores") + .getAsJsonObject("examplemod:iron_controller") + .getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") + .get("background_generation_scale").getAsDouble()); + } + + @Test + void backgroundControllerDefaultsAreNonControllingAndBoundsAreValidated() { + WorldgenProvider.OreDimensionDefinition ordinary = WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")).build(); + assertFalse(ordinary.backgroundGenerationScale().isPresent()); + assertTrue(WorldgenProvider.builder("examplemod", 1) + .ore(id("examplemod:ordinary"), id("minecraft:iron_ore"), ore -> ore.dimension(ordinary)) + .build().mergeNewEntriesIntoExistingWorlds()); + assertThrows(IllegalStateException.class, () -> WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")) + .backgroundGenerationScale(-0.01D).build()); + assertThrows(IllegalStateException.class, () -> WorldgenProvider.OreDimensionDefinition + .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")) + .backgroundGenerationScale(1.01D).build()); + } + @Test void rejectsDefinitionsOutsideProviderNamespace() { assertThrows(IllegalStateException.class, () -> WorldgenProvider.builder("examplemod", 1) 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..2b5c078a --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java @@ -0,0 +1,124 @@ +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 net.minecraft.client.gui.GuiScreen; +import net.minecraft.util.ResourceLocation; + +class WorldSettingsExtensionRegistryTest { + @Test + void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() { + WorldSettingsExtension first = register("order_first", "settings", parent -> new GuiScreen() { }); + WorldSettingsExtension second = register("order_second", "settings", parent -> new GuiScreen() { }); + 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 GuiScreen() { }); + assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register( + id("duplicate_owner", "second"), + "button.realisticdeposits.other", parent -> new GuiScreen() { })); + assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register( + id("blank_key", "settings"), " ", parent -> new GuiScreen() { })); + 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().getResourceDomain()); + 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<>(); + GuiScreen expectedParent = new GuiScreen() { }; + GuiScreen expectedChild = new GuiScreen() { }; + 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() { + GuiScreen parent = new GuiScreen() { }; + 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 TextComponentTranslation(\"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..5a3b23a7 --- /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.Forge12TestBootstrap; + +import net.minecraft.init.Biomes; + +class BiomeDirectoryModelTest { + @BeforeAll + static void registerVanilla() { + Forge12TestBootstrap.registerVanilla(); + } + + @Test + void derivesVanillaRoutineDimensionsFromForgeBiomeTypes() { + assertEquals("minecraft:overworld", BiomeDirectoryModel.routineDimension(Biomes.BEACH)); + assertEquals("minecraft:the_nether", BiomeDirectoryModel.routineDimension(Biomes.HELL)); + assertEquals("minecraft:the_end", BiomeDirectoryModel.routineDimension(Biomes.SKY)); + } + + @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..d0899a71 --- /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 geometryFitsBothSupportedOreSourceSizes() { + 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)); + assertEquals(5, CompactScrollList.firstFromThumb(54, 54, 5)); + } + + @Test + void wheelArrowsTrackAndRowsOperateIndependently() { + ProbeList list = new ProbeList(); + assertTrue(list.mouseWheel(20, 30, -120)); + assertEquals(3, list.firstIndex()); + assertFalse(list.mouseWheel(2, 2, -120)); + + list.mousePressed(null, 105, 83); // lower arrow + assertEquals(4, list.firstIndex()); + list.mousePressed(null, 105, 65); // track below thumb: one visible page + assertEquals(8, list.firstIndex()); + + list.setFirstIndex(0); + list.mousePressed(null, 20, 53); + list.press(); + assertEquals(2, list.selected); + list.mousePressed(null, 95, 21); + list.press(); + assertEquals(0, list.action); + list.setFirstIndex(20); + list.ensureVisible(5); + assertEquals(5, list.firstIndex()); + list.ensureVisible(12); + assertEquals(9, list.firstIndex()); + } + + 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/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index 89579826..223ff6da 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -2,6 +2,7 @@ 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; @@ -9,10 +10,18 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import com.google.gson.JsonObject; 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.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; + +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; +import net.minecraft.init.Blocks; +import net.minecraft.item.ItemStack; +import net.minecraftforge.oredict.OreDictionary; import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; @@ -20,6 +29,18 @@ class GeologyEditorSessionTest { @BeforeAll static void bootstrapMinecraft() { Forge12TestBootstrap.registerVanilla(); + ItemStack diamond = new ItemStack(Blocks.DIAMOND_ORE); + boolean registered = false; + for (int id : OreDictionary.getOreIDs(diamond)) { + registered |= "oreDiamond".equals(OreDictionary.getOreName(id)); + } + if (!registered) OreDictionary.registerOre("oreDiamond", diamond); + ItemStack emerald = new ItemStack(Blocks.EMERALD_ORE); + registered = false; + for (int id : OreDictionary.getOreIDs(emerald)) { + registered |= "oreEmerald".equals(OreDictionary.getOreName(id)); + } + if (!registered) OreDictionary.registerOre("oreEmerald", emerald); } @Test @@ -104,6 +125,72 @@ void providerFluidRemovalLeavesATombstoneButLocalRulesAreDeleted() { assertFalse(session.section("fluid_deposits").has("pack:fluid_deposit/test")); } + @Test + void missingProviderAssetsRemainDormantWithoutBlockingTheEditor() { + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); + session.configureDefaultVanillaStrata(); + + JsonObject rock = new JsonObject(); + rock.addProperty("family", "igneous_volcanic"); + rock.addProperty("weight", 1.0D); + rock.addProperty("enabled", true); + rock.addProperty("block", "missingmod:basalt"); + markOrphanedProvider(rock); + session.section("rocks").add("missingmod:rock/basalt", rock); + + JsonObject ore = new JsonObject(); + ore.addProperty("block", "missingmod:ore"); + ore.addProperty("enabled", true); + JsonObject oreRule = GeologyEditorSession.defaultOreDimension(); + JsonArray oreHosts = new JsonArray(); + oreHosts.add(new JsonPrimitive("minecraft:stone")); + oreRule.add("host_blocks", oreHosts); + JsonObject oreDimensions = new JsonObject(); + oreDimensions.add("minecraft:overworld", oreRule); + ore.add("dimensions", oreDimensions); + markOrphanedProvider(ore); + session.section("ores").add("missingmod:ore/test", ore); + + String depositId = session.assignFluidDeposit("minecraft:water"); + JsonObject deposit = session.section("fluid_deposits").remove(depositId).getAsJsonObject(); + deposit.addProperty("block", "missingmod:fluid"); + markOrphanedProvider(deposit); + session.section("fluid_deposits").add("missingmod:fluid_deposit/test", deposit); + + JsonObject terrain = new JsonObject(); + terrain.addProperty("enabled", true); + JsonArray terrainHosts = new JsonArray(); + terrainHosts.add(new JsonPrimitive("missingmod:host")); + terrain.add("host_blocks", terrainHosts); + markOrphanedProvider(terrain); + session.section("terrain_dimensions").add("missingmod:dimension", terrain); + + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", true); + JsonObject paletteBiomes = new JsonObject(); + JsonObject placement = new JsonObject(); + placement.addProperty("weight", 1.0D); + paletteBiomes.add("missingmod:biome", placement); + palette.add("biomes", paletteBiomes); + markOrphanedProvider(palette); + session.section("biome_palettes").add("missingmod:palette", palette); + + JsonObject materials = new JsonObject(); + materials.addProperty("dimension", "minecraft:overworld"); + materials.addProperty("default_fluid", "missingmod:fluid"); + materials.addProperty("snow_block", "missingmod:snow"); + markOrphanedProvider(materials); + session.section("dimension_materials").add("missingmod:materials", materials); + + java.util.List errors = session.validate(); + assertTrue(errors.isEmpty(), errors.toString()); + + rock.remove("orphaned_provider"); + assertTrue(session.validate().contains("Invalid rock block: missingmod:basalt"), + "The same unresolved local definition must still block saving"); + } + @Test void rangedSelectorOreIsValidWithoutAnExplicitDimension() { GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); @@ -147,6 +234,14 @@ void standaloneFluidPickerCreatesAUsableCoveredOverworldRule() { assertTrue(errors.isEmpty(), errors.toString()); } + @Test + void fluidPickerRejectsDecorativeBlocksThatOnlyUseWaterMaterial() { + Block decorativeWaterBlock = new Block(Material.WATER) { }; + + assertFalse(GeologyEditorSession.isFluidBlock(decorativeWaterBlock)); + assertTrue(GeologyEditorSession.isFluidBlock(Blocks.WATER)); + } + @Test void namespacedGeomesCanBeAddedValidatedAndRoundTripped() { String geomeId = "cakeworld:cocoa_basin"; @@ -179,4 +274,661 @@ void namespacedGeomesCanBeAddedValidatedAndRoundTripped() { assertFalse(invalidSession.section("geomes").has("bad:upper")); assertTrue(invalidSession.section("geomes").has(geomeId)); } + + @Test + void oreSourceEditsStayInsideThePendingEditorSession() { + WorldGeologyProfile original = profileWithOreSourcePolicy(); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + GeologyEditorSession.OreSourceGroup group = session.oreSourceGroups().get(0); + assertEquals("keep_separate", group.mode); + assertThrows(UnsupportedOperationException.class, () -> group.candidates.clear()); + assertThrows(UnsupportedOperationException.class, + () -> group.outputs.put("example:other", 2.0D)); + + session.setOreSourceMode(group.key, true); + session.setOreSourceOutputMode(group.key, "custom"); + session.setOreSourcePlacement(group.key, "orespawn:standard", "mineralogy:sulfur"); + session.setOreSourceOutput(group.key, "mineralogy:sulfur", true, 3.5D); + GeologyEditorSession.OreSourceGroup edited = session.oreSourceGroups().get(0); + assertEquals("consolidated", edited.mode); + assertEquals("mineralogy:sulfur", edited.placements.get("orespawn:standard")); + assertEquals(3.5D, edited.outputs.get("mineralogy:sulfur").doubleValue()); + assertEquals(before, original.rootCopy(), "editing must not persist the profile early"); + assertFalse(before.equals(session.profile().rootCopy())); + } + + @Test + void equivalentPlacementRulesAreHiddenWithoutChangingThePendingProfile() { + JsonObject root = WorldGeologyProfile.recommended(false).toJson(); + JsonObject policy = new JsonObject(); + policy.addProperty("material", "orespawn:gold"); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("status", "review_required"); + policy.addProperty("review_required", true); + JsonObject outputs = new JsonObject(); + outputs.addProperty("minecraft:gold_ore", 1.0D); + outputs.addProperty("orespawn:vanilla_gold_badlands", 1.0D); + policy.add("outputs", outputs); + JsonObject placements = new JsonObject(); + placements.addProperty("orespawn:standard", "minecraft:gold_ore"); + policy.add("placement_sources", placements); + JsonArray candidates = new JsonArray(); + candidates.add(candidate("minecraft:gold_ore", "minecraft", "minecraft:gold_ore", + "orespawn:standard", false)); + candidates.add(candidate("orespawn:vanilla_gold_badlands", "minecraft", "minecraft:gold_ore", + "orespawn:standard", false)); + policy.add("candidates", candidates); + JsonObject policies = new JsonObject(); + policies.add("orespawn:gold|minecraft:overworld", policy); + root.add("ore_source_policies", policies); + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + GeologyEditorSession session = new GeologyEditorSession(profile); + JsonObject before = session.profile().rootCopy(); + + assertEquals(1, session.oreSourceGroups().size()); + assertTrue(session.oreSourceGroups().get(0).isRedundantSeparatePolicy()); + assertEquals(before, session.profile().rootCopy(), + "Listing a harmless group must not rewrite the pending profile"); + } + + @Test + void customMaterialGroupsKeepStableIdsAndRequireConfirmedAliasMoves() { + WorldGeologyProfile original = profileWithOreSourcePolicy(); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Volcanogenic Sulphides"); + assertTrue(session.addOreMaterialAlias(material, "oreCopperZinc", false)); + assertFalse(session.addOreMaterialAlias(material, "oreSulfur", false)); + assertTrue(session.addOreMaterialAlias(material, "oreSulfur", true)); + + assertEquals(material, session.oreDictionaryOwner("oreSulfur")); + assertEquals("Volcanogenic Sulphides", session.oreSourceGroups().stream() + .filter(group -> material.equals(group.material)).findFirst().get().displayName); + assertTrue(session.oreMaterialGroupsChanged()); + assertEquals(before, original.rootCopy(), "pending group edits must not persist before main Done"); + + assertTrue(session.dissolveOreMaterialGroup(material)); + assertEquals("orespawn:sulfur", session.oreDictionaryOwner("oreSulfur")); + assertEquals("orespawn:copperzinc", session.oreDictionaryOwner("oreCopperZinc")); + assertFalse(key.equals(session.addOreMaterialGroup()), + "dissolved group IDs remain reserved by their dormant policy"); + session.resetOreMaterialGroup("orespawn:sulfur"); + assertEquals("orespawn:sulfur", session.oreDictionaryOwner("oreSulfur")); + } + + @Test + void manageVanillaToggleRebakesNativePlacementSourcesInsideTheEditor() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativeDiamondOre()); + GeologyEditorSession.OreSourceGroup disabled = group(session, "orespawn:diamond"); + assertFalse(session.profile().manageVanillaOres()); + assertFalse(disabled.hasManagedPlacementSource()); + assertTrue(disabled.placements.isEmpty()); + + session.setManageVanillaOres(true); + GeologyEditorSession.OreSourceGroup enabled = group(session, "orespawn:diamond"); + assertTrue(session.profile().manageVanillaOres()); + assertTrue(enabled.hasManagedPlacementSource()); + assertEquals("minecraft:diamond_ore", enabled.placements.get("orespawn:standard")); + + session.setManageVanillaOres(false); + GeologyEditorSession.OreSourceGroup disabledAgain = group(session, "orespawn:diamond"); + assertFalse(disabledAgain.hasManagedPlacementSource()); + assertTrue(disabledAgain.placements.isEmpty()); + } + + @Test + void vanillaAliasMoveRequiresVanillaManagementAndNeverChangesOwnershipWhenBlocked() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativeDiamondOre()); + String customKey = session.addOreMaterialGroup(); + String customMaterial = customKey.substring(0, customKey.indexOf('|')); + + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + assertTrue(session.vanillaOreManagementRequiredForAliasMove(customMaterial, "oreDiamond")); + assertFalse(session.addOreMaterialAlias(customMaterial, "oreDiamond", true)); + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + + session.setManageVanillaOres(true); + assertFalse(session.vanillaOreManagementRequiredForAliasMove(customMaterial, "oreDiamond")); + assertTrue(session.addOreMaterialAlias(customMaterial, "oreDiamond", true)); + assertEquals(customMaterial, session.oreDictionaryOwner("oreDiamond")); + assertTrue(group(session, customMaterial).hasManagedPlacementSource()); + } + + @Test + void managedVanillaAliasesBuildOneLiveReviewWithoutEmptyOrStaleOutputs() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativePreciousOres()); + session.setManageVanillaOres(true); + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Precious Stones"); + + assertTrue(session.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(session.addOreMaterialAlias(material, "oreEmerald", true)); + + GeologyEditorSession.OreSourceGroup review = group(session, material); + assertEquals(Arrays.asList("oreDiamond", "oreEmerald"), review.oreDictionaryEntries); + assertEquals(2, review.outputCandidates().size()); + assertEquals(2, review.outputs.size(), "Keep Original must expose both moved outputs immediately"); + assertEquals("minecraft:diamond_ore", review.placements.get("orespawn:standard")); + assertEquals(2, OreSourceListScreen.placementCandidates(review, "orespawn:standard").size()); + assertTrue(review.needsReview()); + assertEquals(0xFF5555, OreSourceListScreen.groupRowColor(review)); + + session.acceptOreSourcePolicy(key); + GeologyEditorSession.OreSourceGroup accepted = group(session, material); + assertFalse(accepted.needsAttention()); + assertEquals(0xFFFF55, OreSourceListScreen.groupRowColor(accepted)); + + String unrelated = session.addOreMaterialGroup(); + String unrelatedMaterial = unrelated.substring(0, unrelated.indexOf('|')); + assertTrue(session.addOreMaterialAlias(unrelatedMaterial, "oreCopperZinc", false)); + assertFalse(group(session, material).needsReview(), + "editing another group must not reopen an accepted Precious Stones policy"); + + session.setOreSourceOutputMode(key, "balanced"); + GeologyEditorSession.OreSourceGroup balanced = group(session, material); + assertEquals("consolidated", balanced.mode); + assertEquals(2, balanced.outputs.size()); + assertEquals(1, balanced.placements.size()); + assertFalse(balanced.needsAttention()); + } + + @Test + void customAliasOwnershipSurvivesGlobalReloadWithoutReopeningOldGroups() { + WorldGeologyProfile original = profileWithNativePreciousOres(); + GeologyEditorSession session = new GeologyEditorSession(original); + session.setManageVanillaOres(true); + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Precious Stones"); + assertTrue(session.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(session.addOreMaterialAlias(material, "oreEmerald", true)); + + WorldGeologyProfile reloaded = WorldGeologyProfile.fromGlobalConfig( + session.profile().rootCopy(), session.profile().geologyMode(), + session.profile().placeFluidDeposits()); + GeologyEditorSession reopened = new GeologyEditorSession(reloaded); + + assertFalse(reopened.oreSourceGroups().stream() + .anyMatch(group -> "orespawn:diamond".equals(group.material))); + assertFalse(reopened.oreSourceGroups().stream() + .anyMatch(group -> "orespawn:emerald".equals(group.material))); + GeologyEditorSession.OreSourceGroup restored = group(reopened, material); + assertEquals(2, restored.outputCandidates().size()); + assertEquals(Arrays.asList("oreDiamond", "oreEmerald"), restored.oreDictionaryEntries); + assertTrue(reloaded.rootCopy().getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:diamond|minecraft:overworld") + .get("dormant").getAsBoolean()); + assertTrue(reloaded.rootCopy().getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:emerald|minecraft:overworld") + .get("dormant").getAsBoolean()); + } + + @Test + void resetAllOreSourcesRestoresBuiltInGroupsAndKeepsTheChangePending() { + GeologyEditorSession setup = new GeologyEditorSession(profileWithNativePreciousOres()); + setup.setManageVanillaOres(true); + String key = setup.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + setup.renameOreMaterialGroup(material, "Precious Stones"); + assertTrue(setup.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(setup.addOreMaterialAlias(material, "oreEmerald", true)); + WorldGeologyProfile savedCustomProfile = setup.profile(); + JsonObject savedCustomJson = savedCustomProfile.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(savedCustomProfile); + + session.resetOreSourcesToDefaults(); + + assertTrue(session.profile().manageVanillaOres()); + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + assertEquals("orespawn:emerald", session.oreDictionaryOwner("oreEmerald")); + assertFalse(session.oreSourceGroups().stream() + .anyMatch(group -> material.equals(group.material))); + assertEquals(1, group(session, "orespawn:diamond").outputCandidates().size()); + assertEquals(1, group(session, "orespawn:emerald").outputCandidates().size()); + assertTrue(session.oreMaterialGroupsChanged()); + assertEquals(savedCustomJson, savedCustomProfile.rootCopy(), + "Reset All must remain pending until the main editor saves it"); + } + + @Test + void customGroupRemovalDeletesOnlyEmptyGroupsAndDissolvesPopulatedGroups() { + GeologyEditorSession session = new GeologyEditorSession(profileWithNativePreciousOres()); + session.setManageVanillaOres(true); + + String emptyKey = session.addOreMaterialGroup(); + String emptyMaterial = emptyKey.substring(0, emptyKey.indexOf('|')); + assertTrue(session.deleteEmptyOreMaterialGroup(emptyMaterial)); + assertFalse(session.oreSourceGroups().stream() + .anyMatch(group -> emptyMaterial.equals(group.material))); + + String key = session.addOreMaterialGroup(); + String material = key.substring(0, key.indexOf('|')); + session.renameOreMaterialGroup(material, "Precious Stones"); + assertTrue(session.addOreMaterialAlias(material, "oreDiamond", true)); + assertTrue(session.addOreMaterialAlias(material, "oreEmerald", true)); + assertFalse(session.deleteEmptyOreMaterialGroup(material)); + assertFalse(session.deleteEmptyOreMaterialGroup("orespawn:diamond"), + "discovered groups are facts rather than deletable custom records"); + + assertTrue(session.dissolveOreMaterialGroup(material)); + assertEquals("orespawn:diamond", session.oreDictionaryOwner("oreDiamond")); + assertEquals("orespawn:emerald", session.oreDictionaryOwner("oreEmerald")); + assertTrue(group(session, "orespawn:diamond").hasManagedPlacementSource()); + assertTrue(group(session, "orespawn:emerald").hasManagedPlacementSource()); + assertFalse(session.oreSourceGroups().stream() + .anyMatch(group -> material.equals(group.material))); + assertFalse(key.equals(session.addOreMaterialGroup()), + "dissolved group IDs remain reserved by their dormant policy"); + } + + @Test + void liveGroupReconciliationRetainsSelectedMissingSourcesForDeterministicRestoration() { + JsonObject root = profileWithTwoOreOutputs().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + JsonObject missing = policy.getAsJsonArray("candidates").get(1).getAsJsonObject(); + missing.addProperty("loaded", false); + missing.addProperty("placement_active", false); + policy.getAsJsonObject("outputs").entrySet().clear(); + policy.getAsJsonObject("outputs").addProperty("baseminerals:sulfur", 2.0D); + policy.getAsJsonObject("placement_sources") + .addProperty("orespawn:standard", "baseminerals:sulfur"); + + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false))); + String customKey = session.addOreMaterialGroup(); + String customMaterial = customKey.substring(0, customKey.indexOf('|')); + session.renameOreMaterialGroup(customMaterial, "Unrelated Group"); + + GeologyEditorSession.OreSourceGroup sulfur = group(session, "orespawn:sulfur"); + assertEquals(2.0D, sulfur.outputs.get("baseminerals:sulfur").doubleValue()); + assertEquals("baseminerals:sulfur", sulfur.placements.get("orespawn:standard")); + assertEquals("missing_source", sulfur.status); + } + + @Test + void balancedSingleAndCustomModesKeepOneExplicitOutputPolicy() { + GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); + String key = session.oreSourceGroups().get(0).key; + GeologyEditorSession.OreSourceGroup initial = session.oreSourceGroups().get(0); + assertTrue(initial.oreDictionaryEntries.contains("oreSulfur")); + assertTrue(initial.oreDictionaryEntries.contains("oreSulphur")); + assertEquals(2, initial.outputCandidates().size(), + "the selected group exposes the union of candidates from all aliases"); + + session.setOreSourceOutputMode(key, "balanced"); + GeologyEditorSession.OreSourceGroup balanced = session.oreSourceGroups().get(0); + assertEquals(2, balanced.outputs.size()); + assertTrue(balanced.outputs.values().stream().allMatch(weight -> weight == 1.0D)); + assertFalse(balanced.needsAttention(), + "a saved consolidated rule must no longer be marked for review"); + + session.setOreSourceOutputMode(key, "single"); + GeologyEditorSession.OreSourceGroup single = session.oreSourceGroups().get(0); + assertEquals(1, single.outputs.size()); + assertTrue(single.outputs.containsKey("mineralogy:sulfur"), + "Single initially follows the reviewed placement priority"); + + session.setOreSourceOutputMode(key, "custom"); + session.setOreSourceOutput(key, "baseminerals:sulfur", true, 2.5D); + session.setOreSourceOutput(key, "mineralogy:sulfur", false, 1.0D); + GeologyEditorSession.OreSourceGroup custom = session.oreSourceGroups().get(0); + assertEquals(1, custom.outputs.size()); + assertEquals(2.5D, custom.outputs.get("baseminerals:sulfur").doubleValue()); + } + + @Test + void outputOnlyCustomGroupCannotEnterAnInvalidBalancedPolicy() { + JsonObject root = profileWithTwoOreOutputs().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + policy.getAsJsonObject("placement_sources").entrySet().clear(); + for (JsonElement element : policy.getAsJsonArray("candidates")) { + element.getAsJsonObject().addProperty("placement_active", false); + } + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + policy.addProperty("review_required", true); + policy.addProperty("status", "review_required"); + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.fromJson( + root, WorldGeologyProfile.recommended(false))); + + session.setOreSourceOutputMode("orespawn:sulfur|minecraft:overworld", "balanced"); + + GeologyEditorSession.OreSourceGroup group = session.oreSourceGroups().get(0); + assertEquals("keep_separate", group.mode); + assertEquals("custom", group.outputMode); + assertTrue(group.needsReview(), "Keep Original remains available for explicit review"); + assertFalse(group.hasManagedPlacementSource()); + } + + @Test + void keepOriginalRestoresThePolicySnapshotFromWhenTheEditorOpened() { + GeologyEditorSession session = new GeologyEditorSession(profileWithTwoOreOutputs()); + GeologyEditorSession.OreSourceGroup original = session.oreSourceGroups().get(0); + String key = original.key; + + session.setOreSourceOutputMode(key, "custom"); + session.setOreSourceOutput(key, "mineralogy:sulfur", false, 1.0D); + session.setOreSourceOutput(key, "baseminerals:sulfur", true, 4.5D); + session.setOreSourcePlacement(key, "orespawn:standard", "baseminerals:sulfur"); + GeologyEditorSession.OreSourceGroup edited = session.oreSourceGroups().get(0); + assertEquals(java.util.Collections.singleton("baseminerals:sulfur"), edited.outputs.keySet()); + assertEquals("baseminerals:sulfur", edited.placements.get("orespawn:standard")); + + session.restoreOreSourceOriginalMode(key); + GeologyEditorSession.OreSourceGroup restored = session.oreSourceGroups().get(0); + assertEquals("keep_separate", restored.mode); + assertEquals(original.outputMode, restored.outputMode); + assertEquals(original.outputs, restored.outputs); + assertEquals(original.placements, restored.placements); + } + + @Test + void acceptingKeepOriginalClearsReviewWithoutChangingItsPolicy() { + WorldGeologyProfile originalProfile = profileWithTwoOreOutputs(); + GeologyEditorSession session = new GeologyEditorSession(originalProfile); + GeologyEditorSession.OreSourceGroup before = session.oreSourceGroups().get(0); + JsonObject persistedBefore = originalProfile.rootCopy(); + + assertTrue(before.needsReview()); + assertEquals("keep_separate", before.mode); + session.acceptOreSourcePolicy(before.key); + + GeologyEditorSession.OreSourceGroup accepted = session.oreSourceGroups().get(0); + assertFalse(accepted.needsReview()); + assertFalse(accepted.needsAttention()); + assertEquals("separate", accepted.status); + assertEquals(before.mode, accepted.mode); + assertEquals(before.outputMode, accepted.outputMode); + assertEquals(before.outputs, accepted.outputs); + assertEquals(before.placements, accepted.placements); + assertEquals(persistedBefore, originalProfile.rootCopy(), + "Accept remains pending until the main editor saves the profile"); + } + + @Test + void missingExternalDuplicateCannotOverrideItsLoadedManagedOutput() { + JsonObject root = profileWithTwoOreOutputs().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + JsonObject managed = policy.getAsJsonArray("candidates").get(1).getAsJsonObject(); + JsonObject external = zone.moddev.mc.orespawn.util.JsonCopies.copy(managed); + external.addProperty("source_id", "external/minecraft:gold_ore/0"); + external.addProperty("loaded", false); + external.addProperty("placement_active", false); + external.addProperty("external", true); + policy.getAsJsonArray("candidates").add(external); + policy.addProperty("review_required", false); + policy.addProperty("status", "external_generation"); + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + JsonObject before = profile.rootCopy(); + + GeologyEditorSession.OreSourceGroup group = + new GeologyEditorSession(profile).oreSourceGroups().get(0); + + assertEquals("separate", group.status); + assertFalse(group.needsAttention()); + assertEquals(before, profile.rootCopy(), "Deriving the visible status must not rewrite the profile"); + } + + @Test + void dimensionResetRestoresActiveProvidersRemovesLocalRulesAndPreservesMissingProviders() + throws Exception { + JsonObject defaults = new JsonObject(); + JsonObject defaultPalettes = new JsonObject(); + defaultPalettes.add("active:palette", biomePalette("active", "minecraft:plains", 1.0D)); + JsonObject defaultMaterials = new JsonObject(); + defaultMaterials.add("active:materials", dimensionMaterials("active", "minecraft:water")); + BiomeProviderDefaultsSnapshot snapshot = providerDefaults(defaultPalettes, defaultMaterials, + java.util.Collections.singleton("active")); + + JsonObject root = WorldGeologyProfile.recommended(false).rootCopy(); + JsonObject palettes = new JsonObject(); + palettes.add("active:palette", biomePalette("active", "minecraft:plains", 9.0D)); + palettes.add("user:palette", biomePalette("", "minecraft:desert", 1.0D)); + palettes.add("missing:palette", biomePalette("missing", "minecraft:forest", 3.0D)); + root.add("biome_palettes", palettes); + JsonObject materials = new JsonObject(); + materials.add("active:materials", dimensionMaterials("active", "minecraft:lava")); + materials.add("user:materials", dimensionMaterials("", "minecraft:lava")); + materials.add("missing:materials", dimensionMaterials("missing", "minecraft:lava")); + root.add("dimension_materials", materials); + BiomeReplacementRules.set(root, "minecraft:overworld", "minecraft:plains", "minecraft:desert"); + WorldGeologyProfile original = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + JsonObject originalBytes = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original, + java.util.Collections.singleton("minecraft:overworld"), snapshot); + + session.resetBiomeDimension("minecraft:overworld"); + + assertEquals(defaultPalettes.get("active:palette"), + session.section("biome_palettes").get("active:palette")); + assertFalse(session.section("biome_palettes").has("user:palette")); + assertTrue(session.section("biome_palettes").has("missing:palette")); + assertTrue(session.biomeReplacements("minecraft:overworld").isEmpty()); + assertEquals(defaultMaterials.get("active:materials"), + session.section("dimension_materials").get("active:materials")); + assertFalse(session.section("dimension_materials").has("user:materials")); + assertTrue(session.section("dimension_materials").has("missing:materials")); + assertEquals(originalBytes, original.rootCopy(), + "reset must stay pending until the main editor saves"); + } + + @Test + void selectedBiomeResetRestoresOnlyItsProviderPlacementsAndClearsItsOverride() + throws Exception { + JsonObject defaultPalettes = new JsonObject(); + defaultPalettes.add("active:palette", biomePalette("active", "minecraft:plains", 1.0D)); + BiomeProviderDefaultsSnapshot snapshot = providerDefaults(defaultPalettes, + new JsonObject(), java.util.Collections.singleton("active")); + JsonObject root = WorldGeologyProfile.recommended(false).rootCopy(); + JsonObject palettes = new JsonObject(); + palettes.add("active:palette", biomePalette("active", "minecraft:plains", 8.0D)); + JsonObject local = biomePalette("", "minecraft:plains", 4.0D); + local.getAsJsonObject("biomes").add("minecraft:desert", + biomePlacement(2.0D)); + palettes.add("user:palette", local); + root.add("biome_palettes", palettes); + BiomeReplacementRules.set(root, "minecraft:overworld", "minecraft:plains", "minecraft:desert"); + GeologyEditorSession session = new GeologyEditorSession( + WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)), + java.util.Collections.singleton("minecraft:overworld"), snapshot); + + session.resetBiome("minecraft:overworld", "minecraft:plains"); + + assertEquals(1.0D, session.biomePlacementByPalette("active:palette", "minecraft:plains") + .get("weight").getAsDouble()); + assertFalse(session.biomePaletteById("user:palette").getAsJsonObject("biomes") + .has("minecraft:plains")); + assertTrue(session.biomePaletteById("user:palette").getAsJsonObject("biomes") + .has("minecraft:desert")); + assertFalse(session.biomeReplacements("minecraft:overworld").containsKey("minecraft:plains")); + } + + @Test + void replacementRoundTripIsExactAndDoesNotMutateTheUnsavedProfile() { + WorldGeologyProfile original = WorldGeologyProfile.recommended(false); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + + session.replaceBiome("minecraft:overworld", "minecraft:plains", "minecraft:desert"); + WorldGeologyProfile saved = session.profile(); + WorldGeologyProfile reopened = WorldGeologyProfile.fromJson(saved.toJson(), + WorldGeologyProfile.recommended(false)); + + assertEquals("minecraft:desert", new GeologyEditorSession(reopened) + .biomeReplacements("minecraft:overworld").get("minecraft:plains")); + assertEquals(saved.toJson(), reopened.toJson()); + assertEquals(before, original.rootCopy(), "Cancel must retain the original bytes"); + } + + private static BiomeProviderDefaultsSnapshot providerDefaults(JsonObject palettes, + JsonObject materials, java.util.Set active) throws Exception { + java.lang.reflect.Constructor constructor = + BiomeProviderDefaultsSnapshot.class.getDeclaredConstructor( + JsonObject.class, JsonObject.class, java.util.Set.class); + constructor.setAccessible(true); + return constructor.newInstance(palettes, materials, active); + } + + private static JsonObject biomePalette(String owner, String biome, double weight) { + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", true); + if (!owner.isEmpty()) palette.addProperty("source_provider", owner); + JsonObject biomes = new JsonObject(); + biomes.add(biome, biomePlacement(weight)); + palette.add("biomes", biomes); + return palette; + } + + private static JsonObject biomePlacement(double weight) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", weight); + placement.add("similar_biomes", new JsonArray()); + placement.add("required_similar_biomes", new JsonArray()); + return placement; + } + + private static JsonObject dimensionMaterials(String owner, String fluid) { + JsonObject materials = new JsonObject(); + materials.addProperty("dimension", "minecraft:overworld"); + if (!owner.isEmpty()) materials.addProperty("source_provider", owner); + materials.addProperty("default_fluid", fluid); + return materials; + } + + private static WorldGeologyProfile profileWithOreSourcePolicy() { + JsonObject root = WorldGeologyProfile.recommended(false).toJson(); + JsonObject policy = new JsonObject(); + policy.addProperty("material", "orespawn:sulfur"); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("status", "review_required"); + policy.addProperty("review_required", true); + JsonObject outputs = new JsonObject(); + outputs.addProperty("mineralogy:sulfur", 1.0D); + policy.add("outputs", outputs); + policy.add("placement_sources", new JsonObject()); + JsonObject candidate = new JsonObject(); + candidate.addProperty("source_id", "mineralogy:sulfur"); + candidate.addProperty("owner", "mineralogy"); + candidate.addProperty("owner_name", "Mineralogy"); + candidate.addProperty("owner_version", "6.0.0"); + candidate.addProperty("registry_id", "minecraft:iron_ore"); + candidate.addProperty("metadata", 0); + candidate.addProperty("placement_channel", "orespawn:standard"); + candidate.addProperty("loaded", true); + candidate.addProperty("placement_active", true); + candidate.addProperty("external", false); + candidate.addProperty("enrichment", false); + candidate.addProperty("review_required", true); + candidate.addProperty("material_declared", false); + candidate.add("ore_dictionary", new JsonArray()); + JsonArray candidates = new JsonArray(); + candidates.add(candidate); + policy.add("candidates", candidates); + JsonObject policies = new JsonObject(); + policies.add("orespawn:sulfur|minecraft:overworld", policy); + root.add("ore_source_policies", policies); + return WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)); + } + + private static WorldGeologyProfile profileWithTwoOreOutputs() { + JsonObject root = profileWithOreSourcePolicy().rootCopy(); + JsonObject policy = root.getAsJsonObject("ore_source_policies") + .getAsJsonObject("orespawn:sulfur|minecraft:overworld"); + policy.getAsJsonObject("outputs").addProperty("baseminerals:sulfur", 1.0D); + policy.getAsJsonObject("placement_sources") + .addProperty("orespawn:standard", "mineralogy:sulfur"); + policy.getAsJsonArray("candidates").add(candidate("baseminerals:sulfur", + "baseminerals", "minecraft:gold_ore", "orespawn:standard", false)); + return WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false)); + } + + private static WorldGeologyProfile profileWithNativeDiamondOre() { + WorldGeologyProfile recommended = WorldGeologyProfile.recommended(false); + JsonObject root = recommended.rootCopy(); + root.addProperty("manage_vanilla_ores", false); + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("source_mod", "minecraft"); + ore.addProperty("native_generation", true); + ore.addProperty("block", "minecraft:diamond_ore"); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "vein"); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", rule); + ore.add("dimensions", dimensions); + JsonObject ores = new JsonObject(); + ores.add("minecraft:diamond_ore", ore); + root.add("ores", ores); + root.add("ore_source_policies", new JsonObject()); + return WorldGeologyProfile.fromGlobalConfig(root, + recommended.geologyMode(), recommended.placeFluidDeposits()); + } + + private static WorldGeologyProfile profileWithNativePreciousOres() { + WorldGeologyProfile diamond = profileWithNativeDiamondOre(); + JsonObject root = diamond.rootCopy(); + JsonObject emerald = new JsonObject(); + emerald.addProperty("enabled", true); + emerald.addProperty("source_mod", "minecraft"); + emerald.addProperty("native_generation", true); + emerald.addProperty("block", "minecraft:emerald_ore"); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "clusters"); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", rule); + emerald.add("dimensions", dimensions); + root.getAsJsonObject("ores").add("minecraft:emerald_ore", emerald); + root.add("ore_source_policies", new JsonObject()); + return WorldGeologyProfile.fromGlobalConfig(root, + diamond.geologyMode(), diamond.placeFluidDeposits()); + } + + private static GeologyEditorSession.OreSourceGroup group( + GeologyEditorSession session, String material) { + return session.oreSourceGroups().stream() + .filter(group -> material.equals(group.material)) + .findFirst().orElseThrow(() -> new AssertionError("Missing group " + material)); + } + + private static void markOrphanedProvider(JsonObject definition) { + definition.addProperty("source_provider", "missingmod"); + definition.addProperty("orphaned_provider", true); + } + + private static JsonObject candidate(String sourceId, String owner, String registryId, + String channel, boolean reviewRequired) { + JsonObject candidate = new JsonObject(); + candidate.addProperty("source_id", sourceId); + candidate.addProperty("owner", owner); + candidate.addProperty("owner_name", owner); + candidate.addProperty("owner_version", ""); + candidate.addProperty("registry_id", registryId); + candidate.addProperty("metadata", 0); + candidate.addProperty("placement_channel", channel); + candidate.addProperty("loaded", true); + candidate.addProperty("placement_active", true); + candidate.addProperty("external", false); + candidate.addProperty("enrichment", false); + candidate.addProperty("review_required", reviewRequired); + candidate.addProperty("material_declared", false); + candidate.add("ore_dictionary", new JsonArray()); + return candidate; + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java b/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java index a2f1f792..400abaf6 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/LegacyBlockTranslationTest.java @@ -1,6 +1,7 @@ package zone.moddev.mc.orespawn.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -13,7 +14,10 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; import net.minecraft.init.Blocks; +import net.minecraft.util.text.ITextComponent; import net.minecraft.util.text.TextComponentTranslation; import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; @@ -56,4 +60,16 @@ void vanillaFluidBlocksResolveThroughLegacyNameKeys() { assertEquals("tile.water.name", water.getKey()); assertEquals("missing:fluid", DialogTexts.blockName(null, "missing:fluid").getUnformattedText()); } + + @Test + void missingModBlockTranslationsFallBackToFriendlyRegistryNames() { + Block unnamedFluid = new Block(Material.WATER) { } + .setUnlocalizedName("null"); + ITextComponent label = DialogTexts.blockName(unnamedFluid, + "biomesoplenty:hot_spring_water", key -> false); + + assertEquals("Hot Spring Water", label.getUnformattedText()); + assertTrue(DialogTexts.blockName(Blocks.WATER, "minecraft:water", key -> true) + instanceof TextComponentTranslation); + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java new file mode 100644 index 00000000..0bc15e16 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSourcePlacementScreenTest.java @@ -0,0 +1,150 @@ +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 java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +class OreSourcePlacementScreenTest { + @Test + void placementRulesExcludeExternalMissingAndOutputOnlyCandidates() { + OreSourceCandidate managed = candidate("managed", "orespawn:standard", true, true, false); + OreSourceCandidate second = candidate("second", "orespawn:standard", true, true, false); + OreSourceCandidate external = candidate("external", "orespawn:standard", true, true, true); + OreSourceCandidate missing = candidate("missing", "orespawn:standard", false, true, false); + OreSourceCandidate outputOnly = candidate("output", "realisticdeposits:district", true, false, false); + OreSourceGroup group = group("consolidated", + Arrays.asList(managed, second, external, missing, outputOnly), + Collections.singletonMap("lost:channel", "gone")); + + List standard = OreSourceListScreen.placementCandidates( + group, "orespawn:standard"); + assertEquals(Arrays.asList(managed, second), standard); + assertTrue(OreSourceGroupSettingsScreen.placementSelectable(group, "orespawn:standard")); + assertFalse(OreSourceGroupSettingsScreen.placementSelectable(group, + "realisticdeposits:district")); + assertEquals(Arrays.asList("lost:channel", "orespawn:standard"), + OreSourceGroupSettingsScreen.placementChannels(group)); + } + + @Test + void keepOriginalAndSingleManagedSourcesRemainReadOnly() { + OreSourceCandidate first = candidate("first", "orespawn:standard", true, true, false); + OreSourceCandidate second = candidate("second", "orespawn:standard", true, true, false); + assertFalse(OreSourceGroupSettingsScreen.placementSelectable( + group("consolidated", Collections.singletonList(first), Collections.emptyMap()), + "orespawn:standard")); + assertFalse(OreSourceGroupSettingsScreen.placementSelectable( + group("keep_separate", Arrays.asList(first, second), Collections.emptyMap()), + "orespawn:standard")); + assertTrue(group("keep_separate", Collections.singletonList(first), + Collections.emptyMap()).hasManagedPlacementSource()); + OreSourceCandidate outputOnly = candidate("output", "orespawn:standard", + true, false, false); + assertFalse(group("keep_separate", Collections.singletonList(outputOnly), + Collections.emptyMap()).hasManagedPlacementSource()); + } + + @Test + void customGroupActionsAndPlacementChoiceCountsDescribeTheCurrentState() { + OreSourceCandidate first = candidate("first", "orespawn:standard", true, true, false); + OreSourceCandidate second = candidate("second", "orespawn:standard", true, true, false); + OreSourceGroup empty = customGroup(Collections.emptyList(), Collections.emptyList()); + OreSourceGroup populated = customGroup(Collections.singletonList("oreTest"), + Arrays.asList(first, second)); + OreSourceGroup discovered = group("keep_separate", "review_required", + Collections.singletonList("oreTest"), Arrays.asList(first, second)); + + assertTrue(empty.isCustom()); + assertTrue(empty.canDeleteEmpty()); + assertFalse(empty.canDissolve()); + assertTrue(populated.isCustom()); + assertFalse(populated.canDeleteEmpty()); + assertTrue(populated.canDissolve()); + assertFalse(discovered.isCustom()); + assertFalse(discovered.canDeleteEmpty()); + assertFalse(discovered.canDissolve()); + } + + @Test + void groupColoursSeparateRoutineResolvedAndUnresolvedEntries() { + OreSourceCandidate first = candidate("first", "first:ore", "orespawn:standard", + true, true, false); + OreSourceCandidate second = candidate("second", "second:ore", "orespawn:standard", + true, true, false); + OreSourceGroup routine = group("keep_separate", "separate", + Collections.singletonList("oreIron"), Collections.singletonList(first)); + OreSourceGroup resolved = group("consolidated", "consolidated", + Arrays.asList("oreSulfur", "oreSulphur"), Arrays.asList(first, second)); + OreSourceGroup unresolved = group("keep_separate", "review_required", + Arrays.asList("oreSulfur", "oreSulphur"), Arrays.asList(first, second)); + OreSourceCandidate externalCandidate = candidate("external", "external:ore", + "external:uncontrolled", true, false, true); + OreSourceGroup external = group("keep_separate", "external_generation", + Arrays.asList("oreSulfur", "oreSulphur"), + Arrays.asList(first, externalCandidate)); + + assertTrue(routine.isRoutineSingleSource()); + assertFalse(OreSourceListScreen.showByDefault(routine)); + assertEquals(0x55FF55, OreSourceListScreen.groupRowColor(routine)); + assertFalse(resolved.needsAttention()); + assertTrue(OreSourceListScreen.showByDefault(resolved)); + assertEquals(0xFFFF55, OreSourceListScreen.groupRowColor(resolved)); + assertTrue(unresolved.needsAttention()); + assertTrue(unresolved.needsReview()); + assertFalse(resolved.needsReview()); + assertTrue(OreSourceListScreen.showByDefault(unresolved)); + assertEquals(0xFF5555, OreSourceListScreen.groupRowColor(unresolved)); + assertFalse(external.needsAttention(), + "An informational external generator must not look like an unresolved decision"); + assertFalse(external.needsReview()); + assertTrue(OreSourceListScreen.showByDefault(external)); + assertEquals(0xFFFF55, OreSourceListScreen.groupRowColor(external)); + } + + private static OreSourceCandidate candidate(String source, String channel, + boolean loaded, boolean active, boolean external) { + return candidate(source, "owner:ore", channel, loaded, active, external); + } + + private static OreSourceCandidate candidate(String source, String registry, String channel, + boolean loaded, boolean active, boolean external) { + return new OreSourceCandidate(source, "owner", "Owner", "1", registry, 0, + channel, Collections.singletonList("oreTest"), loaded, active, + external, false, false); + } + + private static OreSourceGroup group(String mode, List candidates, + java.util.Map placements) { + return new OreSourceGroup("orespawn:test|minecraft:overworld", "orespawn:test", + "minecraft:overworld", "Test", Collections.singletonList("oreTest"), false, + mode, "balanced", "consolidated", candidates, + Collections.unmodifiableMap(new LinkedHashMap<>()), + Collections.unmodifiableMap(new LinkedHashMap<>(placements))); + } + + private static OreSourceGroup group(String mode, String status, List aliases, + List candidates) { + return new OreSourceGroup("orespawn:test|minecraft:overworld", "orespawn:test", + "minecraft:overworld", "Test", aliases, false, mode, "balanced", status, + candidates, Collections.unmodifiableMap(new LinkedHashMap<>()), + Collections.emptyMap()); + } + + private static OreSourceGroup customGroup(List aliases, + List candidates) { + return new OreSourceGroup("orespawn:custom/custom_group|minecraft:overworld", + "orespawn:custom/custom_group", "minecraft:overworld", "Custom Group", + aliases, false, "keep_separate", "custom", "review_required", candidates, + Collections.emptyMap(), Collections.emptyMap()); + } +} 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..06f080cc --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java @@ -0,0 +1,111 @@ +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.GuiScreen; +import net.minecraft.util.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 GuiScreen() { }); + WorldSettingsExtension uiOnlyScreen = WorldSettingsExtensionRegistry.registerConfigScreen( + "directory_ui_only", parent -> new GuiScreen() { }); + + List loaded = Arrays.asList( + new LoadedMod("orespawn", "MMD OreSpawn", "4.1.0.110021"), + 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/OreSpawnModsScreenContractTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java new file mode 100644 index 00000000..b0187b1f --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java @@ -0,0 +1,77 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.awt.image.BufferedImage; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.Test; + +class OreSpawnModsScreenContractTest { + private static final Path CLIENT = Paths.get("src", "main", "java", "zone", "moddev", "mc", + "orespawn", "client"); + + @Test + void packagedCogIsARealSixteenPixelPngRatherThanAFontGlyph() throws Exception { + Path cog = Paths.get("src", "main", "resources", "assets", "orespawn", "textures", "gui", + "cog.png"); + byte[] bytes = Files.readAllBytes(cog); + assertArrayEquals(new byte[] {(byte) 0x89, 0x50, 0x4e, 0x47}, + java.util.Arrays.copyOf(bytes, 4)); + BufferedImage image = ImageIO.read(cog.toFile()); + assertEquals(16, image.getWidth()); + assertEquals(16, image.getHeight()); + String source = read(CLIENT.resolve("CogButton.java")); + assertTrue(source.contains("textures/gui/cog.png")); + assertFalse(source.contains("\\u2699")); + assertTrue(source.contains("GlStateManager.scale(1.5F, 1.5F, 1.0F)")); + assertTrue(source.contains("if (enabled)")); + } + + @Test + void directoryOwnsPaginationClippingTooltipsAndFailureContainment() throws Exception { + String source = read(CLIENT.resolve("OreSpawnModsScreen.java")); + assertTrue(source.contains("pageCount(entries.size(), pageSize)")); + assertTrue(source.contains("font.trimStringToWidth")); + assertTrue(source.contains("cog.enabled = entry.configurable()")); + assertTrue(source.contains("tooltip.orespawn.mod.unavailable")); + assertTrue(source.contains("entry.extension.createScreen(this)")); + assertTrue(source.contains("if (screen == null)")); + assertTrue(source.contains("catch (RuntimeException failure)")); + assertTrue(source.contains("minecraft.displayGuiScreen(parent)")); + assertTrue(source.contains("WorldSettingsExtensionNavigation.open(this, screen)")); + String navigation = read(CLIENT.resolve("WorldSettingsExtensionNavigation.java")); + assertTrue(navigation.contains("event.getGui() == null")); + assertTrue(navigation.contains("event.getGui() instanceof GuiMainMenu")); + assertTrue(navigation.contains("event.setGui(directory)")); + } + + @Test + void discoveryConsumesRecordedBoundariesRatherThanScanningProfilesInTheGui() throws Exception { + String model = read(CLIENT.resolve("OreSpawnModDirectoryModel.java")); + String legacyThree = read(Paths.get("src", "main", "java", "com", "mcmoddev", "orespawn", + "compat", "LegacyOs3Bridge.java")); + String legacyTwo = read(Paths.get("src", "main", "java", "zone", "moddev", "mc", "orespawn", + "worldgen", "LegacyConfigMigrator.java")); + assertTrue(model.contains("Loader.instance().getActiveModList()")); + assertTrue(model.contains("mod.getName()")); + assertTrue(model.contains("mod.getVersion()")); + assertFalse(model.contains("Files.")); + assertFalse(model.contains("JsonParser")); + assertTrue(legacyThree.contains("recordLegacyLineage(owner, 1)")); + assertTrue(legacyThree.contains("recordLegacyLineage(owner, 3)")); + assertTrue(legacyTwo.contains("recordLegacyLineage(owner, 2)")); + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} 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 7fea8b86..32b7cccd 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -1,12 +1,14 @@ 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 java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -26,7 +28,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce .sorted() .collect(Collectors.toList()); } - assertEquals(24, screens.size(), "Review this render-order gate when screens are added or removed"); + assertEquals(28, screens.size(), "Review this render-order gate when screens are added or removed"); for (Path screen : screens) { String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8); int render = source.indexOf( @@ -60,9 +62,238 @@ void compactOrePlacementRowsStayAboveFooterAtMinimumTestHeight() { assertCompactOrePlacementClearsFooter(240); } + @Test + void advancedAndFluidEditorsShareTheRecoveredTerrainRow() throws Exception { + Path screen = Paths.get("src", "main", "java", "zone", "moddev", "mc", + "orespawn", "client", "OreSpawnWorldSettingsScreen.java"); + String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8); + int advanced = source.indexOf("new TextComponentTranslation(\"button.orespawn.advanced\")"); + int advancedCall = source.lastIndexOf("addButton", advanced); + int fluid = source.indexOf("fluidEditorLabel()", advanced); + int fluidCall = source.lastIndexOf("addButton", fluid); + assertTrue(advanced > 0 && fluid > advanced); + assertTrue(source.substring(advancedCall, advanced) + .contains("font, left, top + (row * rowIndex)")); + assertTrue(source.substring(fluidCall, fluid) + .contains("font, right, top + (row * rowIndex++)")); + } + + @Test + void oreSourcesAndAddBlockShareTheCompactOreFooter() throws Exception { + String source = screenSource("GeologyMaterialsScreen.java"); + int width = source.indexOf("int addWidth = tab == MaterialTab.ORES"); + int sources = source.indexOf("button.orespawn.ore_sources", width); + int add = source.indexOf("button.orespawn.add_block", sources); + assertTrue(width >= 0 && sources > width && add > sources); + assertTrue(source.substring(width, add).contains("addX - addWidth - 5")); + assertTrue(source.substring(width, add).contains("addX, controlsY, addWidth, 20")); + } + + @Test + void oreSourceEditorKeepsBothCompactScrollablePanesVisible() throws Exception { + assertEquals(406, OreSourceListScreen.contentWidth(426)); + assertEquals(174, OreSourceListScreen.leftPaneWidth(426)); + assertEquals(209, OreSourceListScreen.listHeight(265)); + assertEquals(13, CompactScrollList.visibleRows(209, 16)); + assertEquals(300, OreSourceListScreen.contentWidth(320)); + assertEquals(129, OreSourceListScreen.leftPaneWidth(320)); + assertEquals(184, OreSourceListScreen.listHeight(240)); + assertEquals(11, CompactScrollList.visibleRows(184, 16)); + String list = screenSource("OreSourceListScreen.java"); + assertTrue(list.contains("initGroups(")); + assertTrue(list.contains("initOutputs(")); + assertTrue(list.contains("new CompactScrollList(this, leftPaneX, CONTENT_TOP")); + assertTrue(list.contains("new CompactScrollList(this, rightPaneX, CONTENT_TOP + 24")); + assertTrue(list.contains("label.orespawn.ore_source.groups")); + assertTrue(list.contains("button.orespawn.show_all")); + assertTrue(list.contains("button.orespawn.ore_source.hide_single")); + assertTrue(list.contains("compactFilterWidth")); + assertTrue(list.contains("button.orespawn.ore_source.accept")); + assertTrue(list.contains("acceptOreSourcePolicy(group.key)")); + assertTrue(list.contains("button.orespawn.ore_source.reset_all")); + assertTrue(list.contains("button.orespawn.ore_source.confirm_reset_all")); + assertTrue(list.contains("session.resetOreSourcesToDefaults()")); + assertTrue(list.contains("leftPaneX, height - 28")); + assertTrue(list.contains("rightPaneX + rightPaneWidth - doneWidth, height - 28")); + assertEquals(58, OreSourceListScreen.compactFilterWidth(110, 48)); + assertTrue(list.contains("groupRowColor")); + assertTrue(list.contains("drawCompactCog")); + assertTrue(list.contains("outputCandidates()")); + assertTrue(list.contains("single || !group.outputs.containsKey(candidate.sourceId)"), + "Single output rows behave as radio buttons and cannot deselect their only output"); + assertTrue(list.contains("mouseWheel(mouseX, mouseY, wheel)")); + assertTrue(list.contains("minecraft.displayGuiScreen(parent)")); + assertFalse(list.contains("button.orespawn.ore_source.all_groups")); + assertFalse(list.contains("button.orespawn.ore_source.advanced")); + assertFalse(list.contains("groupPage")); + assertFalse(list.contains("outputPage")); + assertFalse(list.contains("nameField")); + assertFalse(list.contains("aliasField")); + assertFalse(list.contains("WIDE_MINIMUM")); + assertFalse(list.contains("compactDetail")); + assertFalse(list.contains("button.orespawn.back")); + assertFalse(list.contains("button.orespawn.ore_source.needs_attention")); + assertFalse(Files.exists(Paths.get("src", "main", "java", "zone", "moddev", "mc", + "orespawn", "client", "OreSourceDetailScreen.java")), + "The technical detail screen must not remain as a second competing flow"); + } + + @Test + void groupSettingsOwnsAliasesAndClearlyNamedPlacementRules() throws Exception { + assertEquals(80, OreSourceGroupSettingsScreen.aliasListHeight(265)); + assertEquals(64, OreSourceGroupSettingsScreen.aliasListHeight(240)); + assertEquals(101, OreSourceGroupSettingsScreen.doneButtonWidth(406)); + assertEquals(80, OreSourceGroupSettingsScreen.doneButtonWidth(240)); + assertEquals(120, OreSourceGroupSettingsScreen.doneButtonWidth(600)); + assertEquals(297, OreSourceGroupSettingsScreen.validationMessageWidth(406)); + String settings = screenSource("OreSourceGroupSettingsScreen.java"); + assertTrue(settings.contains("contentX + nameLabelWidth, NAME_TOP")); + assertTrue(settings.contains("NAME_TOP + 6")); + assertTrue(settings.contains("option.orespawn.ore_source.group_name")); + assertTrue(settings.contains("option.orespawn.ore_source.alias")); + assertTrue(settings.contains("label.orespawn.ore_source.placement_rules")); + assertTrue(settings.contains("tooltip.orespawn.ore_source.placement_rules")); + assertTrue(settings.contains("new TextComponentString(\"?\")")); + assertTrue(settings.contains("label.orespawn.ore_source.standard_veins")); + assertTrue(settings.contains("placementSelectable(group, channel)")); + assertTrue(settings.contains("placementSourceLabel(source, choices.size())")); + assertTrue(settings.contains("group.canDeleteEmpty()")); + assertTrue(settings.contains("group.canDissolve()")); + assertTrue(settings.contains("button.orespawn.ore_source.confirm_dissolve")); + assertTrue(settings.contains("session.deleteEmptyOreMaterialGroup(group.material)")); + assertTrue(settings.contains("session.dissolveOreMaterialGroup(group.material)")); + assertFalse(settings.contains("session.deleteOreMaterialGroup(group.material)")); + assertTrue(settings.contains("contentX + contentWidth - doneWidth, height - 28")); + assertTrue(settings.contains("contentX + 2, height - 22, 0xFF5555")); + assertTrue(settings.contains("renderStringTooltip(java.util.Collections.singletonList(error)")); + assertFalse(settings.contains("width / 2 - 75, height - 28")); + assertFalse(settings.contains("width / 2, 19, 0xFF5555")); + assertTrue(screenSource("OreSourceListScreen.java").contains("!candidate.external")); + assertTrue(settings.contains("minecraft.displayGuiScreen(parent)")); + assertFalse(settings.contains("button.orespawn.ore_source.advanced")); + assertFalse(settings.contains("placementExplanationLines")); + } + + @Test + void tooltipsFitBesideTheCursorInsteadOfRunningOffScreen() { + assertEquals(260, OreSpawnScreen.tooltipWidth(426, 300)); + assertEquals(189, OreSpawnScreen.tooltipWidth(426, 213)); + assertEquals(136, OreSpawnScreen.tooltipWidth(320, 160)); + assertEquals(96, OreSpawnScreen.tooltipWidth(240, 120)); + } + + @Test + void materialGroupDefaultsPersistOnlyThroughMainDone() throws Exception { + String main = screenSource("OreSpawnWorldSettingsScreen.java"); + String groups = screenSource("OreSourceListScreen.java"); + int save = main.indexOf("private void saveAndClose()"); + int persistence = main.indexOf("GeomeConfig.persistOreMaterialGroups", save); + assertTrue(save >= 0 && persistence > save); + assertFalse(groups.contains("persistOreMaterialGroups"), + "leaving Material Groups must not persist future-world defaults"); + assertTrue(groups.contains("minecraft.displayGuiScreen(parent)"), + "Done and Escape return to the pending ORES editor session"); + } + + @Test + void biomeDirectoryUsesTwoPanesAt426AndResponsivePagesAt320() throws Exception { + List dimensions = Arrays.asList( + "minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"); + assertEquals("minecraft:the_nether", + BiomeWorldMaterialsScreen.nextDimension(dimensions, "minecraft:overworld")); + assertEquals("minecraft:the_end", + BiomeWorldMaterialsScreen.nextDimension(dimensions, "minecraft:the_nether")); + assertEquals("minecraft:overworld", + BiomeWorldMaterialsScreen.nextDimension(dimensions, "minecraft:the_end")); + assertEquals(406, BiomeWorldMaterialsScreen.contentWidth(426)); + assertEquals(174, BiomeWorldMaterialsScreen.leftPaneWidth(426)); + assertEquals(161, BiomeWorldMaterialsScreen.listHeight(265)); + assertEquals(213, BiomeWorldMaterialsScreen.materialsButtonY(265)); + assertEquals(300, BiomeWorldMaterialsScreen.contentWidth(320)); + assertEquals(132, BiomeWorldMaterialsScreen.leftPaneWidth(320)); + assertEquals(136, BiomeWorldMaterialsScreen.listHeight(240)); + assertEquals(188, BiomeWorldMaterialsScreen.materialsButtonY(240)); + assertEquals(24, BiomeWorldMaterialsScreen.detailTop(false), + "the two-pane detail must align with the top controls"); + assertEquals(34, BiomeWorldMaterialsScreen.detailTop(true), + "the compact detail must remain below its Back button"); + assertEquals(95, BiomeWorldMaterialsScreen.detailRuleTop(false, false)); + assertEquals(119, BiomeWorldMaterialsScreen.detailRuleTop(false, true)); + assertEquals(139, BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2), + "a two-line replacement validation error must reserve space above Placement Rules"); + assertEquals(107, BiomeWorldMaterialsScreen.detailMessageTop(false, true)); + assertTrue(BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2) - 10 + >= BiomeWorldMaterialsScreen.detailMessageTop(false, true) + 20, + "the Placement Rules label must clear both warning lines"); + assertEquals(189, BiomeWorldMaterialsScreen.controlsTop(265, + BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2)), + "validation errors must reduce the rule list rather than push footer controls down"); + assertTrue(BiomeWorldMaterialsScreen.controlsTop(265, + BiomeWorldMaterialsScreen.detailRuleTop(false, true, 2)) + 44 < 265 - 28, + "detail controls must remain above Done at the tested window height"); + assertTrue(BiomeWorldMaterialsScreen.detailRuleTop(false, false) - 10 + >= BiomeWorldMaterialsScreen.detailTop(false) + 59, + "the Placement Rules label must clear the replacement button"); + String source = screenSource("BiomeWorldMaterialsScreen.java"); + assertTrue(source.contains("TWO_PANE_MINIMUM = 400")); + assertTrue(source.contains("new CompactScrollList(this, left, CONTENT_TOP")); + assertTrue(source.contains("if (compact) compactDetail = true")); + assertTrue(source.contains("button.orespawn.show_all")); + assertTrue(source.contains("button.orespawn.biome.hide_routine")); + assertTrue(source.contains("button.orespawn.dimension_materials_named")); + assertTrue(source.contains("materialsButtonY(height), leftWidth, 20"), + "dimension materials belong to the dimension/list pane"); + assertTrue(source.contains("tooltip.orespawn.biome.dimension")); + assertTrue(source.contains("value.orespawn.dimension.the_nether")); + assertTrue(source.contains("\"< \" + dimensionName(dimension).getUnformattedText() + \" >\"")); + assertTrue(source.contains("button.orespawn.biome.replace_new_terrain")); + assertTrue(source.contains("button.orespawn.biome.leave_original")); + assertTrue(source.contains("tooltip.orespawn.biome.no_retrogen")); + assertTrue(source.contains("detailMessageLines")); + assertFalse(source.contains("warning.orespawn.biome.target_not_declared"), + "a working replacement must not display a provider-ownership warning"); + assertFalse(source.contains("label.orespawn.biome.new_terrain_only"), + "the replacement status already explains that the change affects new terrain"); + assertFalse(source.contains("height - 39, 0xFF5555"), + "replacement feedback must not be drawn behind the footer controls"); + assertTrue(source.contains("new BiomePlacementScreen")); + assertTrue(source.contains("new BiomePaletteScreen")); + assertTrue(source.contains("new DimensionMaterialsScreen")); + assertTrue(source.contains("new GeomeBiomeScreen")); + String materials = screenSource("DimensionMaterialsScreen.java"); + assertTrue(materials.contains("label.orespawn.dimension_materials.scope")); + assertTrue(materials.contains("BiomeWorldMaterialsScreen.dimensionName(dimension)")); + } + + @Test + void paletteSettingsExposeEveryExistingPaletteControl() throws Exception { + String settings = screenSource("BiomePaletteSettingsScreen.java"); + for (String field : new String[] { "enabled", "mode", "scope", "region_size", + "coverage", "fallback_weight", "include_namespaces", "exclude_namespaces" }) { + assertTrue(settings.contains("\"" + field + "\""), field); + } + String palettes = screenSource("BiomePaletteScreen.java"); + assertTrue(palettes.contains("session.biomeDirectory().palettes(dimension)")); + assertTrue(palettes.contains("resetBiomePalette")); + assertTrue(palettes.contains("resetBiomeDimension")); + assertTrue(palettes.contains("resetAllBiomeManagement")); + assertFalse(palettes.contains("button.orespawn.add_biome")); + } + + private static String screenSource(String name) throws Exception { + Path screen = Paths.get("src", "main", "java", "zone", "moddev", "mc", + "orespawn", "client", name); + return new String(Files.readAllBytes(screen), StandardCharsets.UTF_8); + } + private static void assertRowsClearFooter(int height) { + int rows = 8; + int top = OreSpawnScreenLayout.mainTop(height); + int available = OreSpawnScreenLayout.footerY(height) - top - 20 - 4; + int spacing = Math.min(OreSpawnScreenLayout.mainRowSpacing(height), + Math.max(20, available / (rows - 1))); int lastRowBottom = OreSpawnScreenLayout.mainTop(height) - + (OreSpawnScreenLayout.mainRowSpacing(height) * 7) + 20; + + (spacing * (rows - 1)) + 20; assertTrue(lastRowBottom < OreSpawnScreenLayout.footerY(height)); } 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 d9f3929d..a5f4b991 100644 --- a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java @@ -7,7 +7,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -113,6 +116,55 @@ void validatesBiomePaletteAndDimensionMaterialsOnlyInSchemaFour() { () -> WorldgenIntegrationManager.validateProvider("examplemod", solidFluid)); } + @Test + void sourceArbitrationFieldsRequireProviderSchemaFive() { + assertDoesNotThrow(() -> WorldgenIntegrationManager.validateProvider( + "examplemod", oreSourceProvider(5))); + assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( + "examplemod", oreSourceProvider(4))); + } + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + void providerCanDeferNewEntriesForExistingWorldsWithoutChangingNewWorldDefaults() + throws Exception { + JsonObject root = new JsonObject(); + root.addProperty("schema_version", 4); + root.addProperty("provider_modid", "realisticdeposits"); + root.addProperty("provider_revision", 1); + root.addProperty("merge_new_entries_into_existing_worlds", false); + JsonObject ores = new JsonObject(); + ores.add("realisticdeposits:deposit/minecraft/iron_ore", new JsonObject()); + root.add("ores", ores); + + Class definitionType = Class.forName( + "zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager$ProviderDefinition"); + Constructor constructor = definitionType.getDeclaredConstructor( + String.class, int.class, JsonObject.class); + constructor.setAccessible(true); + Object provider = constructor.newInstance("realisticdeposits", 1, root); + Field activeField = WorldgenIntegrationManager.class.getDeclaredField("ACTIVE_PROVIDERS"); + activeField.setAccessible(true); + Map active = (Map) activeField.get(null); + Map saved = new LinkedHashMap(active); + try { + active.clear(); + active.put("realisticdeposits", provider); + JsonObject existingWorld = new JsonObject(); + WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(existingWorld); + assertFalse(existingWorld.getAsJsonObject("ores") + .has("realisticdeposits:deposit/minecraft/iron_ore")); + + JsonObject newWorld = new JsonObject(); + WorldgenIntegrationManager.mergeProviderDefinitions(newWorld); + assertTrue(newWorld.getAsJsonObject("ores") + .has("realisticdeposits:deposit/minecraft/iron_ore")); + } finally { + active.clear(); + active.putAll(saved); + } + } + private static JsonObject provider(String block, boolean withHost, int schema) { JsonObject root = new JsonObject(); root.addProperty("schema_version", schema); @@ -183,4 +235,33 @@ private static JsonObject biomeProvider(int schema, String fluid) { root.add("dimension_materials", materials); return root; } + + private static JsonObject oreSourceProvider(int schema) { + JsonObject root = new JsonObject(); + root.addProperty("schema_version", schema); + root.addProperty("provider_modid", "examplemod"); + root.addProperty("provider_revision", 1); + JsonObject rule = new JsonObject(); + // This test exercises only the schema gate. Pattern decoding is covered by + // the provider validation tests that install the OreSpawn pattern registry. + rule.addProperty("enabled", false); + rule.addProperty("min_y", 0); + rule.addProperty("max_y", 64); + rule.addProperty("frequency", 1.0D); + rule.addProperty("quantity", 8); + rule.addProperty("placement_channel", "orespawn:standard"); + JsonArray hosts = new JsonArray(); + hosts.add(new JsonPrimitive("minecraft:stone")); + rule.add("host_blocks", hosts); + JsonObject ore = new JsonObject(); + ore.addProperty("block", "minecraft:iron_ore"); + ore.addProperty("material", "orespawn:iron"); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", rule); + ore.add("dimensions", dimensions); + JsonObject ores = new JsonObject(); + ores.add("examplemod:iron", ore); + root.add("ores", ores); + return root; + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitutionTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitutionTest.java new file mode 100644 index 00000000..be7857e0 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/AquiferMaterialSubstitutionTest.java @@ -0,0 +1,162 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.BitSet; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import net.minecraft.init.Blocks; +import net.minecraft.world.chunk.ChunkPrimer; +import net.minecraft.world.chunk.IChunkGenerator; +import net.minecraft.world.chunk.storage.ExtendedBlockStorage; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; + +class AquiferMaterialSubstitutionTest { + @BeforeAll + static void bootstrapMinecraftRegistries() { + Forge12TestBootstrap.registerVanilla(); + } + + @Test + void capturesOnlyExactNativeFluidBelowSeaLevel() { + ChunkPrimer primer = new ChunkPrimer(); + primer.setBlockState(1, 10, 2, Blocks.WATER.getDefaultState()); + primer.setBlockState(3, 63, 4, Blocks.WATER.getDefaultState()); + primer.setBlockState(5, 64, 6, Blocks.WATER.getDefaultState()); + primer.setBlockState(7, 20, 8, Blocks.LAVA.getDefaultState()); + + BitSet mask = AquiferMaterialSubstitution.captureMask( + primer, Blocks.WATER.getDefaultState(), 64); + + assertEquals(2, mask.cardinality()); + assertTrue(mask.get(AquiferMaterialSubstitution.index(1, 10, 2))); + assertTrue(mask.get(AquiferMaterialSubstitution.index(3, 63, 4))); + assertFalse(mask.get(AquiferMaterialSubstitution.index(5, 64, 6))); + assertFalse(mask.get(AquiferMaterialSubstitution.index(7, 20, 8))); + } + + @Test + void primerIndexRoundTripsEveryLocalCoordinate() { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y : new int[] { 0, 1, 63, 127, 255 }) { + int index = AquiferMaterialSubstitution.index(x, y, z); + assertEquals(x, AquiferMaterialSubstitution.localX(index)); + assertEquals(y, AquiferMaterialSubstitution.localY(index)); + assertEquals(z, AquiferMaterialSubstitution.localZ(index)); + } + } + } + } + + @Test + void pendingMasksAreIdentityScopedAndConsumedOnceAtNegativeCoordinates() { + AquiferMaterialSubstitution.PendingMasks masks = + new AquiferMaterialSubstitution.PendingMasks(); + IChunkGenerator overworld = generator(); + IChunkGenerator otherDimension = generator(); + BitSet bits = new BitSet(); + bits.set(AquiferMaterialSubstitution.index(2, 30, 9)); + AquiferMaterialSubstitution.Pending pending = new AquiferMaterialSubstitution.Pending( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState(), bits); + + masks.put(overworld, -12, -34, pending); + assertNull(masks.take(otherDimension, -12, -34)); + assertNotNull(masks.take(overworld, -12, -34)); + assertNull(masks.take(overworld, -12, -34)); + assertEquals(0, masks.size()); + } + + @Test + void pendingMasksCanBeRemovedPerDimensionOrClearedAtShutdown() { + AquiferMaterialSubstitution.PendingMasks masks = + new AquiferMaterialSubstitution.PendingMasks(); + IChunkGenerator overworld = generator(); + IChunkGenerator nether = generator(); + AquiferMaterialSubstitution.Pending pending = new AquiferMaterialSubstitution.Pending( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState(), new BitSet()); + masks.put(overworld, 0, 0, pending); + masks.put(nether, 0, 0, pending); + + masks.remove(overworld); + assertNull(masks.take(overworld, 0, 0)); + assertNotNull(masks.take(nether, 0, 0)); + masks.put(nether, 1, 1, pending); + masks.clear(); + assertEquals(0, masks.size()); + } + + @Test + void lightingCompatibilitySelectsTheSafeFastPath() { + assertTrue(AquiferMaterialSubstitution.lightCompatible( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState())); + assertFalse(AquiferMaterialSubstitution.lightCompatible( + Blocks.WATER.getDefaultState(), Blocks.LAVA.getDefaultState())); + } + + @Test + void substitutionTouchesOnlyRecordedCellsThatStillContainNativeFluid() { + ExtendedBlockStorage[] sections = new ExtendedBlockStorage[16]; + sections[1] = new ExtendedBlockStorage(16, true); + sections[1].set(1, 4, 2, Blocks.WATER.getDefaultState()); + sections[1].set(3, 5, 4, Blocks.LAVA.getDefaultState()); + sections[1].set(5, 6, 6, Blocks.WATER.getDefaultState()); + + BitSet bits = new BitSet(); + bits.set(AquiferMaterialSubstitution.index(1, 20, 2)); + bits.set(AquiferMaterialSubstitution.index(3, 21, 4)); + AquiferMaterialSubstitution.Pending pending = new AquiferMaterialSubstitution.Pending( + Blocks.WATER.getDefaultState(), Blocks.FLOWING_WATER.getDefaultState(), bits); + + assertEquals(1, AquiferMaterialSubstitution.applyToSections(pending, sections)); + assertEquals(Blocks.FLOWING_WATER.getDefaultState(), sections[1].get(1, 4, 2)); + assertEquals(Blocks.LAVA.getDefaultState(), sections[1].get(3, 5, 4), + "a recorded cell changed by caves or another pass must be preserved"); + assertEquals(Blocks.WATER.getDefaultState(), sections[1].get(5, 6, 6), + "a later lake or spring outside the mask must be preserved"); + } + + @Test + void terrainHookRunsBeforeEveryOtherOreSpawnLocalModification() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "OreSpawnWorldGenerator.java")), + StandardCharsets.UTF_8); + assertTrue(source.contains("captureNativeAquifer(ChunkGeneratorEvent.ReplaceBiomeBlocks event)")); + assertTrue(source.indexOf("AquiferMaterialSubstitution.apply(world, chunk)") + < source.indexOf("StoneReplacer.FEATURE.generate(world, chunk, random)")); + assertTrue(source.contains("priority = EventPriority.LOWEST")); + String substitution = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "AquiferMaterialSubstitution.java")), + StandardCharsets.UTF_8); + assertTrue(substitution.contains("section.set(x, y & 15, z, pending.selectedFluid)")); + assertFalse(substitution.contains("world.setBlockState")); + assertFalse(substitution.contains("scheduleUpdate")); + assertTrue(substitution.indexOf("generator.oceanBlock = nativeFluid") + < substitution.indexOf("if (lightCompatible(nativeFluid, selected))"), + "the fast path must leave the generator on its native fluid"); + assertTrue(substitution.contains("entry.getKey().oceanBlock = entry.getValue()"), + "shutdown must restore compatibility-path generator state"); + } + + private static IChunkGenerator generator() { + return (IChunkGenerator) Proxy.newProxyInstance( + AquiferMaterialSubstitutionTest.class.getClassLoader(), + new Class[] { IChunkGenerator.class }, + (proxy, method, arguments) -> { + Class type = method.getReturnType(); + if (type == boolean.class) return false; + if (type == int.class) return 0; + return null; + }); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java new file mode 100644 index 00000000..1d94048b --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java @@ -0,0 +1,66 @@ +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.IdentityHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.minecraft.block.Block; +import net.minecraft.block.material.Material; +import net.minecraft.util.ResourceLocation; + +class BackgroundGenerationScaleTest { + private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld"); + private static final ResourceLocation IRON = new ResourceLocation("minecraft", "iron_ore"); + + @Test + void controllerRuleRemainsUnscaledWhileBackgroundRulesAreScaled() { + assertEquals(8.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, true, 0.25D)); + assertEquals(2.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, false, 0.25D)); + } + + @Test + void declaredMaterialsScaleOnlyTheStandardPlacementChannel() { + ResourceLocation material = new ResourceLocation("orespawn:iron"); + assertEquals(0.15D, OreSpawnOreGeneration.channelBackgroundScale(material, + new ResourceLocation("orespawn:standard"), 0.15D, 0.75D)); + assertEquals(1.0D, OreSpawnOreGeneration.channelBackgroundScale(material, + new ResourceLocation("realisticdeposits:district"), 0.15D, 0.75D)); + assertEquals(0.75D, OreSpawnOreGeneration.channelBackgroundScale(null, + new ResourceLocation("orespawn:standard"), 0.15D, 0.75D)); + } + + @Test + void lowestControllerScaleWinsForTheSamePrimaryResource() { + Map scales = new IdentityHashMap<>(); + Block output = new Block(Material.ROCK); + OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.75D); + OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.25D); + OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.5D); + assertEquals(0.25D, scales.get(output).doubleValue()); + } + + @Test + void vanillaScaleGateIsStableByWorldDimensionChunkAndResource() { + assertFalse(OreSpawnOreGeneration.passesBackgroundScale( + OVERWORLD, IRON, 42L, -3, 7, 0.0D)); + assertTrue(OreSpawnOreGeneration.passesBackgroundScale( + OVERWORLD, IRON, 42L, -3, 7, 1.0D)); + boolean first = OreSpawnOreGeneration.passesBackgroundScale( + OVERWORLD, IRON, 42L, -3, 7, 0.25D); + assertEquals(first, OreSpawnOreGeneration.passesBackgroundScale( + OVERWORLD, IRON, 42L, -3, 7, 0.25D)); + + int accepted = 0; + for (int chunk = -5000; chunk < 5000; chunk++) { + if (OreSpawnOreGeneration.passesBackgroundScale( + OVERWORLD, IRON, 42L, chunk, -chunk, 0.25D)) accepted++; + } + assertTrue(accepted > 2200 && accepted < 2800, + "quarter-scale gate should retain approximately one quarter of chunks: " + accepted); + } +} 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..a007a1b4 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java @@ -0,0 +1,174 @@ +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.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map.Entry; +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 net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.biome.Biome; +import net.minecraft.world.biome.BiomeProvider; +import net.minecraftforge.fml.common.registry.ForgeRegistries; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; +import zone.moddev.mc.orespawn.worldgen.BakedBiomeWorldgen.Palette; + +class BiomePaletteOrderingTest { + @BeforeAll + static void bootstrapMinecraft() { + Forge12TestBootstrap.registerVanilla(); + } + + @Test + void ordinaryOrderIsStableAndUiOverridesAlwaysRunLast() { + JsonObject section = new JsonObject(); + section.add("provider:first", new JsonObject()); + section.add("orespawn:ui/biome_overrides/minecraft_overworld", new JsonObject()); + section.add("provider:second", new JsonObject()); + List ids = BiomeWorldgenManager.orderedPaletteEntries(section).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 terminalOverrideRunsAfterOrdinaryPalettesEvenWhenStoredFirst() { + 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, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + Biome plains = biome("minecraft:plains"); + Biome forest = biome("minecraft:forest"); + BiomeOverlaySource overlay = new BiomeOverlaySource( + new ConstantBiomeProvider(plains), baked, 78431L); + + assertEquals(2, baked.size()); + assertSame(forest, overlay.getBiome(new BlockPos(0, 64, 0))); + assertSame(forest, overlay.getBiome(new BlockPos(-257, 64, 389))); + } + + @Test + void missingTargetLeavesSourceUnchangedAndReactivatesWhenTargetReturns() { + JsonObject missingRoot = root(); + missingRoot.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", + palette("missing:orchard", "minecraft:plains")); + List missing = BiomeWorldgenManager.bakePalettes(missingRoot, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + Biome plains = biome("minecraft:plains"); + + assertTrue(missing.isEmpty()); + assertTrue(missingRoot.toString().contains("missing:orchard"), + "dormant target must remain persisted"); + JsonObject restoredRoot = root(); + restoredRoot.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", + palette("minecraft:desert", "minecraft:plains")); + List restored = BiomeWorldgenManager.bakePalettes(restoredRoot, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + assertSame(biome("minecraft:desert"), new BiomeOverlaySource( + new ConstantBiomeProvider(plains), restored, 11L) + .getBiome(new BlockPos(64, 70, 64))); + } + + @Test + void absentOrDisabledOverrideProducesIdenticalBiomeCoordinates() { + JsonObject untouched = root(); + untouched.getAsJsonObject("biome_palettes").add("provider:first", + palette("minecraft:desert", "minecraft:plains")); + JsonObject disabled = new com.google.gson.JsonParser().parse(untouched.toString()) + .getAsJsonObject(); + JsonObject disabledOverride = palette("minecraft:forest", "minecraft:desert"); + disabledOverride.addProperty("enabled", false); + disabled.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", disabledOverride); + Biome plains = biome("minecraft:plains"); + BiomeOverlaySource baseline = overlay(untouched, plains, 918273L); + BiomeOverlaySource candidate = overlay(disabled, plains, 918273L); + for (int z = -96; z <= 96; z += 3) { + for (int x = -96; x <= 96; x += 3) { + assertSame(baseline.getBiome(new BlockPos(x, 63, z)), + candidate.getBiome(new BlockPos(x, 63, z)), x + "," + z); + } + } + } + + private static BiomeOverlaySource overlay(JsonObject root, Biome source, long seed) { + return new BiomeOverlaySource(new ConstantBiomeProvider(source), + BiomeWorldgenManager.bakePalettes(root, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()), seed); + } + + 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); + palette.add("include_namespaces", new JsonArray()); + palette.add("exclude_namespaces", new JsonArray()); + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray sources = new JsonArray(); + sources.add(new com.google.gson.JsonPrimitive(source)); + placement.add("similar_biomes", sources); + placement.add("required_similar_biomes", new JsonArray()); + 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 BiomeProvider { + private final Biome biome; + + ConstantBiomeProvider(Biome biome) { this.biome = biome; } + + @Override public Biome getBiome(BlockPos pos, Biome fallback) { return biome; } + @Override public Biome[] getBiomesForGeneration(Biome[] reuse, int x, int z, + int width, int length) { return filled(reuse, width, length); } + @Override public Biome[] getBiomes(Biome[] reuse, int x, int z, + int width, int length, boolean cacheFlag) { return filled(reuse, width, length); } + @Override public List getBiomesToSpawnIn() { return Collections.singletonList(biome); } + + private Biome[] filled(Biome[] reuse, int width, int length) { + Biome[] result = reuse != null && reuse.length >= width * length + ? reuse : new Biome[width * length]; + Arrays.fill(result, 0, width * length, biome); + return result; + } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java index 3a869dc6..7a82fa46 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -26,7 +26,7 @@ void coordinatorRunsSurfacesBeforeManagedOresAndLeavesStaticBiomeListsAlone() th assertTrue(source.contains("@SubscribeEvent(priority = EventPriority.LOWEST)\r\n\tpublic void filterVanillaOre") || source.contains("@SubscribeEvent(priority = EventPriority.LOWEST)\n\tpublic void filterVanillaOre"), "vanilla-ore suppression must make its decision at Forge's final standard event priority"); - assertTrue(source.contains("OreSpawnOreGeneration.takesOverVanillaOre(dimension, output)")); + assertTrue(source.contains("OreSpawnOreGeneration.allowsVanillaOre(dimension, output")); assertTrue(source.contains("event.setResult(Event.Result.DENY)")); assertTrue(source.contains("earlyComplete.add(key)") && source.contains("oreComplete.add(key)"), "terrain and ore callbacks must be independently deduplicated"); 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..4240a42a --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java @@ -0,0 +1,508 @@ +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.assertNotEquals; +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 com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; +import zone.moddev.mc.orespawn.util.JsonCopies; + +class OreSourcePoliciesTest { + @BeforeAll + static void bootstrapVanilla() { + Forge12TestBootstrap.registerVanilla(); + } + + @Test + void exactOreDictionaryNamesAndCuratedSpellingsProduceCanonicalMaterials() { + OreSourcePolicies.Inference sulfur = OreSourcePolicies.inferMaterial( + Arrays.asList("oreSulphur", "oreSulfur", "dustSulfur")); + OreSourcePolicies.Inference aluminum = OreSourcePolicies.inferMaterial( + Arrays.asList("oreAluminium", "oreAluminum")); + + assertEquals("orespawn:sulfur", sulfur.material.toString()); + assertEquals(Arrays.asList("oreSulfur", "oreSulphur"), sulfur.names); + assertFalse(sulfur.reviewRequired); + assertEquals("orespawn:aluminum", aluminum.material.toString()); + } + + @Test + void niterAndSaltpeterRemainDistinctAndAmbiguousMembershipRequiresReview() { + assertEquals("orespawn:niter", OreSourcePolicies.inferMaterial( + Collections.singletonList("oreNiter")).material.toString()); + assertEquals("orespawn:saltpeter", OreSourcePolicies.inferMaterial( + Collections.singletonList("oreSaltpeter")).material.toString()); + OreSourcePolicies.Inference ambiguous = OreSourcePolicies.inferMaterial( + Arrays.asList("oreNiter", "oreSaltpeter")); + assertTrue(ambiguous.material.toString().startsWith("orespawn:review/")); + assertTrue(ambiguous.reviewRequired); + assertEquals(null, OreSourcePolicies.inferMaterial( + Arrays.asList("dustSulfur", "crushedSulfur")).material); + OreSourcePolicies.Inference invalid = OreSourcePolicies.inferMaterial( + Collections.singletonList("oreBad Path")); + assertEquals(null, invalid.material); + assertFalse(invalid.reviewRequired); + } + + @Test + void unrelatedAmbiguousAliasFamiliesReceiveDistinctStableReviewGroups() { + JsonObject root = root(); + OreMaterialGroups.initialize(root); + java.util.List adamantineAliases = Arrays.asList( + "oreAdamantine", "oreAdamantite", "oreAdamantium", "oreAdamant"); + java.util.List reorderedAdamantineAliases = Arrays.asList( + "oreAdamantium", "oreAdamant", "oreAdamantite", "oreAdamantine"); + java.util.List mercuryAliases = Arrays.asList("oreMercury", "oreQuicksilver"); + + OreSourcePolicies.Inference adamantine = OreSourcePolicies.inferMaterial(root, + adamantineAliases); + OreSourcePolicies.Inference reordered = OreSourcePolicies.inferMaterial(root, + reorderedAdamantineAliases); + OreSourcePolicies.Inference mercury = OreSourcePolicies.inferMaterial(root, mercuryAliases); + + assertTrue(adamantine.reviewRequired); + assertTrue(mercury.reviewRequired); + assertEquals(adamantine.material, reordered.material, + "Alias order must not change the provisional material identity"); + assertNotEquals(adamantine.material, mercury.material, + "Unrelated ambiguous alias families must never share a review group"); + + OreMaterialGroups.ensureDefinition(root, adamantine.material, adamantineAliases); + OreMaterialGroups.ensureDefinition(root, mercury.material, mercuryAliases); + OreMaterialGroups.Definition adamantineDefinition = OreMaterialGroups.definition(root, + adamantine.material); + OreMaterialGroups.Definition mercuryDefinition = OreMaterialGroups.definition(root, + mercury.material); + assertEquals(Arrays.asList("oreAdamant", "oreAdamantine", "oreAdamantite", + "oreAdamantium"), adamantineDefinition.oreDictionaryEntries); + assertEquals(Arrays.asList("oreMercury", "oreQuicksilver"), + mercuryDefinition.oreDictionaryEntries); + assertEquals("Adamant, Adamantine, Adamantite, Adamantium", + adamantineDefinition.displayName); + assertEquals("Mercury, Quicksilver", mercuryDefinition.displayName); + assertFalse(OreSourcePolicies.inferMaterial(root, adamantineAliases).reviewRequired, + "Once the alias family exists, accepting it must survive a later rebuild"); + } + + @Test + void legacySharedReviewGroupIsRemovedSoItsAliasFamiliesCanBeRediscovered() { + JsonObject root = root(); + JsonObject groups = new JsonObject(); + JsonObject legacy = new JsonObject(); + legacy.addProperty("display_name", "Review required"); + JsonArray aliases = new JsonArray(); + aliases.add(new JsonPrimitive("oreAdamantine")); + aliases.add(new JsonPrimitive("oreMercury")); + legacy.add("ore_dictionary_entries", aliases); + groups.add("orespawn:review_required", legacy); + root.add(OreMaterialGroups.SECTION, groups); + + assertTrue(OreMaterialGroups.initialize(root)); + assertFalse(root.getAsJsonObject(OreMaterialGroups.SECTION) + .has("orespawn:review_required")); + } + + @Test + void freshCataloguedMmdConflictConsolidatesUsingReviewedPriority() { + JsonObject root = rootWithSulfurConflict(); + assertTrue(OreSourcePolicies.initialize(root, false)); + JsonObject policy = policy(root); + + assertEquals("consolidated", policy.get("mode").getAsString()); + assertEquals("consolidated", policy.get("status").getAsString()); + assertEquals("balanced", policy.get("output_mode").getAsString()); + assertEquals(2, policy.getAsJsonObject("outputs").entrySet().size()); + assertTrue(policy.getAsJsonObject("outputs").has("mineralogy:sulfur")); + assertTrue(policy.getAsJsonObject("outputs").has("baseminerals:sulfur")); + assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + } + + @Test + void upgradedWorldConflictPreservesEverySourceUntilUserChooses() { + JsonObject root = rootWithSulfurConflict(); + OreSourcePolicies.initialize(root, true); + JsonObject policy = policy(root); + + assertEquals("keep_separate", policy.get("mode").getAsString()); + assertEquals(2, policy.getAsJsonObject("outputs").entrySet().size()); + } + + @Test + void thirdPartyConflictIsNotAutomaticallyConsolidated() { + JsonObject root = root(); + addOre(root, "otherone:sulfur", "otherone", "minecraft:iron_ore", "minecraft:overworld"); + addOre(root, "othertwo:sulfur", "othertwo", "minecraft:gold_ore", "minecraft:overworld"); + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + assertEquals("keep_separate", policy.get("mode").getAsString()); + assertEquals("review_required", policy.get("status").getAsString()); + } + + @Test + void missingSelectionsRemainPersistedAndSnapshotsAreImmutable() { + JsonObject root = rootWithSulfurConflict(); + OreSourcePolicies.initialize(root, false); + root.getAsJsonObject("ores").remove("mineralogy:sulfur"); + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + assertTrue(policy.getAsJsonObject("outputs").has("mineralogy:sulfur")); + assertEquals("missing_source", policy.get("status").getAsString()); + boolean missing = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("mineralogy:sulfur".equals(candidate.get("source_id").getAsString())) { + missing = !candidate.get("loaded").getAsBoolean(); + } + } + assertTrue(missing); + OreSourcePolicies.Snapshot snapshot = OreSourcePolicies.snapshot(root); + assertThrows(UnsupportedOperationException.class, () -> snapshot.groups().clear()); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.groups().get(0).outputs.put("replacement", 2.0D)); + + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "minecraft:overworld"); + OreSourcePolicies.initialize(root, false); + for (JsonElement element : policy(root).getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("mineralogy:sulfur".equals(candidate.get("source_id").getAsString())) { + assertTrue(candidate.get("loaded").getAsBoolean()); + } + } + } + + @Test + void lithiumUsesItsReviewedMmdPriority() { + JsonObject root = root(); + addOre(root, "electricadvantage:lithium", "electricadvantage", + "minecraft:gold_ore", "orespawn:lithium", "minecraft:overworld", null); + addOre(root, "baseminerals:lithium", "baseminerals", + "minecraft:iron_ore", "orespawn:lithium", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals("consolidated", policy.get("mode").getAsString()); + assertTrue(policy.getAsJsonObject("outputs").has("baseminerals:lithium")); + } + + @Test + void placementChannelsStayIndependentWhileOutputsRemainMaterialWide() { + JsonObject root = rootWithSulfurConflict(); + addOre(root, "electricadvantage:sulfur_district", "electricadvantage", + "minecraft:coal_ore", "orespawn:sulfur", "minecraft:overworld", + "realisticdeposits:district"); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals(2, policy.getAsJsonObject("placement_sources").entrySet().size()); + assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + assertEquals("electricadvantage:sulfur_district", policy.getAsJsonObject("placement_sources") + .get("realisticdeposits:district").getAsString()); + assertEquals(3, policy.getAsJsonObject("outputs").entrySet().size()); + } + + @Test + void catalogExcludesScienceAndSeparatesDenseEnrichment() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "minecraft:overworld"); + addOre(root, "densemetals:dense_sulfur", "densemetals", "minecraft:gold_ore", + "minecraft:overworld"); + addOre(root, "basesciences:sulfur", "basesciences", "minecraft:coal_ore", + "minecraft:overworld"); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals("keep_separate", policy.get("mode").getAsString()); + assertEquals(2, policy.getAsJsonArray("candidates").size()); + assertFalse(policy.getAsJsonObject("outputs").has("densemetals:dense_sulfur")); + boolean enrichment = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + enrichment |= "densemetals:dense_sulfur".equals( + candidate.get("source_id").getAsString()) + && candidate.get("enrichment").getAsBoolean(); + } + assertTrue(enrichment); + } + + @Test + void netherAndEndCatalogCandidatesCannotLeakIntoOtherDomains() { + JsonObject root = root(); + addOre(root, "nethermetals:sulfur", "nethermetals", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "endmetals:sulfur", "endmetals", "minecraft:coal_ore", + "orespawn:sulfur", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, false); + assertTrue(root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().isEmpty()); + + addOre(root, "nethermetals:sulfur", "nethermetals", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:the_nether", null); + addOre(root, "baseminerals:sulfur", "baseminerals", "minecraft:iron_ore", + "orespawn:sulfur", "minecraft:the_nether", null); + OreSourcePolicies.initialize(root, false); + JsonObject policy = policy(root); + assertEquals("minecraft:the_nether", policy.get("domain").getAsString()); + } + + @Test + void customPatternsDefaultToTheirOwnPlacementChannel() { + JsonObject custom = new JsonObject(); + JsonObject pattern = new JsonObject(); + pattern.addProperty("type", "realisticdeposits:district"); + pattern.add("settings", new JsonObject()); + custom.add("pattern", pattern); + assertEquals("realisticdeposits:district", + OreSourcePolicies.placementChannel(custom).toString()); + pattern.addProperty("type", "orespawn:registered_custom"); + assertEquals("orespawn:registered_custom", + OreSourcePolicies.placementChannel(custom).toString()); + + JsonObject builtIn = new JsonObject(); + builtIn.addProperty("pattern", "vein"); + assertEquals("orespawn:standard", OreSourcePolicies.placementChannel(builtIn).toString()); + } + + @Test + void sameVanillaOutputWithComplementaryPlacementRulesIsNotAConflict() { + JsonObject root = root(); + addOre(root, "minecraft:gold_ore", "minecraft", "minecraft:gold_ore", + "orespawn:gold", "minecraft:overworld", null); + addOre(root, "orespawn:vanilla_gold_badlands", "minecraft", "minecraft:gold_ore", + "orespawn:gold", "minecraft:overworld", null); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().size()); + OreSourcePolicies.GroupView group = OreSourcePolicies.snapshot(root).groups().get(0); + assertEquals("separate", group.status, + "Complementary normal and Badlands rules are one harmless material group"); + assertEquals(1, group.outputs.size()); + } + + @Test + void sameOutputAcrossIndependentPlacementChannelsIsNotAConflict() { + JsonObject root = root(); + addOre(root, "minecraft:iron_ore", "minecraft", "minecraft:iron_ore", + "orespawn:iron", "minecraft:overworld", null); + addOre(root, "realisticdeposits:overworld_iron", "realisticdeposits", "minecraft:iron_ore", + "orespawn:iron", "minecraft:overworld", "realisticdeposits:stratiform_seam"); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().size()); + OreSourcePolicies.GroupView group = OreSourcePolicies.snapshot(root).groups().get(0); + assertEquals("separate", group.status, + "A placement engine using the same output remains an independent channel"); + assertEquals(1, group.outputs.size()); + assertEquals(2, group.placements.size()); + } + + @Test + void differentOwnersInTheSameChannelStillCompeteForOnePlacementBudget() { + JsonObject root = root(); + addOre(root, "otherone:sulfur", "otherone", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "othertwo:sulfur", "othertwo", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().size()); + assertEquals("review_required", policy(root).get("status").getAsString()); + } + + @Test + void duplicateRegistryStatesAppearAsOneSelectableOutput() { + JsonObject root = root(); + addOre(root, "otherone:sulfur", "otherone", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "othertwo:sulfur", "othertwo", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + + OreSourcePolicies.initialize(root, false); + + assertEquals(1, policy(root).getAsJsonObject("outputs").entrySet().size()); + assertEquals(2, policy(root).getAsJsonArray("candidates").size(), + "the placement candidates remain auditable even when their output state is shared"); + } + + @Test + void snapshotsOrderFriendlyMaterialGroupsDeterministically() { + JsonObject root = root(); + addOre(root, "example:zinc", "example", "minecraft:gold_ore", + "orespawn:zinc", "minecraft:overworld", null); + addOre(root, "example:copper", "example", "minecraft:iron_ore", + "orespawn:copper", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, true); + + java.util.List groups = OreSourcePolicies.snapshot(root).groups(); + assertEquals("orespawn:copper", groups.get(0).material.toString()); + assertEquals("orespawn:zinc", groups.get(1).material.toString()); + } + + @Test + void separateLegacyPolicyForOneLoadedOutputIsHiddenWithoutBeingDeleted() { + JsonObject root = root(); + addOre(root, "minecraft:gold_ore", "minecraft", "minecraft:gold_ore", + "orespawn:gold", "minecraft:overworld", null); + addOre(root, "orespawn:vanilla_gold_badlands", "minecraft", "minecraft:iron_ore", + "orespawn:gold", "minecraft:overworld", null); + OreSourcePolicies.initialize(root, true); + JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION); + assertEquals(1, policies.entrySet().size()); + + root.getAsJsonObject("ores").getAsJsonObject("orespawn:vanilla_gold_badlands") + .addProperty("block", "minecraft:gold_ore"); + OreSourcePolicies.initialize(root, true); + + assertEquals(1, policies.entrySet().size(), "Existing profile data must not be rewritten away"); + assertEquals(1, OreSourcePolicies.snapshot(root).groups().size(), + "All Groups must retain harmless single-output material groups"); + } + + @Test + void inactiveManagedProviderRemainsAnOutputWithoutOwningPlacement() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "electricadvantage:sulfur", "electricadvantage", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + root.getAsJsonObject("ores").getAsJsonObject("electricadvantage:sulfur") + .addProperty("enabled", false); + + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + assertEquals("balanced", policy.get("output_mode").getAsString()); + assertEquals(2, policy.getAsJsonObject("outputs").entrySet().size()); + assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + boolean outputOnly = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("electricadvantage:sulfur".equals(candidate.get("source_id").getAsString())) { + outputOnly = candidate.get("loaded").getAsBoolean() + && !candidate.get("placement_active").getAsBoolean() + && !candidate.get("external").getAsBoolean(); + } + } + assertTrue(outputOnly); + } + + @Test + void staleMissingExternalDuplicateDoesNotKeepManagedOutputRed() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", + "orespawn:sulfur", "minecraft:overworld", null); + addOre(root, "electricadvantage:sulfur", "electricadvantage", "minecraft:gold_ore", + "orespawn:sulfur", "minecraft:overworld", null); + root.getAsJsonObject("ores").getAsJsonObject("electricadvantage:sulfur") + .addProperty("enabled", false); + OreSourcePolicies.initialize(root, false); + + JsonObject policy = policy(root); + JsonObject external = null; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + JsonObject candidate = element.getAsJsonObject(); + if ("electricadvantage:sulfur".equals(candidate.get("source_id").getAsString())) { + external = JsonCopies.copy(candidate); + } + } + assertTrue(external != null); + external.addProperty("source_id", "external/minecraft:gold_ore/0"); + external.addProperty("loaded", false); + external.addProperty("placement_active", false); + external.addProperty("external", true); + policy.getAsJsonArray("candidates").add(external); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "single"); + policy.addProperty("review_required", false); + policy.addProperty("status", "external_generation"); + + assertTrue(OreSourcePolicies.initialize(root, false)); + assertEquals("separate", policy(root).get("status").getAsString()); + assertEquals("separate", OreSourcePolicies.snapshot(root).groups().get(0).status); + assertEquals(3, policy(root).getAsJsonArray("candidates").size(), + "The missing historical candidate remains available for deterministic restoration"); + } + + @Test + void materialGroupsInferExactNamesAndPreserveCuratedEdits() { + JsonObject root = root(); + JsonObject groups = new JsonObject(); + JsonObject sulfur = new JsonObject(); + sulfur.addProperty("display_name", "Brimstone"); + JsonArray aliases = new JsonArray(); + aliases.add(new JsonPrimitive("oreSulfur")); + sulfur.add("ore_dictionary_entries", aliases); + groups.add("orespawn:sulfur", sulfur); + root.add(OreMaterialGroups.SECTION, groups); + addOre(root, "example:cobalt", "example", "minecraft:coal_ore", + "minecraft:overworld"); + root.getAsJsonObject("ores").getAsJsonObject("example:cobalt").remove("material"); + // The test block has no cobalt dictionary entry; exercise inference directly. + OreSourcePolicies.Inference cobalt = OreSourcePolicies.inferMaterial(root, + Collections.singletonList("oreCobalt")); + assertEquals("orespawn:cobalt", cobalt.material.toString()); + + OreMaterialGroups.initialize(root); + JsonObject savedSulfur = root.getAsJsonObject(OreMaterialGroups.SECTION) + .getAsJsonObject("orespawn:sulfur"); + assertEquals("Brimstone", savedSulfur.get("display_name").getAsString()); + assertEquals(1, savedSulfur.getAsJsonArray("ore_dictionary_entries").size(), + "curated aliases return only through the explicit Reset Defaults action"); + } + + private static JsonObject rootWithSulfurConflict() { + JsonObject root = root(); + addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", "minecraft:overworld"); + addOre(root, "baseminerals:sulfur", "baseminerals", "minecraft:gold_ore", "minecraft:overworld"); + return root; + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("ores", new JsonObject()); + return root; + } + + private static void addOre(JsonObject root, String id, String owner, String block, String dimension) { + addOre(root, id, owner, block, "orespawn:sulfur", dimension, null); + } + + private static void addOre(JsonObject root, String id, String owner, String block, + String material, String dimension, String channel) { + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("block", block); + ore.addProperty("material", material); + ore.addProperty("source_provider", owner); + JsonObject dimensions = new JsonObject(); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "vein"); + if (channel != null) rule.addProperty("placement_channel", channel); + dimensions.add(dimension, rule); + ore.add("dimensions", dimensions); + root.getAsJsonObject("ores").add(id, ore); + } + + private static JsonObject policy(JsonObject root) { + JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION); + assertEquals(1, policies.entrySet().size()); + return policies.entrySet().iterator().next().getValue().getAsJsonObject(); + } +} 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 a14854d1..45d9187a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java @@ -7,12 +7,15 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.Collections; import java.util.Random; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; import net.minecraft.util.ResourceLocation; @@ -66,6 +69,134 @@ void managedWritesDoNotLeakReusablePositionsIntoForgeTickData() throws Exception assertFalse(source.contains("world.setBlockState(cursor, output, GENERATION_WRITE_FLAGS)")); } + @Test + void normalGenerationAndRetrogenShareStableExtendedIdentityInputs() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "OreSpawnOreGeneration.java")), + StandardCharsets.UTF_8); + assertTrue(source.contains("generateChunk(level, world, chunk")); + assertTrue(source.contains("generateChunk(level, null, chunk")); + assertTrue(source.contains("dimension, worldSeed,\n\t\t\t\tattemptIndex, geologySampler")); + assertTrue(source.contains("this.chunkX = chunkPos.chunkXPos")); + assertTrue(source.contains("this.chunkZ = chunkPos.chunkZPos")); + } + + @Test + void wholeBodyOutputSelectionIgnoresChunkOrder() { + ResourceLocation material = new ResourceLocation("orespawn:sulfur"); + ResourceLocation channel = new ResourceLocation("realisticdeposits:district"); + long body = 0x1234ABCD5678EF90L; + double first = OreSpawnOreGeneration.outputSelectionSample(42L, WorldIds.OVERWORLD, + material, channel, body); + double fromAnotherChunk = OreSpawnOreGeneration.outputSelectionSample(42L, WorldIds.OVERWORLD, + material, channel, body); + assertEquals(first, fromAnotherChunk); + } + + @Test + void ordinaryAttemptsIncludeChunkRuleChannelAndAttemptIdentity() { + ResourceLocation rule = new ResourceLocation("examplemod:sulfur"); + ResourceLocation channel = new ResourceLocation("orespawn:standard"); + long first = OreSpawnOreGeneration.ordinaryOutputIdentity(new ChunkPos(1, 2), 0, rule, channel); + assertEquals(first, OreSpawnOreGeneration.ordinaryOutputIdentity( + new ChunkPos(1, 2), 0, rule, channel)); + assertFalse(first == OreSpawnOreGeneration.ordinaryOutputIdentity( + new ChunkPos(2, 1), 0, rule, channel)); + assertFalse(first == OreSpawnOreGeneration.ordinaryOutputIdentity( + new ChunkPos(1, 2), 1, rule, channel)); + } + + @Test + void placementChannelsAreArbitratedIndependentlyWithoutFallback() { + ResourceLocation material = new ResourceLocation("orespawn:iron"); + ResourceLocation standard = new ResourceLocation("orespawn:standard"); + ResourceLocation district = new ResourceLocation("realisticdeposits:district"); + ResourceLocation ordinary = new ResourceLocation("basemetals:iron"); + ResourceLocation large = new ResourceLocation("realisticdeposits:iron_district"); + JsonObject root = new JsonObject(); + JsonObject policies = new JsonObject(); + JsonObject policy = new JsonObject(); + policy.addProperty("mode", "consolidated"); + JsonObject placements = new JsonObject(); + placements.addProperty(standard.toString(), ordinary.toString()); + placements.addProperty(district.toString(), large.toString()); + placements.addProperty("missing:channel", "missing:rule"); + policy.add("placement_sources", placements); + policies.add(OreSourcePolicies.key(material, WorldIds.OVERWORLD), policy); + root.add(OreSourcePolicies.SECTION, policies); + Map available = new LinkedHashMap<>(); + available.put(ordinary, standard); + available.put(large, district); + + Map selected = OreSpawnOreGeneration + .selectedPlacementRules(root, material, WorldIds.OVERWORLD, available); + assertEquals(2, selected.size()); + assertEquals(ordinary, selected.get(standard)); + assertEquals(large, selected.get(district)); + assertFalse(selected.containsKey(new ResourceLocation("missing:channel"))); + } + + @Test + void selectorPoliciesRemainKeyedByTheirSelectorDomain() { + ResourceLocation material = new ResourceLocation("orespawn:copper"); + ResourceLocation standard = new ResourceLocation("orespawn:standard"); + ResourceLocation source = new ResourceLocation("basemetals:copper"); + ResourceLocation selector = new ResourceLocation("orespawn:all_except_nether_end"); + JsonObject root = new JsonObject(); + JsonObject policies = new JsonObject(); + JsonObject policy = new JsonObject(); + policy.addProperty("mode", "consolidated"); + JsonObject placements = new JsonObject(); + placements.addProperty(standard.toString(), source.toString()); + policy.add("placement_sources", placements); + policies.add(OreSourcePolicies.key(material, selector), policy); + root.add(OreSourcePolicies.SECTION, policies); + Map available = new LinkedHashMap<>(); + available.put(source, standard); + + assertEquals(source, OreSpawnOreGeneration.selectedPlacementRules( + root, material, selector, available).get(standard)); + assertEquals(null, OreSpawnOreGeneration.selectedPlacementRules( + root, material, WorldIds.OVERWORLD, available)); + } + + @Test + void keepSeparatePolicyLeavesEveryPlacementRuleUntouched() { + ResourceLocation material = new ResourceLocation("orespawn:sulfur"); + JsonObject root = new JsonObject(); + JsonObject policies = new JsonObject(); + JsonObject policy = new JsonObject(); + policy.addProperty("mode", "keep_separate"); + policies.add(OreSourcePolicies.key(material, WorldIds.OVERWORLD), policy); + root.add(OreSourcePolicies.SECTION, policies); + + assertEquals(null, OreSpawnOreGeneration.selectedPlacementRules(root, material, + WorldIds.OVERWORLD, Collections.emptyMap())); + } + + @Test + void weightedOutputSelectionUsesPositiveRelativeWeights() { + assertEquals(0, OreSpawnOreGeneration.weightedIndex(0.0D, 1.0D, 3.0D)); + assertEquals(0, OreSpawnOreGeneration.weightedIndex(0.249999D, 1.0D, 3.0D)); + assertEquals(1, OreSpawnOreGeneration.weightedIndex(0.25D, 1.0D, 3.0D)); + assertEquals(1, OreSpawnOreGeneration.weightedIndex(0.999999D, 1.0D, 3.0D)); + assertEquals(-1, OreSpawnOreGeneration.weightedIndex(0.5D, 0.0D, -1.0D)); + } + + @Test + void stableOutputSelectionRespectsConfiguredWeightDistribution() { + ResourceLocation material = new ResourceLocation("orespawn:sulfur"); + ResourceLocation channel = new ResourceLocation("orespawn:standard"); + int first = 0; + for (long identity = 0; identity < 10000; identity++) { + double sample = OreSpawnOreGeneration.outputSelectionSample( + 42L, WorldIds.OVERWORLD, material, channel, identity); + if (OreSpawnOreGeneration.weightedIndex(sample, 1.0D, 3.0D) == 0) first++; + } + assertTrue(first > 2200 && first < 2800, + "one-to-three weights should select the first source about one quarter of the time: " + first); + } + @Test void fixedQuantityDoesNotConsumeRandomState() { CountingRandom random = new CountingRandom(0); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java index 578e6f1b..1f4e13df 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibilityTest.java @@ -49,6 +49,15 @@ void compatibilityDoesNotRewriteStaticBiomeFeatureGraphs() throws Exception { assertFalse(source.contains("BiomeDecorator")); } + @Test + void independentChunkGeneratorsKeepTheirOwnSpringPass() throws Exception { + String source = source(); + assertTrue(source.contains("instanceof ChunkProviderOverworld")); + assertTrue(source.indexOf("instanceof ChunkProviderOverworld") + < source.indexOf("event.getRand()"), + "an unsupported generator must be rejected before OreSpawn consumes its random stream"); + } + @Test void springChecksEveryNeighbourIsLoadedBeforeReadingItsState() throws Exception { String source = source(); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java index 286327e7..495e4a09 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java @@ -18,11 +18,18 @@ import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Algorithm; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; +import zone.moddev.mc.orespawn.test.Forge12TestBootstrap; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; class WorldGeologyProfileTest { + @BeforeAll + static void bootstrapMinecraft() { + Forge12TestBootstrap.registerVanilla(); + } + @Test void schemaThreeOilMigratesToNamedFluidDeposit() { JsonObject legacy = completeGlobalFixture(); @@ -122,6 +129,75 @@ void schemaOneMigrationPreservesShapeAndSnapshotsGlobalGeology() { assertFalse(result.has("oil")); } + @Test + void freshProfilesConsolidateReviewedConflictsButUpgradesStaySeparate() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + JsonObject freshPolicy = onlyPolicy(fresh.toJson()); + assertEquals("consolidated", freshPolicy.get("mode").getAsString()); + assertEquals("balanced", freshPolicy.get("output_mode").getAsString()); + assertEquals(2, freshPolicy.getAsJsonObject("outputs").entrySet().size()); + + JsonObject legacy = JsonCopies.copy(global); + legacy.addProperty("schema_version", 5); + legacy.remove(OreSourcePolicies.SECTION); + WorldGeologyProfile upgraded = WorldGeologyProfile.fromJson(legacy, fresh); + JsonObject upgradedPolicy = onlyPolicy(upgraded.toJson()); + assertEquals("keep_separate", upgradedPolicy.get("mode").getAsString()); + assertEquals(2, upgradedPolicy.getAsJsonObject("outputs").entrySet().size()); + assertTrue(upgraded.toJson().has(OreMaterialGroups.SECTION)); + assertEquals(WorldGeologyProfile.SCHEMA_VERSION, + upgraded.toJson().get("schema_version").getAsInt()); + } + + @Test + void schemaSixConsolidatedPoliciesInferBalancedOutputMode() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + JsonObject oldWorld = fresh.rootCopy(); + oldWorld.addProperty("schema_version", 6); + oldWorld.remove(OreMaterialGroups.SECTION); + onlyPolicy(oldWorld).remove("output_mode"); + + WorldGeologyProfile migrated = WorldGeologyProfile.fromJson(oldWorld, fresh); + + assertEquals(7, migrated.toJson().get("schema_version").getAsInt()); + assertEquals("balanced", onlyPolicy(migrated.toJson()).get("output_mode").getAsString()); + assertTrue(migrated.toJson().has(OreMaterialGroups.SECTION)); + } + + @Test + void schemaOneUpgradeDoesNotInheritFreshWorldConsolidation() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fallback = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + assertEquals("consolidated", onlyPolicy(fallback.toJson()).get("mode").getAsString()); + JsonObject schemaOne = new JsonObject(); + schemaOne.addProperty("schema_version", 1); + schemaOne.addProperty("geology_mode", "geome"); + + WorldGeologyProfile upgraded = WorldGeologyProfile.fromJson(schemaOne, fallback); + assertEquals("keep_separate", onlyPolicy(upgraded.toJson()).get("mode").getAsString()); + } + + @Test + void existingUnprofiledWorldDoesNotInheritFreshWorldConsolidation() { + JsonObject global = completeGlobalFixture(); + addSulfurConflict(global); + WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig( + global, GeologyMode.GEOME, false); + assertEquals("consolidated", onlyPolicy(fresh.toJson()).get("mode").getAsString()); + + WorldGeologyProfile upgraded = WorldGeologyProfileManager + .initializeNewWorldPolicies(fresh, true); + assertEquals("keep_separate", onlyPolicy(upgraded.toJson()).get("mode").getAsString()); + } + @Test void schemaTwoRoundTripKeepsExternalAndProviderData() { JsonObject root = completeGlobalFixture(); @@ -525,6 +601,31 @@ private static JsonObject oreDefaultsFixture(double frequency) { return objectWith("ores", objectWith("minecraft:coal_ore", coal)); } + private static void addSulfurConflict(JsonObject root) { + JsonObject ores = root.getAsJsonObject("ores"); + addSulfurOre(ores, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore"); + addSulfurOre(ores, "baseminerals:sulfur", "baseminerals", "minecraft:gold_ore"); + } + + private static void addSulfurOre(JsonObject ores, String id, String owner, String block) { + JsonObject ore = new JsonObject(); + ore.addProperty("enabled", true); + ore.addProperty("block", block); + ore.addProperty("material", "orespawn:sulfur"); + ore.addProperty("source_provider", owner); + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("pattern", "vein"); + ore.add("dimensions", objectWith("minecraft:overworld", rule)); + ores.add(id, ore); + } + + private static JsonObject onlyPolicy(JsonObject root) { + JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION); + assertEquals(1, policies.entrySet().size()); + return policies.entrySet().iterator().next().getValue().getAsJsonObject(); + } + private static JsonObject oreRule(int minY, int maxY, double frequency, int quantity, String pattern, String distribution, double discardChance) { JsonObject rule = new JsonObject(); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java index d772b36f..e933d46c 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmarkTest.java @@ -1,6 +1,7 @@ package zone.moddev.mc.orespawn.worldgen; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; import java.nio.charset.StandardCharsets; @@ -32,6 +33,20 @@ void vanillaControlBypassesEveryForge112GenerationEntryPoint() throws Exception "terrain, springs, ores, ore filtering, and IWorldGenerator must all be bypassed"); } + @Test + void integrationBenchmarkCanProveNormalizedBlockAndBiomeParity() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "worldgen", "WorldgenBenchmark.java")), + StandardCharsets.UTF_8); + assertTrue(source.contains("orespawn.worldgenBenchmarkNormalizeBlock")); + assertTrue(source.contains("orespawn.worldgenBenchmarkNormalizeBiome")); + assertTrue(source.contains("ORESPAWN_BENCHMARK_HASH")); + assertTrue(source.contains("orespawn.worldgenBenchmarkBiome")); + assertTrue(source.indexOf("ORESPAWN_BENCHMARK summary") + < source.indexOf("auditNormalizedHashes(level"), + "hashing must happen outside the measured generation interval"); + } + private static int occurrences(String value, String needle) { int result = 0; for (int index = 0; (index = value.indexOf(needle, index)) >= 0; index += needle.length()) {