diff --git a/.gitattributes b/.gitattributes index d8b984a8..2d0b0e9f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,6 @@ * text=auto eol=lf *.bat text eol=crlf -#*.bat text eol=lf *.gradle text diff=java mcmod.info text *.java text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b56856eb..e5277c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,9 +73,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.120062.jar - build/libs/OreSpawn-4.0.16.120062-sources.jar - build/libs/OreSpawn-4.0.16.120062-javadoc.jar + build/libs/OreSpawn-4.1.0.120062.jar + build/libs/OreSpawn-4.1.0.120062-sources.jar + build/libs/OreSpawn-4.1.0.120062-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt - name: Upload diagnostics on failure diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 6c71313a..69ef7665 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,25 @@ +Version 4.1.0.120062 + +* Keep imported vanilla ore settings inactive while Manage Vanilla Ores is off. + When it is on, use the imported settings without a second default ore pass. +* Treat imported built-in patterns as ordinary veins while retaining saved + placement choices and independent custom channels. +* Show inactive sources separately from missing mods, without rewriting saved + rules or choices just to display them. +* Put Help & Guide beside Recommended Defaults and fit the world-creation tab + above the footer in smaller windows. +* Reject invalid geome names instead of silently changing their spelling. +* Recognise inline vanilla ore features and release their wrappers when a + server stops, while preserving shared feature identities across biomes. +* Add an isolated shared-ore client profile and real Nether fresh/reload checks. +* Use Minecraft Mod Development as the jar vendor. +* Keep generated Javadoc artwork and license notices identical on Windows and Linux. + +* Add provider extensions, the mod directory, ore-source groups and built-in MMD conflict defaults. +* Add the biome directory, exact new-terrain replacements and dimension material controls. +* Keep existing worlds' ore-source choices while starting new shared-tag MMD groups Balanced. +* Split the NeoGradle build into smaller commented scripts and keep tracked text in LF format. + Version 4.0.16.120062 * Leave benchmark shutdown to the GameTest harness when a benchmark is run diff --git a/README.md b/README.md index b6dac10e..d2cab5a9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. +This branch targets NeoForge 20.6.139 and Java 21. It keeps NeoForge's own +registration, biome modifiers and development runs separate from the Forge +build for the same Minecraft version. + ## What Happens When It Is Installed? OreSpawn is deliberately passive on its own. It does not replace stone, remove @@ -36,6 +40,10 @@ 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. +Leave **Manage Vanilla Ores** off to keep normal Minecraft ore placement, +including when an older configuration has been imported. Turning it on uses +eligible imported settings instead of adding a second default placement pass. + Important files: | Location | Purpose | @@ -94,10 +102,10 @@ exported to `config/orespawn-guide/` without overwriting existing files. ## Building -This branch builds target-qualified version `4.0.16.120062`: the OreSpawn 4.0.16 +This branch builds target-qualified version `4.1.0.120062`: the OreSpawn 4.1 feature set for Minecraft 1.20.6 and NeoForge. -OreSpawn 4.0.16.120062 uses NeoGradle 7.1.38 and Gradle 9.2.1. Use exact +OreSpawn 4.1.0.120062 uses NeoGradle 7.1.38 and Gradle 9.2.1. Use exact Temurin 21.0.7+6 from the repository root: ```powershell @@ -117,7 +125,7 @@ Import or refresh the project with Eclipse Buildship. NeoGradle supplies the Eclipse model and run configurations through the `eclipse` task; this branch does not use ForgeGradle's `genEclipseRuns` task. -The audited public outputs are `OreSpawn-4.0.16.120062.jar`, its sources jar, +The audited public outputs are `OreSpawn-4.1.0.120062.jar`, its sources jar, and its Javadoc jar. CI additionally proves a cache-cold NeoGradle bootstrap and a same-cache offline rerun. diff --git a/build.gradle b/build.gradle index c6a019e0..c068d4d2 100644 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,14 @@ plugins { id 'net.neoforged.gradle.userdev' version '7.1.38' } +// NeoGradle's staged results must not mix with another Minecraft checkout. +// Keep downloaded dependencies shared, but store execution results locally. +gradle.sharedServices.registrations.named('CachedExecutionService') { + parameters.cacheDirectory.set(providers.gradleProperty( + 'net.neoforged.gradle.caching.cacheDirectory').orElse( + layout.projectDirectory.dir('.gradle/neo-execution').asFile.absolutePath)) +} + version = mod_version group = mod_group_id @@ -56,10 +64,17 @@ tasks.withType(AbstractArchiveTask).configureEach { def archiveTextSuffixes = [ '.cfg', '.css', '.html', '.info', '.java', '.js', '.json', '.lang', - '.mcmeta', '.md', '.properties', '.toml', '.txt', '.xml' + '.mcmeta', '.md', '.properties', '.svg', '.toml', '.txt', '.xml' ] def archiveTextPatterns = archiveTextSuffixes.collect { "**/*${it}".toString() } -archiveTextPatterns.addAll(['**/element-list', '**/package-list']) +archiveTextPatterns.addAll(['**/element-list', '**/package-list', + '**/legal/LICENSE', '**/legal/ASSEMBLY_EXCEPTION', '**/legal/ADDITIONAL_LICENSE_INFO']) + +// A normalization policy change must rebuild the archives too. +tasks.withType(Jar).configureEach { + inputs.property('normalizedArchiveTextPatterns', archiveTextPatterns) +} + def normalizeArchiveLineEndings = { details -> details.filter(FixCrLfFilter, eol: FixCrLfFilter.CrLf.newInstance('lf'), @@ -167,6 +182,31 @@ runs { modSource project.sourceSets.main } } + + ['One', 'Both'].each { String scenario -> + ['Fresh', 'Reload', 'Missing', 'Restored'].each { String phase -> + if (scenario == 'One' && phase != 'Fresh' && phase != 'Reload') return + register("oreSource${scenario}${phase}") { + runType 'server' + workingDirectory layout.buildDirectory.dir("ore-source-${scenario.toLowerCase(Locale.ROOT)}-run") + systemProperty 'forge.logging.console.level', 'info' + systemProperty 'oresourceprobe.phase', phase.toLowerCase(Locale.ROOT) + argument '--nogui' + modSource project.sourceSets.main + } + } + } + + ['Fresh', 'Reload'].each { String phase -> + register("biomeOverride${phase}") { + runType 'server' + workingDirectory layout.buildDirectory.dir('biome-override-run') + systemProperty 'forge.logging.console.level', 'info' + systemProperty 'orespawn.biomeOverridePhase', phase.toLowerCase(Locale.ROOT) + argument '--nogui' + modSource project.sourceSets.main + } + } } configurations { @@ -182,28 +222,22 @@ def mineralogy5OracleJar = new File(fixtureRoot, def mineralogy5OracleSha256 = 'CCA84E9270585478B08F54BA091AD56AB2CB390386C650ED78B2673DC57403EB' -tasks.register('verifyLegacyFixtures') { - group = 'verification' - description = 'Verifies the sealed Mineralogy 1.18.2 5.4.0 oracle used only by isolated tests.' - inputs.file mineralogy5OracleJar - doLast { - if (!mineralogy5OracleJar.isFile()) { - throw new GradleException("Missing mandatory Mineralogy oracle: ${mineralogy5OracleJar}") - } - MessageDigest digest = MessageDigest.getInstance('SHA-256') - mineralogy5OracleJar.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 actual = digest.digest().encodeHex().toString().toUpperCase() - if (actual != mineralogy5OracleSha256) { - throw new GradleException("Mineralogy oracle checksum mismatch: ${actual}") - } - } -} +// Helpers shared by the small build scripts live here; tasks stay with their owner. +ext.oreSpawnBuild = [ + mineralogy5OracleJar: mineralogy5OracleJar, + mineralogy5OracleSha256: mineralogy5OracleSha256, + expectedMavenGroup: expectedMavenGroup, + expectedMavenArtifact: expectedMavenArtifact, + expectedMavenCoordinate: expectedMavenCoordinate, + archiveTextSuffixes: archiveTextSuffixes, + archiveTextPatterns: archiveTextPatterns, + normalizeArchiveLineEndings: normalizeArchiveLineEndings, + bundledDocumentationDirectory: bundledDocumentationDirectory, + prepareBundledDocumentation: prepareBundledDocumentation +] + +apply from: 'gradle/verification/support.gradle' dependencies { implementation "net.neoforged:neoforge:${neo_version}" @@ -235,10 +269,6 @@ tasks.withType(ProcessResources).configureEach { tasks.named('processResources', ProcessResources).configure { dependsOn prepareBundledDocumentation - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } } tasks.named('jar', Jar).configure { @@ -250,11 +280,11 @@ tasks.named('jar', Jar).configure { manifest { attributes([ 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', + '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', 'OreSpawn-Loader' : 'neoforge', 'FMLAT' : 'accesstransformer.cfg', @@ -312,813 +342,17 @@ tasks.named('compileTestJava', JavaCompile).configure { options.compilerArgs.add('-proc:none') } -tasks.register('verifyLegacyOracleIsolation') { - group = 'verification' - dependsOn tasks.named('verifyLegacyFixtures') - doLast { - configurations.findAll { it.canBeResolved }.each { configuration -> - if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { - throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") - } - } - } -} - -// Several registry-focused tests initialize the real global config singleton. -// Keep that target-native coverage without creating or changing a developer's -// checkout config as a side effect of `test` or `build`. -def unitTestWorldgenConfig = file('config/orespawn-worldgen.json') -def unitTestWorldgenConfigWasPresent = false -byte[] unitTestWorldgenConfigBytes = null -tasks.named('test', Test).configure { - doFirst { - unitTestWorldgenConfigWasPresent = unitTestWorldgenConfig.isFile() - unitTestWorldgenConfigBytes = unitTestWorldgenConfigWasPresent - ? unitTestWorldgenConfig.bytes : null - } -} -def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenConfig') { - doLast { - if (unitTestWorldgenConfigWasPresent) { - byte[] after = unitTestWorldgenConfig.isFile() ? unitTestWorldgenConfig.bytes : null - if (after == null || !java.util.Arrays.equals(unitTestWorldgenConfigBytes, after)) { - unitTestWorldgenConfig.parentFile.mkdirs() - unitTestWorldgenConfig.bytes = unitTestWorldgenConfigBytes - throw new GradleException('Unit tests changed config/orespawn-worldgen.json; the original was restored') - } - } else if (unitTestWorldgenConfig.isFile()) { - delete unitTestWorldgenConfig - } - } -} -tasks.named('test') { - finalizedBy preserveDeveloperWorldgenConfig -} - -// A NeoForge process is not green merely because it returns exit code zero. -// The loader can log a worldgen/linkage failure and still shut down normally. -// NeoForge 20.6 probes the macOS kqueue and Linux epoll transports on Windows. -// Their bounded DEBUG/Appender stack blocks are harmless when followed by the -// normal NIO fallback. Do not broaden this to standalone initializer/linkage -// failures or to any other ERROR/FATAL record. - -def runtimeCrashSnapshot = { File runDirectory -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (!crashDirectory.isDirectory()) return [] as Set - return fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() }.collect { it.absolutePath } as Set -} - -def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (crashDirectory.isDirectory()) { - def crashes = fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } - if (!crashes.isEmpty()) { - throw new GradleException("${context} produced crash report ${crashes.first()}") - } - } - - File logsDirectory = new File(runDirectory, 'logs') - if (!logsDirectory.isDirectory()) return - def failures = [] - [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] - .findAll { it.isFile() }.each { File log -> - int lineNumber = 0 - boolean nativeTransportFallback = false - log.eachLine('UTF-8') { String line -> - lineNumber++ - boolean nativeTransportStart = - line.contains('[io.ne.ch.kq.KQueue/]: KQueue support is not available') || - line.contains('[io.ne.ch.ep.Epoll/]: Epoll support is not available') - boolean nativeTransportAppender = nativeTransportFallback && - line.contains('Server thread ERROR An exception occurred processing Appender DebugFile') - boolean startsLogRecord = line ==~ /^\[[0-9]{2}:[0-9]{2}:[0-9]{2}\].*/ || - line ==~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T.* (?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL) .*/ - if (startsLogRecord && !nativeTransportStart && !nativeTransportAppender) { - nativeTransportFallback = false - } - if (nativeTransportStart) nativeTransportFallback = true - boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ - boolean knownNoise = nativeTransportFallback - boolean fatalText = line.contains('Encountered an unexpected exception') || - line.contains('Exception stopping the server') || - line.contains('Migration audit failed') || - line.contains('java.lang.Error:') || - line.contains('NoSuchMethodError') || - line.contains('NoClassDefFoundError') || - line.contains('ExceptionInInitializerError') || - line.contains('Tried to assign a mutable BlockPos') || - line.contains('causing cascading worldgen lag') - if ((unexpectedSeverity || fatalText) && !knownNoise) { - failures.add("${log.name}:${lineNumber}: ${line}") - } - } - } - if (!failures.isEmpty()) { - throw new GradleException("${context} logged unexpected errors:\n" - + failures.take(20).join('\n')) - } -} - -tasks.register('runtimeLogScannerTest') { - group = 'verification' - description = 'Proves runtime log validation rejects real NeoForge failures.' - doLast { - File probe = file("${buildDir}/runtime-log-scanner-test") - delete probe - File logs = new File(probe, 'logs'); logs.mkdirs() - new File(logs, 'latest.log').setText( - '[Server thread/INFO] [neoforge]: Done\n', 'UTF-8') - assertRuntimeLogsClean(probe, 'scanner-clean-log-probe', [] as Set) - new File(logs, 'latest.log').setText( - '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') - boolean rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') - new File(logs, 'latest.log').setText( - '[Server thread/DEBUG] [neoforge]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') - new File(logs, 'latest.log').setText( - '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') - new File(logs, 'latest.log').setText( - '[10:00:00] [Server thread/DEBUG] [io.ne.ch.kq.KQueue/]: KQueue support is not available\n' + - 'java.lang.ExceptionInInitializerError: null\n' + - '2026-08-17T10:00:00Z Server thread ERROR An exception occurred processing Appender DebugFile\n' + - 'Caused by: java.lang.NoClassDefFoundError: Could not initialize class io.netty.channel.kqueue.Native\n' + - '[10:00:01] [Server thread/DEBUG] [io.ne.ch.ni.NioIoHandler/]: NIO fallback active\n', 'UTF-8') - assertRuntimeLogsClean(probe, 'scanner-windows-native-transport-probe', [] as Set) - new File(logs, 'latest.log').setText( - '[Server thread/ERROR] [example]: java.lang.ExceptionInInitializerError: real failure\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-standalone-initializer-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted a standalone initializer failure') - delete probe - } -} - -tasks.named('check') { - dependsOn 'runtimeLogScannerTest' -} - -tasks.register('verifyMineralogyOracleIsolation') { - group = 'verification' - description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' - doLast { - configurations.each { configuration -> - if (configuration.canBeResolved && - configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { - throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") - } - } - } -} - -tasks.named('check') { - dependsOn 'verifyMineralogyOracleIsolation' -} - -['runClient', 'runServer', 'runGameTestServer', 'runClientData', 'runData'].each { String taskName -> - tasks.matching { it.name == taskName }.configureEach { JavaExec runTask -> - doFirst { - new File(runTask.workingDir, 'mods').mkdirs() - if (taskName == 'runGameTestServer') { - File serverProperties = new File(runTask.workingDir, 'server.properties') - if (!serverProperties.isFile()) { - serverProperties.setText('# Generated for the OreSpawn GameTest gate\n', 'UTF-8') - } - } - runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) - } - doLast { - assertRuntimeLogsClean(runTask.workingDir, taskName, - runTask.ext.oreSpawnCrashSnapshot as Set) - } - } -} - -def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') -def compileSurfaceIntegrationTestMod = tasks.register('compileSurfaceIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/biomeIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(surfaceIntegrationClasses) - javaCompiler.set(javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(21) - }) - options.release = 21 - options.encoding = 'UTF-8' -} - -def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { - dependsOn compileSurfaceIntegrationTestMod - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' -} - -def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') -def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { - dependsOn surfaceIntegrationTestModJar - doLast { - File runDirectory = surfaceIntegrationRunDirectory.get().asFile - delete runDirectory - runDirectory.mkdirs() - copy { - from surfaceIntegrationTestModJar.flatMap { it.archiveFile } - into surfaceIntegrationRunDirectory.map { it.dir('mods') } - } - new File(runDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=0 -level-type=minecraft:normal -online-mode=false -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -tasks.configureEach { - if (name == 'runSurfaceIntegrationFresh') { - dependsOn prepareSurfaceIntegrationTest - } else if (name == 'runSurfaceIntegrationReload') { - dependsOn 'runSurfaceIntegrationFresh' - } - if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { - doFirst { - ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) - } - doLast { - assertRuntimeLogsClean(workingDir, "NeoForge 1.20.6 ${name}", - ext.oreSpawnCrashSnapshot as Set) - } - } -} - -def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { - group = 'verification' - description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' - dependsOn 'runSurfaceIntegrationReload' - doLast { - File marker = surfaceIntegrationRunDirectory.get().file( - 'surface-integration-world/surfaceprobe-integration.properties').asFile - if (!marker.isFile()) { - throw new GradleException("Surface integration completion marker is missing: ${marker}") - } - Properties result = new Properties() - marker.withInputStream { result.load(it) } - if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Surface integration reload was not verified: ${marker}") - } - logger.lifecycle('Provider surfaces and dynamic-biome geology verified: {} dimensions, {} columns each, fresh + reload', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) - } -} - -tasks.named('check') { - dependsOn surfaceIntegrationTest -} - -def clientIntegrationClasses = layout.buildDirectory.dir('client-integration-fixture/classes') -def compileClientIntegrationTestMod = tasks.register('compileClientIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/clientIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(clientIntegrationClasses) - options.release = 21 - options.encoding = 'UTF-8' -} -def clientIntegrationTestModJar = tasks.register('clientIntegrationTestModJar', Jar) { - dependsOn compileClientIntegrationTestMod - archiveFileName = 'clientprobe.jar' - destinationDirectory = layout.buildDirectory.dir('client-integration-fixture') - from clientIntegrationClasses - from 'src/clientIntegrationTest/resources' -} - -def benchmarkIntegrationClasses = layout.buildDirectory.dir('benchmark-integration-fixture/classes') -def compileBenchmarkIntegrationTestMod = tasks.register('compileBenchmarkIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/benchmarkIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(benchmarkIntegrationClasses) - options.release = 21 - options.encoding = 'UTF-8' -} -def benchmarkIntegrationTestModJar = tasks.register('benchmarkIntegrationTestModJar', Jar) { - dependsOn compileBenchmarkIntegrationTestMod - archiveFileName = 'benchmarkprobe.jar' - destinationDirectory = layout.buildDirectory.dir('benchmark-integration-fixture') - from benchmarkIntegrationClasses - from 'src/benchmarkIntegrationTest/resources' -} - -def releaseJar = tasks.named('jar', Jar) -def expectedReleaseFiles = providers.provider { - String prefix = "${base.archivesName.get()}-${project.version}" - ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] -} -def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') - -tasks.named('assemble') { - dependsOn releaseJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - doLast { - if (project.mod_version != '4.0.16.120062' - || project.mod_group_id != expectedMavenGroup - || project.minecraft_version != '1.20.6' - || project.neo_version != '20.6.139') { - throw new GradleException('Unexpected OreSpawn NeoForge 1.20.6 release identity') - } - if (project.loader_name != 'neoforge' || project.loader_code != '2' - || project.java_version != '21' || project.gradle_java_version != '21' - || project.java_toolchain_version != '21.0.7+6' - || project.java_setup_version != '21.0.7+6.0.LTS') { - throw new GradleException('Unexpected dispatcher or Java target metadata') - } - List expectedPublicArtifacts = [ - 'OreSpawn-4.0.16.120062.jar', - 'OreSpawn-4.0.16.120062-sources.jar', - 'OreSpawn-4.0.16.120062-javadoc.jar' - ] - if (base.archivesName.get() != expectedMavenArtifact - || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { - throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') - } - String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') - expectedPublicArtifacts.each { artifactName -> - if (!ciWorkflow.contains("build/libs/${artifactName}")) { - throw new GradleException("CI does not upload expected public artifact ${artifactName}") - } - } - [ - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', - 'README.md', 'CHANGELOG.txt' - ].each { path -> - if (!file(path).getText('UTF-8').contains(project.mod_version)) { - throw new GradleException("Release identity missing from ${path}") - } - } - if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { - throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') - } - } -} - -def trackedDocumentationDirectory = file('docs') -def documentationFiles = { - fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { - trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') }.sort() -} -def assertDocumentationTree = { File root, List expected, String label -> - List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } - .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') }.sort() : [] - if (actual != expected) { - throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") - } - expected.each { relative -> - if (!java.util.Arrays.equals(new File(trackedDocumentationDirectory, relative).bytes, - new File(root, relative).bytes)) { - throw new GradleException("${label}/${relative} differs from tracked documentation") - } - } -} - -def prepareEclipseResources = tasks.register('prepareEclipseResources', Sync) { - dependsOn tasks.named('processResources') - from layout.buildDirectory.dir('resources/main') - into file('bin/main') -} - -def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { - group = 'verification' - dependsOn prepareBundledDocumentation - dependsOn tasks.named('processResources') - dependsOn prepareEclipseResources - dependsOn releaseJar - doLast { - List expected = documentationFiles() - if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { - throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") - } - assertDocumentationTree(bundledDocumentationDirectory.asFile, expected, 'generated resources') - assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, - 'META-INF/orespawn/docs'), expected, 'processed resources') - assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), expected, 'Eclipse bin/main') - new ZipFile(releaseJar.get().archiveFile.get().asFile).withCloseable { zip -> - expected.each { relative -> - def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") - if (entry == null || !java.util.Arrays.equals( - new File(trackedDocumentationDirectory, relative).bytes, - zip.getInputStream(entry).withCloseable { it.bytes })) { - throw new GradleException("Release jar documentation differs at ${relative}") - } - } - } - } -} - -tasks.register('verifyReleaseArtifacts') { - group = 'verification' - dependsOn tasks.named('verifyReleaseConfiguration') - dependsOn verifyDocumentationParity - dependsOn tasks.named('assemble') - dependsOn tasks.named('verifyLegacyOracleIsolation') - doLast { - File libs = layout.buildDirectory.dir('libs').get().asFile - List jars = (libs.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() - if (jars.collect { it.name } != expected) { - throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") - } - jars.each { candidate -> - if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") - new ZipFile(candidate).withCloseable { zip -> - zip.entries().findAll { entry -> - !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } - || entry.name.endsWith('/element-list') - || entry.name.endsWith('/package-list')) - }.each { entry -> - boolean cr = zip.getInputStream(entry).withCloseable { - input -> input.bytes.any { value -> value == 13 } - } - if (cr) throw new GradleException("${candidate.name}!/${entry.name} is not LF-normalized") - } - [ - 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', - 'src/benchmarkIntegrationTest/', 'agent-notes/', 'surfaceprobe', - 'clientprobe', 'benchmarkprobe', 'ci-fixtures/', 'net/minecraftforge/', - 'META-INF/mods.toml', 'org/junit/', 'org/mockito/', 'net/bytebuddy/', - 'Mineralogy-1.18.2-5.4.0.jar' - ].each { forbidden -> - if (zip.entries().any { it.name.contains(forbidden) }) { - throw new GradleException("${candidate.name} contains forbidden ${forbidden}") - } - } - } - } - - File mainJar = new File(libs, expectedReleaseFiles.get()[0]) - new ZipFile(mainJar).withCloseable { zip -> - List names = zip.entries().collect { it.name } - [ - 'META-INF/neoforge.mods.toml', - 'META-INF/accesstransformer.cfg', - 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', - 'META-INF/orespawn/docs/VERSIONS.md', - 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', - 'AGENTS.md' - ].each { required -> - if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") - } - String metadata = zip.getInputStream(zip.getEntry('META-INF/neoforge.mods.toml')) - .getText(StandardCharsets.UTF_8.name()) - if (!metadata.contains('modId="orespawn"') - || !metadata.contains("version=\"${project.version}\"") - || !metadata.contains('versionRange="[20.6.139,20.7)"')) { - throw new GradleException('Packaged NeoForge metadata is incorrect') - } - String transformer = zip.getInputStream(zip.getEntry('META-INF/accesstransformer.cfg')) - .getText(StandardCharsets.UTF_8.name()) - List actualRules = transformer.readLines() - .collect { it.replaceFirst(/\s*#.*/, '').trim() }.findAll { !it.isEmpty() } - List expectedRules = [ - 'public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource', - 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker', - 'public-f net.minecraft.world.level.biome.Biome generationSettings', - 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration validBlocks', - 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabManager', - 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabNavigationBar' - ] - if (actualRules != expectedRules) { - throw new GradleException("Unexpected packaged official-named access transformer: ${actualRules}") - } - def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') - def manifest = manifestEntry == null ? null : - new Manifest(zip.getInputStream(manifestEntry)).mainAttributes - if (manifest == null - || manifest.getValue('Implementation-Version') != project.mod_version - || manifest.getValue('OreSpawn-API-Version') != '1' - || manifest.getValue('OreSpawn-Loader') != 'neoforge' - || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate - || manifest.getValue('Implementation-Timestamp') != null) { - throw new GradleException('Release manifest is incorrect or volatile') - } - zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> - byte[] header = zip.getInputStream(entry).withCloseable { it.readNBytes(8) } - int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 65) throw new GradleException("${entry.name} uses class major ${major}, expected 65") - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> - if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { - throw new GradleException('Sources jar is missing OreSpawn.java') - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> - if (zip.getEntry('index.html') == null - || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { - throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') - } - } - } -} - -tasks.register('writeReleaseChecksums') { - group = 'verification' - dependsOn tasks.named('verifyReleaseArtifacts') - def outputFile = layout.buildDirectory.file('release/SHA256SUMS') - outputs.file outputFile - doLast { - File output = outputFile.get().asFile - output.parentFile.mkdirs() - File libs = layout.buildDirectory.dir('libs').get().asFile - String contents = expectedReleaseFiles.get().sort().collect { name -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - new File(libs, name).withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" - }.join('\n') + '\n' - output.setText(contents, 'UTF-8') - } -} - -tasks.register('verifyPreparedReleaseArtifacts') { - group = 'verification' - doLast { - if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') - File prepared = file(preparedReleaseDir.get()) - List expected = expectedReleaseFiles.get().collect { it.toString() }.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 jars ${jars*.name} do not match ${expected}") - } - File checksums = new File(prepared, 'SHA256SUMS') - if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { - throw new GradleException('Prepared release is missing checksums or changelog') - } - } -} - -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 { - from components.java - } - pom { - name = 'MMD OreSpawn' - description = project.mod_description - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - licenses { - license { - name = 'GNU Lesser General Public License, Version 2.1' - url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' - } - } - } - } - } - repositories { - maven { - name = 'release' - url = uri(mavenUploadUrl.get()) - credentials { - username = mavenUploadUsername.orNull ?: '' - password = mavenUploadPassword.orNull ?: '' - } - } - } -} - -tasks.register('verifyMavenCoordinates') { - group = 'verification' - dependsOn tasks.named('generatePomFileForMavenJavaPublication') - doLast { - File pomFile = layout.buildDirectory.file( - 'publications/mavenJava/pom-default.xml').get().asFile - def pom = new XmlSlurper(false, false).parse(pomFile) - def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] - def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] - if (project.group.toString() != expectedMavenGroup || actual != expected) { - throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") - } - } -} - -tasks.register('validateMavenReleaseCredentials') { - group = 'publishing' - doLast { - if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() - || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { - throw new GradleException( - 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') - } - if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { - throw new GradleException('Release publication must use a remote repository') - } - } -} -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn preparedReleaseDir.isPresent() - ? tasks.named('verifyPreparedReleaseArtifacts') - : tasks.named('verifyReleaseArtifacts') -} - -tasks.register('verifyCommandPortability') { - group = 'verification' - description = 'Rejects shell-specific wrappers in generic Gradle task execution.' - doLast { - List commandSources = [file('build.gradle')] - commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) - commandSources.each { File source -> - String text = source.getText('UTF-8') - if (text =~ /(?i)(?:commandLine|executable)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { - throw new GradleException("Shell-specific command wrapper in ${source}") - } - } - } -} - -tasks.register('verifyColdNeoForgeBootstrapContract') { - group = 'verification' - description = 'Verifies that CI qualifies NeoGradle from an isolated Java 21 cache without Forge tooling.' - doLast { - String workflow = file('.github/workflows/ci.yml').getText('UTF-8') - [ - 'Cold NeoForge bootstrap', - 'orespawn-neoforge-cold-cache', - '--no-build-cache', - '--offline', - "java-version: '21.0.7+6.0.LTS'" - ].each { required -> - if (!workflow.contains(required)) { - throw new GradleException("Cold NeoForge bootstrap is missing ${required}") - } - } - ['MinecraftMavenizer', 'mavenizer', 'java-version: \'25', 'net.minecraftforge.gradle'].each { forbidden -> - if (workflow.contains(forbidden)) { - throw new GradleException("Cold NeoForge bootstrap contains Forge-only tooling: ${forbidden}") - } - } - } -} +apply from: 'gradle/verification/client-and-benchmark.gradle' +apply from: 'gradle/verification/ore-sources.gradle' +apply from: 'gradle/verification/biome-override.gradle' +apply from: 'gradle/verification/packaged-runtime.gradle' +apply from: 'gradle/verification/native-ores.gradle' +apply from: 'gradle/release/artifacts.gradle' +apply from: 'gradle/release/publishing.gradle' +apply from: 'gradle/ide/eclipse.gradle' tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') dependsOn tasks.named('verifyMavenCoordinates') dependsOn tasks.named('verifyColdNeoForgeBootstrapContract') } - -idea { - module { - downloadSources = true - downloadJavadoc = true - } -} - -eclipse { - project { - natures 'org.eclipse.buildship.core.gradleprojectnature' - buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' - } - classpath { - downloadSources = true - downloadJavadoc = true - file { - whenMerged { classpath -> - def libraryPaths = new LinkedHashSet() - classpath.entries = classpath.entries.findAll { entry -> - !(entry instanceof org.gradle.plugins.ide.eclipse.model.Library) - || libraryPaths.add(entry.path) - } - classpath.entries.removeAll { entry -> - entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder - && ['src/main/resources', 'src/generated/resources'].contains(entry.path) - } - if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { - classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( - 'build/resources/main', 'bin/main')) - } - } - } - } - synchronizationTasks 'prepareEclipseResources' -} - -tasks.named('eclipse').configure { - dependsOn prepareEclipseResources - doLast { - String requestedGradleHome = System.getenv('GRADLE_USER_HOME') - if (requestedGradleHome == null || requestedGradleHome.trim().isEmpty()) { - return - } - - File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') - Properties preferences = new Properties() - if (preferencesFile.isFile()) { - preferencesFile.withInputStream { preferences.load(it) } - } - String gradleHome = file(requestedGradleHome).canonicalPath - preferences.setProperty('eclipse.preferences.version', '1') - preferences.setProperty('connection.gradle.distribution', 'GRADLE_DISTRIBUTION(WRAPPER)') - preferences.setProperty('override.workspace.settings', 'true') - preferences.setProperty('gradle.user.home', gradleHome) - preferences.setProperty('connection.gradle.user.home', gradleHome) - preferences.setProperty('java.home', System.getProperty('java.home')) - preferencesFile.parentFile.mkdirs() - preferencesFile.withOutputStream { - preferences.store(it, 'Generated by the OreSpawn Eclipse task to keep NeoGradle launch modules on one cache.') - } - } -} - -tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - dependsOn tasks.named('eclipse') - dependsOn tasks.named('verifyLegacyOracleIsolation') - doLast { - File prefs = file('.settings/org.eclipse.buildship.core.prefs') - if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') - Properties preferences = new Properties() - prefs.withInputStream { preferences.load(it) } - if (preferences.getProperty('override.workspace.settings') != 'true') { - throw new GradleException('Eclipse Buildship workspace override is not active') - } - File eclipseClasspath = file('.classpath') - if (!eclipseClasspath.isFile()) throw new GradleException('Eclipse .classpath was not generated') - String classpathText = eclipseClasspath.getText('UTF-8') - if (!classpathText.contains('path="build/resources/main"') - || classpathText.contains('path="src/main/resources"') - || classpathText.contains('path="src/generated/resources"')) { - throw new GradleException('Eclipse must consume only Gradle-processed production resources') - } - ['META-INF/neoforge.mods.toml', 'META-INF/orespawn/docs/README.md', - 'META-INF/orespawn/docs/VERSIONS.md'].each { relative -> - if (!new File('bin/main', relative).isFile()) { - throw new GradleException("Eclipse output is missing ${relative}") - } - } - File metadata = file('bin/main/META-INF/neoforge.mods.toml') - String metadataText = metadata.getText('UTF-8') - if (metadataText.contains('\${mod_version}') - || !metadataText.contains("version=\"${project.version}\"")) { - throw new GradleException('Eclipse output contains unexpanded NeoForge metadata') - } - List forbidden = [ - 'src/test', 'bin/test', 'build/classes/java/test', - 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', - 'surfaceprobe', 'clientprobe', 'benchmarkprobe', 'junit-', 'opentest4j-', - 'Mineralogy-1.18.2-5.4.0.jar', 'net.minecraftforge', 'META-INF/mods.toml', - 'C:\\Users\\John' - ] - fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> - List leaked = forbidden.findAll { launch.getText('UTF-8').contains(it) } - if (!leaked.isEmpty()) { - throw new GradleException("${launch.name} exposes test/Forge/local content: ${leaked}") - } - } - } -} diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index 756056ab..00000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,17 +0,0 @@ -# OreSpawn Documentation Map - -This index is for navigating the documentation to learn how to integrate with -and use OreSpawn with a mod or modpack. -Start with [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). - -Use the focused guides for implementation details: - -- [API.md](API.md) for the supported Java API; -- [PROVIDERS.md](PROVIDERS.md) for packaged and configurable providers; -- [FEATURES.md](FEATURES.md) for rocks, ores, deposits, and geology; -- [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; -- [TEMPLATES.md](TEMPLATES.md) for selectable world styles; -- [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; -- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified - versioning, skipped functional releases, and branch-release convention; -- [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index 5154abdf..0c2d14d0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -45,8 +45,22 @@ coverage with `OreDefinition.Builder.dimensionSelector(...)` and `OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END`. Explicit dimensions override that selector and prevent duplicate placement. -The builder emits provider schema 4. Legacy provider schemas 1-3 remain -readable. Schema 4 is required for biome palettes and dimension materials. +The builder emits provider schema 5. Legacy provider schemas 1-4 remain +readable. Schema 4 is required for biome palettes and dimension materials; +schema 5 adds explicit material and placement-channel IDs. API major remains 1. + +For custom ore patterns, `OreGenerationContext` extends the existing +`OrePlacementContext` without changing its binary contract. It exposes the +world seed, dimension, chunk coordinates, and an optional geology sampler. +The `tryPlace(x, y, z, outputIdentity)` overload gives separated slices of one +logical deposit the same output choice. Code built against the older context +continues to work through the original `tryPlace` method. + +Client add-ons may register one optional configuration screen per mod through +`WorldSettingsExtensionRegistry.registerConfigScreen(modId, factory)` during +client initialization. OreSpawn shows it in the loaded-mod directory and +returns Escape to that directory. Do not load this client-only API on a +dedicated server. Provider-owned fluid deposits are declarative and may target several dimensions: diff --git a/docs/BIOMES.md b/docs/BIOMES.md index c3e23fc8..3ff93ffc 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -172,10 +172,18 @@ then lexical template ID order. ## World-Creation Editor -**Biomes & World Materials** is visible even when rock strata are disabled. -It lists palettes and materials by dimension, uses installed-registry pickers, -and validates IDs before world creation. The editor is creation-only in 4.0.0; -existing worlds remain editable through their self-contained server profile. +**Biomes** is visible even when rock strata are disabled. Its directory shows +loaded and referenced biomes once, with provider placement rules and effective +palette order. It can replace one loaded source biome with another in newly +generated terrain. The exact replacement layer runs after ordinary palettes; +existing chunks are never rewritten. A missing target stays dormant until its +mod returns. Palette settings expose mode, scope, region size, coverage, +fallback weight, and namespace filters without reordering provider palettes. + +The materials button belongs to the selected dimension, not the selected +biome. Aquifer fluid, snow, and ice substitutions apply across every biome in +that dimension. The deep-aquifer field is available where the target generator +exposes that fluid separately. ## Performance Boundaries diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 33235d97..018b6ec8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,13 +4,14 @@ OreSpawn uses three JSON contracts: | File | Schema | Purpose | |---|---:|---| -| `config/orespawn-worldgen.json` | 6 | Installed-pack defaults for new worlds | -| `/serverconfig/orespawn-worldgen.json` | 5 | Self-contained snapshot for one world | -| `config/-orespawn.json` | 4 | Optional authoritative provider override | +| `config/orespawn-worldgen.json` | 8 | Installed-pack defaults for new worlds | +| `/serverconfig/orespawn-worldgen.json` | 7 | Self-contained snapshot for one world | +| `config/-orespawn.json` | 5 | Optional authoritative provider override | -A provider may package schema 4 at `data//orespawn/provider.json`. -Legacy provider schemas 1-3 remain accepted. Fluid deposits require schema 3; -biome palettes and dimension materials require schema 4. +A provider may package schema 5 at `data//orespawn/provider.json`. +Legacy provider schemas 1-4 remain accepted. Fluid deposits require schema 3; +biome palettes and dimension materials require schema 4. Explicit ore material +and placement-channel IDs require schema 5. The profile for a new world is merged in this order: passive OreSpawn defaults, packaged or API providers, provider override files, the global configuration, @@ -29,7 +30,7 @@ report for the exact decision. | Field | Values | Meaning | |---|---|---| -| `schema_version` | Contract-specific integer | Global 6, world 5, provider 4 | +| `schema_version` | Contract-specific integer | Global 8, world 7, provider 5 | | `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine | | `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules | | `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features | @@ -44,11 +45,24 @@ report for the exact decision. | `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces | | `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions | | `ores` | object keyed by rule ID | Ore outputs and per-dimension placement | +| `ore_material_groups` | object keyed by material ID | Friendly names and exact block-tag aliases; imported Ore Dictionary names stay dormant | +| `ore_source_policies` | object keyed by material and dimension | Saved output mode, weights, and placement source per channel | | `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement | | `retrogen` | object | Bounded ore retrogen controls | | `flat_bedrock` | object | Opt-in flat bedrock controls | | `worldgen_aliases` | ID-to-ID object | Replacement output aliases resolved while baking | +Imported vanilla ore rules obey `manage_vanilla_ores`. With it off, their +settings remain saved but normal Minecraft placement is unchanged. With it on, +a valid, enabled import replaces the matching native default in its eligible +dimensions. Saved Ore Sources placement choices still take precedence. This +does not disable ordinary mod-provided ores or enable retrogen. + +Built-in imported patterns share the ordinary placement channel unless a rule +sets `placement_channel` explicitly. Older saved pattern-channel selections +remain valid without rewriting the profile. Unknown and third-party patterns +retain their independent channels. + ## Enumerated Values | Setting | Accepted values | @@ -178,6 +192,7 @@ Each enabled ore dimension uses: | `node_size` | 1-32 | Cluster node size | | `length` | 1-64 | Pattern path length where supported | | `fluid` | registry ID | Fluid used by `underfluids` | +| `placement_channel` | registry ID | Independent placement budget shared by equivalent ores in consolidated modes | At least one of `host_families`, `host_blocks`, or `host_tags` must be present. Hosts may be plain registry IDs or weighted objects such as @@ -195,6 +210,30 @@ The selector `orespawn:all_except_nether_end` covers every dimension except the vanilla Nether and End. Explicit rules in `dimensions` override selector rules for the same ore and dimension, including explicit disabled rules. +### Material groups and source policies + +Provider schema 5 may add a `material` ID to an ore. Otherwise OreSpawn infers +the material only from exact loaded block tags under `*/ores/*`; it does not +guess from a block or mod name. `ore_material_groups` stores a friendly name +and `block_tag_entries` for each group. Imported `ore_dictionary_entries` +remain dormant unless a user gives the group an exact tag mapping. + +`ore_source_policies` stores one material-and-dimension decision: Keep Original +(`keep_separate`) or consolidated Balanced, Single, or Custom output selection. +The `placement_sources` object names one source rule for each placement +channel. Consolidation uses that rule's frequency, shape, depth, and hosts, +then picks the selected output for a whole deposit. It does not give every +output its own placement budget. External generators are shown for context but +cannot be suppressed by OreSpawn. + +For a newly created world, two loaded ordinary MMD providers with the same +exact ore tag start Balanced. A configured Sulfur or Lithium priority chooses +the initial placement source; other conflicts use stable owner/rule-ID order. +Missing providers create no phantom ores. Existing worlds retain their saved +policy, defaulting to Keep Original on upgrade. Both global and world profile +upgrades keep a backup; editing the global defaults in the UI is saved only +when the main OreSpawn editor's Done is pressed. + ## Fluid Deposits, Retrogen, And Bedrock Each `fluid_deposits` entry has a stable provider-namespaced rule ID, an diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 61602dec..19456d5b 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -12,6 +12,8 @@ | Add covered underground oil or another fluid | Provider schema 3 fluid deposit | | Add or place biomes without a framework dependency | Provider schema 4 biome palette | | Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials | +| Share placement between equivalent ore blocks | Provider schema 5 material and placement channel, with one exact block tag | +| Add an OreSpawn-linked settings screen | Client-only `WorldSettingsExtensionRegistry` | | Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` | Strata are optional. If no enabled terrain dimension has eligible rocks, @@ -21,7 +23,7 @@ blocks or tags. ## Provider JSON Quick Start -Put a schema-4 file in your mod jar at: +Put a schema-5 file in your mod jar at: ```text src/main/resources/data/examplemod/orespawn/provider.json @@ -33,7 +35,7 @@ stone without enabling strata: ```json { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "ores": { diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 08a587f4..0f9e8563 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -16,6 +16,9 @@ Hover over unfamiliar controls for a short explanation. The same explanations are collected in **Help & Guide**, so a setting can be learned either while editing it or one topic at a time. +Help & Guide and Recommended Defaults share the top row, leaving room for the +main controls in smaller windows. + There is no requirement to use rock strata. Ore-only mods can use OreSpawn to place ores in ordinary vanilla stone while every geology control remains idle. When no provider supplies rocks, **Configure Rock Strata...** starts with a @@ -25,8 +28,10 @@ player deliberately adds them. You can remove the starter rocks or add blocks from installed mods before creating the world. Mods can also offer new biomes and world materials without enabling strata. -Use **Biomes & World Materials** to inspect installed dimension palettes, -surface blocks, aquifer fluids, snow, and ice. The picker only accepts real +Use **Biomes** to inspect loaded biomes, placement palettes, and surface rules. +Replacing a biome changes only newly generated terrain. The dimension's +**Materials** button changes aquifer fluid, snow, or ice across every biome in +that dimension, not only the biome currently selected. The picker only accepts installed registry entries. Missing optional compatibility biomes are skipped safely instead of breaking world creation. @@ -43,16 +48,21 @@ 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. + Imported vanilla rules stay saved but inactive while it is off. When it is + on, an active import replaces the matching shipped default rather than + adding another placement pass. Existing chunks are not rewritten. - **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. +- **Biomes** lists installed and referenced biomes and their placement rules. + **Replace in new terrain with...** is an exact, one-to-one choice and does + not rewrite existing chunks. Palette settings still offer **Augment** for a + provider mix or **Replace** for a complete provider style. +- **Ore Sources** groups interchangeable ores by exact block tags. Balanced + shares one placement budget across eligible outputs; Single picks one output; + Custom lets you choose weights. Keep Original leaves every rule independent. **World Materials** applies across an entire dimension. **Aquifer Fluid** changes the normal below-sea-level fluid, while **Deep Aquifer** can use a diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index f3bc3f83..a66be26c 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -6,12 +6,20 @@ Provider mods may contribute through NeoForge IMC, a packaged resource at present malformed override leaves that provider inactive instead of silently falling back. -Provider schema 4 supports `profile_defaults`, `rocks`, `ores`, +Provider schema 5 supports `profile_defaults`, `rocks`, `ores`, `fluid_deposits`, `geomes`, `biome_rules`, `terrain_dimensions`, and `templates`, plus `biome_palettes` and `dimension_materials`. Each file requires a matching `provider_modid`, a positive `provider_revision`, and at least one -contribution. Legacy schemas 1-3 remain accepted; schema 3 introduced fluid -deposits and schema 4 introduces biome and world-material controls. +contribution. Legacy schemas 1-4 remain accepted; schema 3 introduced fluid +deposits, schema 4 added biome and world-material controls, and schema 5 adds +optional `material` and `placement_channel` IDs for exact ore grouping. + +Use the same exact loaded block tag, such as `c:ores/sulfur`, for ores that +are interchangeable. OreSpawn never infers equivalence from similar block or +mod names. When two ordinary MMD providers for the same tag are loaded, a new +world starts Balanced with one placement budget per channel. An absent mod +adds no phantom output or placement rule, and old worlds keep their saved +choices. Enrichment and dimension-specific providers are kept separate. An ore-only provider does not need rocks, geomes, or terrain dimensions. Give each ore explicit host blocks or tags and OreSpawn will leave vanilla terrain, diff --git a/docs/README.md b/docs/README.md index e1462cb8..09b222a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,10 @@ OreSpawn is a required NeoForge mod and a declarative world-generation engine. The normal jar is both the compile-time and runtime dependency; there is no shaded or embeddable engine artifact. -Choose the guide that matches what you are doing: +Start with the [developer guide](DEVELOPER_GUIDE.md) when integrating a mod, +or the [player and server guide](PLAYER_GUIDE.md) when configuring a world. +The API and provider guides give the exact Java and JSON contracts. Use the +versioning guide when choosing a matching Minecraft and loader build. - [Player and server guide](PLAYER_GUIDE.md) - [Developer quick start and complete integration map](DEVELOPER_GUIDE.md) @@ -18,7 +21,6 @@ Choose the guide that matches what you are doing: - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) - [Versioning and release conventions](VERSIONS.md) -- [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. The provider and migration guides include OS3-compatible ranged quantities and diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index ee535dfd..d86a997e 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -60,7 +60,7 @@ Examples: | 1.19.4 | Forge | `119041` | `4.0.16.119041` | | 1.20.1 | Forge | `120011` | `4.0.16.120011` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | -| 1.20.6 | NeoForge | `120062` | `4.0.16.120062` | +| 1.20.6 | NeoForge | `120062` | `4.1.0.120062` | | 1.21.1 | Forge | `121011` | `4.0.6.121011` | | 1.21.1 | NeoForge | `121012` | `4.0.6.121012` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | diff --git a/docs/examples/examplemod-orespawn.json b/docs/examples/examplemod-orespawn.json index 1c8279df..ad07ab5c 100644 --- a/docs/examples/examplemod-orespawn.json +++ b/docs/examples/examplemod-orespawn.json @@ -1,5 +1,5 @@ { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "rocks": { diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json index cabcfee1..6710b185 100644 --- a/docs/examples/orespawn-global.json +++ b/docs/examples/orespawn-global.json @@ -1,5 +1,5 @@ { - "schema_version": 6, + "schema_version": 8, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -32,6 +32,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json index 07351678..f379edfb 100644 --- a/docs/examples/orespawn-world.json +++ b/docs/examples/orespawn-world.json @@ -1,5 +1,5 @@ { - "schema_version": 5, + "schema_version": 7, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -70,6 +70,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json index e20e5774..49022e13 100644 --- a/docs/schemas/orespawn-global.schema.json +++ b/docs/schemas/orespawn-global.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 6 } } + "properties": { "schema_version": { "const": 8 } } } ] } diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json index 83f67f7b..1416edfa 100644 --- a/docs/schemas/orespawn-provider.schema.json +++ b/docs/schemas/orespawn-provider.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["schema_version", "provider_modid", "provider_revision"], "properties": { - "schema_version": { "enum": [1, 2, 3, 4] }, + "schema_version": { "enum": [1, 2, 3, 4, 5] }, "provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" }, "provider_revision": { "type": "integer", "minimum": 1 }, "rocks": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/rock" } }, diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json index f23b7970..497cc4a0 100644 --- a/docs/schemas/orespawn-world.schema.json +++ b/docs/schemas/orespawn-world.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 5 } } + "properties": { "schema_version": { "const": 7 } } } ] } diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json index 6d99c56c..c76a11e9 100644 --- a/docs/schemas/profile-common.schema.json +++ b/docs/schemas/profile-common.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://mcmoddev.com/orespawn/schemas/profile-common.schema.json", "type": "object", - "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "fluid_deposits"], + "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_material_groups", "ore_source_policies", "fluid_deposits"], "properties": { "geology_mode": { "enum": ["geome", "legacy"] }, "place_fluid_deposits": { "type": "boolean" }, @@ -19,6 +19,8 @@ "biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } }, "dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } }, "ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } }, + "ore_material_groups": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "$ref": "#/$defs/oreMaterialGroup" } }, + "ore_source_policies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/oreSourcePolicy" } }, "fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fluidDeposit" } }, "worldgen_aliases": { "type": "object", "additionalProperties": { "$ref": "#/$defs/registryId" } }, "providers": { "type": "object" }, @@ -145,6 +147,7 @@ "minimum": 0.0, "maximum": 1.0 }, + "placement_channel": { "$ref": "#/$defs/registryId" }, "spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "node_size": { "type": "integer", "minimum": 1, "maximum": 32 }, @@ -181,6 +184,7 @@ "required": ["enabled"], "properties": { "block": { "$ref": "#/$defs/registryId" }, + "material": { "$ref": "#/$defs/registryId" }, "enabled": { "type": "boolean" }, "source_mod": { "type": "string" }, "native_generation": { "type": "boolean" }, @@ -217,6 +221,56 @@ ], "additionalProperties": true }, + "oreSourceCandidate": { + "type": "object", + "required": ["source_id", "owner", "registry_id", "material", "domain", "placement_channel", "loaded", "external"], + "properties": { + "source_id": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "owner_name": { "type": "string" }, + "owner_version": { "type": "string" }, + "registry_id": { "$ref": "#/$defs/registryId" }, + "metadata": { "type": "integer", "minimum": 0 }, + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "placement_channel": { "$ref": "#/$defs/registryId" }, + "block_tags": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "loaded": { "type": "boolean" }, + "placement_active": { "type": "boolean" }, + "external": { "type": "boolean" }, + "enrichment": { "type": "boolean" }, + "review_required": { "type": "boolean" }, + "material_declared": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreSourcePolicy": { + "type": "object", + "required": ["material", "domain", "mode", "output_mode", "outputs", "placement_sources", "candidates"], + "properties": { + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "mode": { "enum": ["consolidated", "keep_separate"] }, + "output_mode": { "enum": ["balanced", "single", "custom"] }, + "outputs": { "type": "object", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } }, + "placement_sources": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "type": "string", "minLength": 1 } }, + "candidates": { "type": "array", "items": { "$ref": "#/$defs/oreSourceCandidate" } }, + "review_required": { "type": "boolean" }, + "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] }, + "dormant": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreMaterialGroup": { + "type": "object", + "required": ["display_name", "block_tag_entries"], + "properties": { + "display_name": { "type": "string", "minLength": 1, "maxLength": 64 }, + "block_tag_entries": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "ore_dictionary_entries": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": false + }, "fluidDepositDimension": { "type": "object", "required": ["enabled", "min_y", "max_y", "frequency", "min_radius", "max_radius", "min_vertical_radius", "max_vertical_radius", "max_lobes", "min_solid_cover"], diff --git a/gradle.properties b/gradle.properties index 059bafa9..6400cc4d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,7 +18,7 @@ minecraft_version_range=[1.20.6,1.21) # NeoForge 20.6 targets Minecraft 1.20.6. neo_version=20.6.139 neo_version_range=[20.6.139,20.7) -# NeoForge 20.6 uses FML loader major 2. +# Accept the compatible FML loader bundled with NeoForge 20.6. loader_version_range=[2,) loader_name=neoforge loader_code=2 @@ -38,8 +38,10 @@ mod_id=orespawn mod_name=MMD OreSpawn # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-2.1 -# The mod version. See https://semver.org/ -mod_version=4.0.16.120062 +# Four numeric parts: feature version followed by this Minecraft and loader target. +mod_version=4.1.0.120062 +# The organization shown as the vendor in release-jar metadata. +mod_vendor=Minecraft Mod Development # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/gradle/README.md b/gradle/README.md new file mode 100644 index 00000000..c0e5ef64 --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,51 @@ +# Build scripts + +`build.gradle` keeps the target version, NeoGradle runs, dependencies, resources +and Java defaults in one place. The smaller scripts below own the longer checks: + +- `verification/support.gradle` checks sealed test fixtures, protects a + developer's local config during tests and reviews complete runtime logs. +- `verification/client-and-benchmark.gradle` builds the isolated surface, + client and benchmark probes used in development runs. +- `verification/ore-sources.gradle` builds two test-only future MMD providers + and checks their shared ore policy on fresh and reloaded worlds. +- `verification/biome-override.gradle` checks exact biome replacements in new + terrain without changing a previously saved chunk. +- `verification/packaged-runtime.gradle` builds Java argument vectors from an + official NeoForge installation; every dedicated-server command ends in `nogui`. +- `verification/native-ores.gradle` checks native and imported Nether ores, + vanilla-management OFF/ON, fluid hosts, exact reload and new-terrain edits. +- `release/artifacts.gradle` checks the three release jars, bundled guide, + metadata and checksums. +- `release/publishing.gradle` holds the opt-in Maven publication and credential + checks. An ordinary build never uploads anything. +- `ide/eclipse.gradle` keeps Buildship on processed production resources and + checks that test fixtures do not enter normal Eclipse launches. + +Run the normal release check with `clean check build javadoc +verifyReleaseArtifacts writeReleaseChecksums`. Run `eclipse +verifyEclipseProductionClasspath` after changing resources or launch settings. +The client, future-provider, biome-replacement, packaged-runtime and performance +checks are separate opt-in gates; their required runtime paths are described +beside those tasks. Use Java 21 and +the branch's configured Gradle cache for NeoForge 1.20.6. + +Downloaded dependencies can use a shared Gradle home. NeoGradle's intermediate +execution results stay in `.gradle/neo-execution` inside this checkout, so a +different Minecraft branch cannot leave incompatible staged results behind. +The usual `net.neoforged.gradle.caching.cacheDirectory` property still allows an +explicit cache location when investigating the build itself. + +Use `runTestOreSourcesClient` for a manual client with both future MMD provider +fixtures. It uses `run-ore-sources`, separately from ordinary `runClient`, and +never resets its saved worlds or configuration. The coloured wool in this +profile deliberately stands in for ore blocks; these mods are test fixtures. + +Run `nativeOreIntegrationTest` with +`-PpackagedNeoForgeServerRuntime=`. +It creates its own build-owned worlds and runs the ordinary release jar. None +of the fixture classes or resources belongs in a release artifact. + +When adding a task, put it next to the checks it supports and keep any shared +helper in the small `oreSpawnBuild` map in the root build. Test-only source sets +must stay out of the release jars and normal Eclipse classpath. diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..def603a8 --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,113 @@ +// Buildship sees processed production resources, never fixture classes. +def shared = project.ext.oreSpawnBuild +def prepareEclipseResources = shared.prepareEclipseResources +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +eclipse { + project { + natures 'org.eclipse.buildship.core.gradleprojectnature' + buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' + } + classpath { + downloadSources = true + downloadJavadoc = true + file { + whenMerged { classpath -> + def libraryPaths = new LinkedHashSet() + classpath.entries = classpath.entries.findAll { entry -> + !(entry instanceof org.gradle.plugins.ide.eclipse.model.Library) + || libraryPaths.add(entry.path) + } + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + } + } + } + synchronizationTasks 'prepareEclipseResources' +} + +tasks.named('eclipse').configure { + dependsOn prepareEclipseResources + doLast { + String requestedGradleHome = System.getenv('GRADLE_USER_HOME') + if (requestedGradleHome == null || requestedGradleHome.trim().isEmpty()) { + return + } + + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + if (preferencesFile.isFile()) { + preferencesFile.withInputStream { preferences.load(it) } + } + String gradleHome = file(requestedGradleHome).canonicalPath + preferences.setProperty('eclipse.preferences.version', '1') + preferences.setProperty('connection.gradle.distribution', 'GRADLE_DISTRIBUTION(WRAPPER)') + preferences.setProperty('override.workspace.settings', 'true') + preferences.setProperty('gradle.user.home', gradleHome) + preferences.setProperty('connection.gradle.user.home', gradleHome) + preferences.setProperty('java.home', System.getProperty('java.home')) + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by the OreSpawn Eclipse task to keep NeoGradle launch modules on one cache.') + } + } +} + +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipse') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties preferences = new Properties() + prefs.withInputStream { preferences.load(it) } + if (preferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException('Eclipse Buildship workspace override is not active') + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) throw new GradleException('Eclipse .classpath was not generated') + String classpathText = eclipseClasspath.getText('UTF-8') + if (!classpathText.contains('path="build/resources/main"') + || classpathText.contains('path="src/main/resources"') + || classpathText.contains('path="src/generated/resources"')) { + throw new GradleException('Eclipse must consume only Gradle-processed production resources') + } + ['META-INF/neoforge.mods.toml', 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md'].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File metadata = file('bin/main/META-INF/neoforge.mods.toml') + String metadataText = metadata.getText('UTF-8') + if (metadataText.contains('\${mod_version}') + || !metadataText.contains("version=\"${project.version}\"")) { + throw new GradleException('Eclipse output contains unexpanded NeoForge metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', + 'surfaceprobe', 'clientprobe', 'benchmarkprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'net.minecraftforge', 'META-INF/mods.toml', + 'C:\\Users\\John' + ] + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + List leaked = forbidden.findAll { launch.getText('UTF-8').contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${launch.name} exposes test/Forge/local content: ${leaked}") + } + } + } +} diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..03040697 --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,320 @@ +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +// Audit the jars and public documentation that a release would carry. +def shared = project.ext.oreSpawnBuild +def expectedMavenGroup = shared.expectedMavenGroup +def expectedMavenArtifact = shared.expectedMavenArtifact +def expectedMavenCoordinate = shared.expectedMavenCoordinate +def archiveTextSuffixes = shared.archiveTextSuffixes +def archiveTextPatterns = shared.archiveTextPatterns +def normalizeArchiveLineEndings = shared.normalizeArchiveLineEndings +def bundledDocumentationDirectory = shared.bundledDocumentationDirectory +def prepareBundledDocumentation = shared.prepareBundledDocumentation +def releaseJar = tasks.named('jar', Jar) +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +def javadocLegalNames = ['ADDITIONAL_LICENSE_INFO', 'ASSEMBLY_EXCEPTION', 'LICENSE'] +def javadocLegalSource = providers.provider { + tasks.named('javadoc', Javadoc).get().javadocTool.get().metadata.installationPath + .dir('legal/java.base').asFile +} + +// Some JDK ZIPs store these notices as link text. Package the actual notices +// so Windows and Linux produce the same complete documentation archive. +tasks.named('javadoc', Javadoc).configure { + inputs.files(javadocLegalSource.map { directory -> + javadocLegalNames.collect { new File(directory, it) } + }) + doLast { + project.copy { + from javadocLegalSource + include javadocLegalNames + into new File(destinationDir, 'legal') + } + } +} + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.1.0.120062' + || project.mod_group_id != expectedMavenGroup + || project.minecraft_version != '1.20.6' + || project.neo_version != '20.6.139') { + throw new GradleException('Unexpected OreSpawn NeoForge 1.20.6 release identity') + } + if (project.loader_name != 'neoforge' || project.loader_code != '2' + || project.java_version != '21' || project.gradle_java_version != '21' + || project.java_toolchain_version != '21.0.7+6' + || project.java_setup_version != '21.0.7+6.0.LTS') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.1.0.120062.jar', + 'OreSpawn-4.1.0.120062-sources.jar', + 'OreSpawn-4.1.0.120062-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains(project.mod_version)) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + if (!java.util.Arrays.equals(new File(trackedDocumentationDirectory, relative).bytes, + new File(root, relative).bytes)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def prepareEclipseResources = tasks.register('prepareEclipseResources', Sync) { + dependsOn tasks.named('processResources') + from layout.buildDirectory.dir('resources/main') + into file('bin/main') + doLast { + // Eclipse may retain files generated by an older build. + delete file('bin/main/META-INF/orespawn/docs/AGENTS.md'), file('bin/main/AGENTS.md') + } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 20 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 20 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().archiveFile.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") + } + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List expected = expectedReleaseFiles.get().collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException("${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'src/benchmarkIntegrationTest/', 'src/nativeOreIntegrationTest/', + 'nativeoreprobe', 'agent-notes/', 'surfaceprobe', + 'clientprobe', 'benchmarkprobe', 'worldhashprobe', + 'src/worldHashTest/', 'oresource-minerals', + 'oresource-advantage', 'oreSourceMineralsTest/', + 'oreSourceAdvantageTest/', 'ci-fixtures/', 'net/minecraftforge/', + 'META-INF/mods.toml', 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException("${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/neoforge.mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json' + ].each { required -> + if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/neoforge.mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[20.6.139,20.7)"')) { + throw new GradleException('Packaged NeoForge metadata is incorrect') + } + String transformer = zip.getInputStream(zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() }.findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator biomeSource', + 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator globalFluidPicker', + 'public-f net.minecraft.world.level.biome.Biome generationSettings', + 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration validBlocks', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabManager', + 'public net.minecraft.client.gui.screens.worldselection.CreateWorldScreen tabNavigationBar' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged official-named access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('OreSpawn-Loader') != 'neoforge' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = zip.getInputStream(entry).withCloseable { it.readNBytes(8) } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 65) throw new GradleException("${entry.name} uses class major ${major}, expected 65") + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> + if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> + if (zip.getEntry('index.html') == null + || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { + throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') + } + javadocLegalNames.each { name -> + def entry = zip.getEntry("legal/${name}") + String expectedNotice = new File(javadocLegalSource.get(), name).getText('UTF-8') + .replace('\r\n', '\n') + String actualNotice = entry == null ? '' : zip.getInputStream(entry).getText('UTF-8') + if (actualNotice != expectedNotice) { + throw new GradleException("Javadoc legal/${name} differs from the JDK's actual notice") + } + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file outputFile + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) throw new GradleException('preparedReleaseDir is required') + File prepared = file(preparedReleaseDir.get()) + List expected = expectedReleaseFiles.get().collect { it.toString() }.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 jars ${jars*.name} do not match ${expected}") + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + } +} + + +shared.releaseJar = releaseJar +shared.expectedReleaseFiles = expectedReleaseFiles +shared.preparedReleaseDir = preparedReleaseDir +shared.prepareEclipseResources = prepareEclipseResources +shared.documentationFiles = documentationFiles +shared.assertDocumentationTree = assertDocumentationTree diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..d36da951 --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,87 @@ +import groovy.xml.XmlSlurper + +// Publication is opt-in; ordinary builds still validate its coordinates. +def shared = project.ext.oreSpawnBuild +def expectedMavenGroup = shared.expectedMavenGroup +def expectedMavenArtifact = shared.expectedMavenArtifact +def expectedReleaseFiles = shared.expectedReleaseFiles +def preparedReleaseDir = shared.preparedReleaseDir +def releaseJar = shared.releaseJar +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = expectedMavenGroup + artifactId = expectedMavenArtifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } + } else { + from components.java + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") + } + } +} + +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} diff --git a/gradle/verification/biome-override.gradle b/gradle/verification/biome-override.gradle new file mode 100644 index 00000000..d8f72df8 --- /dev/null +++ b/gradle/verification/biome-override.gradle @@ -0,0 +1,99 @@ +// Generate an old chunk, save a replacement, then verify only new terrain changes. +def shared = project.ext.oreSpawnBuild +def hashClasses = layout.buildDirectory.dir('world-hash-fixture/classes') +def compileWorldHashProbe = tasks.register('compileWorldHashProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/worldHashTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(hashClasses) + javaCompiler.set(javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(21) }) + options.release = 21 + options.encoding = 'UTF-8' +} +def worldHashProbeJar = tasks.register('worldHashProbeJar', Jar) { + dependsOn compileWorldHashProbe + archiveFileName = 'worldhashprobe.jar' + destinationDirectory = layout.buildDirectory.dir('world-hash-fixture') + from hashClasses + from 'src/worldHashTest/resources' +} +File runDir = layout.buildDirectory.dir('biome-override-run').get().asFile +File worldDir = new File(runDir, 'biome-override-world') +File markerFile = new File(worldDir, 'biomeoverrideprobe.properties') +def prepare = tasks.register('prepareBiomeOverrideProbe') { + dependsOn worldHashProbeJar + doLast { + delete runDir + runDir.mkdirs() + copy { + from worldHashProbeJar.flatMap { it.archiveFile } + into new File(runDir, 'mods') + } + new File(runDir, 'server.properties').setText('''\ +level-name=biome-override-world +level-seed=49110 +level-type=minecraft:normal +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDir, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def writeOverride = tasks.register('writeBiomeOverride') { + dependsOn 'runBiomeOverrideFresh' + doLast { + if (!markerFile.isFile()) throw new GradleException("Missing fresh biome marker: ${markerFile}") + Properties marker = new Properties() + markerFile.withInputStream { marker.load(it) } + String source = marker.getProperty('source') + if (source == null || !source.contains(':')) { + throw new GradleException("Missing source biome in ${markerFile}") + } + String target = source == 'minecraft:desert' ? 'minecraft:plains' : 'minecraft:desert' + File profileFile = new File(worldDir, 'serverconfig/orespawn-worldgen.json') + if (!profileFile.isFile()) throw new GradleException("Missing world profile: ${profileFile}") + Map profile = (Map) new groovy.json.JsonSlurper().parse(profileFile) + Map palettes = profile.biome_palettes instanceof Map ? (Map) profile.biome_palettes + : new LinkedHashMap() + profile.biome_palettes = palettes + palettes['orespawn:ui/biome_overrides/minecraft_overworld'] = [ + dimension: 'minecraft:overworld', enabled: true, mode: 'replace', + scope: 'all', region_size: 'tiny', coverage: 1.0D, + fallback_weight: 0.0D, + biomes: [(target): [enabled: true, weight: 1.0D, + similar_biomes: [source]]] + ] + profileFile.setText(groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson(profile)) + '\n', 'UTF-8') + marker.setProperty('target', target) + markerFile.withOutputStream { marker.store(it, 'Disposable biome override probe') } + } +} + +tasks.configureEach { + if (name == 'runBiomeOverrideFresh') dependsOn prepare + if (name == 'runBiomeOverrideReload') dependsOn writeOverride + if (name == 'runBiomeOverrideFresh' || name == 'runBiomeOverrideReload') { + doFirst { ext.biomeOverridePriorCrashes = shared.runtimeCrashSnapshot(workingDir) } + doLast { + shared.assertRuntimeLogsClean(workingDir, name, ext.biomeOverridePriorCrashes as Set) + } + } +} +tasks.register('biomeOverrideIntegrationTest') { + group = 'verification' + description = 'Proves exact replacement changes new chunks without rewriting saved chunks.' + dependsOn 'runBiomeOverrideReload' + doLast { + Properties marker = new Properties() + markerFile.withInputStream { marker.load(it) } + if (marker.getProperty('reload_verified') != 'true') { + throw new GradleException('Biome replacement did not pass fresh/reload verification') + } + } +} diff --git a/gradle/verification/client-and-benchmark.gradle b/gradle/verification/client-and-benchmark.gradle new file mode 100644 index 00000000..bde825a9 --- /dev/null +++ b/gradle/verification/client-and-benchmark.gradle @@ -0,0 +1,131 @@ +// Test-only clients and worlds never enter the production source set. +def shared = project.ext.oreSpawnBuild +def assertRuntimeLogsClean = shared.assertRuntimeLogsClean +def runtimeCrashSnapshot = shared.runtimeCrashSnapshot +def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') +def compileSurfaceIntegrationTestMod = tasks.register('compileSurfaceIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/biomeIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(surfaceIntegrationClasses) + javaCompiler.set(javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(21) + }) + options.release = 21 + options.encoding = 'UTF-8' +} + +def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { + dependsOn compileSurfaceIntegrationTestMod + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' +} + +def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') +def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { + dependsOn surfaceIntegrationTestModJar + doLast { + File runDirectory = surfaceIntegrationRunDirectory.get().asFile + delete runDirectory + runDirectory.mkdirs() + copy { + from surfaceIntegrationTestModJar.flatMap { it.archiveFile } + into surfaceIntegrationRunDirectory.map { it.dir('mods') } + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=0 +level-type=minecraft:normal +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +tasks.configureEach { + if (name == 'runSurfaceIntegrationFresh') { + dependsOn prepareSurfaceIntegrationTest + } else if (name == 'runSurfaceIntegrationReload') { + dependsOn 'runSurfaceIntegrationFresh' + } + if (name == 'runSurfaceIntegrationFresh' || name == 'runSurfaceIntegrationReload') { + doFirst { + ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(workingDir) + } + doLast { + assertRuntimeLogsClean(workingDir, "NeoForge 1.20.6 ${name}", + ext.oreSpawnCrashSnapshot as Set) + } + } +} + +def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { + group = 'verification' + description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' + dependsOn 'runSurfaceIntegrationReload' + doLast { + File marker = surfaceIntegrationRunDirectory.get().file( + 'surface-integration-world/surfaceprobe-integration.properties').asFile + if (!marker.isFile()) { + throw new GradleException("Surface integration completion marker is missing: ${marker}") + } + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("Surface integration reload was not verified: ${marker}") + } + logger.lifecycle('Provider surfaces and dynamic-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +tasks.named('check') { + dependsOn surfaceIntegrationTest +} + +def clientIntegrationClasses = layout.buildDirectory.dir('client-integration-fixture/classes') +def compileClientIntegrationTestMod = tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(clientIntegrationClasses) + options.release = 21 + options.encoding = 'UTF-8' +} +def clientIntegrationTestModJar = tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn compileClientIntegrationTestMod + archiveFileName = 'clientprobe.jar' + destinationDirectory = layout.buildDirectory.dir('client-integration-fixture') + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} + +def benchmarkIntegrationClasses = layout.buildDirectory.dir('benchmark-integration-fixture/classes') +def compileBenchmarkIntegrationTestMod = tasks.register('compileBenchmarkIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/benchmarkIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(benchmarkIntegrationClasses) + options.release = 21 + options.encoding = 'UTF-8' +} +def benchmarkIntegrationTestModJar = tasks.register('benchmarkIntegrationTestModJar', Jar) { + dependsOn compileBenchmarkIntegrationTestMod + archiveFileName = 'benchmarkprobe.jar' + destinationDirectory = layout.buildDirectory.dir('benchmark-integration-fixture') + from benchmarkIntegrationClasses + from 'src/benchmarkIntegrationTest/resources' +} + + +shared.surfaceIntegrationClasses = surfaceIntegrationClasses +shared.surfaceIntegrationRunDirectory = surfaceIntegrationRunDirectory +shared.clientIntegrationTestModJar = clientIntegrationTestModJar +shared.benchmarkIntegrationTestModJar = benchmarkIntegrationTestModJar diff --git a/gradle/verification/native-ores.gradle b/gradle/verification/native-ores.gradle new file mode 100644 index 00000000..67a62071 --- /dev/null +++ b/gradle/verification/native-ores.gradle @@ -0,0 +1,137 @@ +// A real Nether checks the shipped host tag and explicit block hosts. +// The fixture is compiled separately and never joins a production classpath. +def shared = project.ext.oreSpawnBuild +def classes = layout.buildDirectory.dir('native-ore-fixture/classes') +def compile = tasks.register('compileNativeOreProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/nativeOreIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = classes + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + options.encoding = 'UTF-8' +} +def jar = tasks.register('nativeOreProbeJar', Jar) { + dependsOn compile + archiveFileName = 'nativeoreprobe.jar' + destinationDirectory = layout.buildDirectory.dir('native-ore-fixture') + from classes + from 'src/nativeOreIntegrationTest/resources' +} +def packagedJar = jar +def serverRuntime = providers.gradleProperty('packagedNeoForgeServerRuntime') +def comparisonJar = providers.gradleProperty('nativeOreSpawnJar') +def finalTasks = [] +['Default', 'Explicit', 'Imported', 'OffControl', 'OffImported'].each { scenario -> + boolean imported = scenario in ['Imported', 'OffImported'] + boolean managed = !scenario.startsWith('Off') + File runDirectory = file("${buildDir}/native-ore-${scenario.toLowerCase()}-run") + def prepare = tasks.register("prepareNativeOre${scenario}") { + dependsOn tasks.named('jar').flatMap { it.archiveFile }, packagedJar + doLast { + // This directory is build-owned; no manual saves are used here. + delete runDirectory + runDirectory.mkdirs() + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedNeoForgeServerRuntime=') + } + shared.prepareTestNeoForgeConfig(runDirectory) + copy { + from comparisonJar.isPresent() ? file(comparisonJar.get()) : tasks.named('jar').flatMap { it.archiveFile } + from packagedJar + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText('''\ +level-name=native-ore-world +level-seed=4192643144957839458 +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + } + } + def previous = prepare + ['Fresh', 'Reload', 'Edited'].each { phase -> + def dependency = previous + previous = tasks.register("nativeOre${scenario}${phase}", Exec) { + group = 'verification' + description = "Checks managed quartz with ${scenario.toLowerCase()} hosts (${phase.toLowerCase()})." + dependsOn dependency + doFirst { + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedNeoForgeServerRuntime=') + } + workingDir runDirectory + // One generation worker makes independent host-form runs comparable. + // Ordinary runtime and performance checks keep their normal worker count. + commandLine shared.neoForgeServerCommand(file(serverRuntime.get()), runDirectory, + ['-XX:ActiveProcessorCount=2', '-Dio.netty.transport.noNative=true', + "-Dnativeoreprobe.phase=${phase.toLowerCase()}", + "-Dnativeoreprobe.manage=${managed}", + "-Dnativeoreprobe.imported=${imported}", + "-Dnativeoreprobe.host=${scenario.toLowerCase()}"]) + } + doLast { + Properties result = new Properties() + File marker = new File(runDirectory, 'native-ore-world/nativeoreprobe.properties') + if (!marker.isFile()) throw new GradleException("Quartz audit is missing: ${marker}") + marker.withInputStream { result.load(it) } + if (result.getProperty("${phase.toLowerCase()}_verified") != 'true') { + throw new GradleException("Quartz ${phase.toLowerCase()} was not verified") + } + shared.assertRuntimeLogsClean(runDirectory, "Native quartz ${scenario} ${phase}", [] as Set) + if (new File(runDirectory, 'logs/latest.log').getText('UTF-8') + .contains("Ignoring invalid placement rule for OreSpawn-managed ore 'minecraft:nether_quartz_ore'")) { + throw new GradleException('Managed quartz was rejected during startup') + } + copy { from new File(runDirectory, 'logs/latest.log'); into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.log" } } + copy { from marker; into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.properties" } } + } + } + } + finalTasks.add(previous) +} +tasks.register('nativeOreIntegrationTest') { + group = 'verification' + description = 'Checks quartz defaults and imports, vanilla OFF parity, reloads and new-terrain-only edits.' + dependsOn finalTasks + doLast { + ['fresh', 'reload', 'edited'].each { phase -> + Properties tagged = new Properties() + Properties explicit = new Properties() + file("${buildDir}/native-ore-default-run/audit-logs/${phase}.properties") + .withInputStream { tagged.load(it) } + file("${buildDir}/native-ore-explicit-run/audit-logs/${phase}.properties") + .withInputStream { explicit.load(it) } + ['quartz', 'blocks_sha256', 'biomes_sha256', 'new_terrain_quartz', 'water', 'new_terrain_water'].each { key -> + if (tagged.getProperty(key) != explicit.getProperty(key)) { + throw new GradleException("Tag and explicit quartz hosts differ for ${phase}: ${key}") + } + } + } + logger.lifecycle('Tag and explicit quartz hosts produce identical terrain, fresh/reload/edited') + [['default', 'imported'], ['offcontrol', 'offimported']].each { pair -> + ['fresh', 'reload', 'edited'].each { phase -> + def audits = pair.collect { scenario -> + Properties result = new Properties() + file("${buildDir}/native-ore-${scenario}-run/audit-logs/${phase}.properties") + .withInputStream { result.load(it) } + result + } + ['quartz', 'blocks_sha256', 'biomes_sha256', 'new_terrain_quartz', 'water', 'new_terrain_water'].each { key -> + if (audits[0].getProperty(key) != audits[1].getProperty(key)) { + throw new GradleException("Imported quartz changed ${pair}, ${phase}: ${key}") + } + } + } + } + logger.lifecycle('Imported quartz: OFF matches native terrain; ON matches one configured pass; reload and no-retrogen verified') + } +} diff --git a/gradle/verification/ore-sources.gradle b/gradle/verification/ore-sources.gradle new file mode 100644 index 00000000..259e4d50 --- /dev/null +++ b/gradle/verification/ore-sources.gradle @@ -0,0 +1,133 @@ +// Future MMD providers are ordinary test-only mods, never production dependencies. +def oreSourceFixtures = [:] +['Minerals': 'oreSourceMineralsTest', 'Advantage': 'oreSourceAdvantageTest'].each { label, folder -> + def classesDir = layout.buildDirectory.dir("ore-source-fixture/${label.toLowerCase(Locale.ROOT)}-classes") + def compile = tasks.register("compileOreSource${label}Fixture", JavaCompile) { + dependsOn tasks.named('classes') + source fileTree("src/${folder}/java") + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory.set(classesDir) + javaCompiler.set(javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(21) + }) + options.release = 21 + options.encoding = 'UTF-8' + } + oreSourceFixtures[label] = tasks.register("oreSource${label}FixtureJar", Jar) { + dependsOn compile + archiveFileName = "oresource-${label.toLowerCase(Locale.ROOT)}.jar" + destinationDirectory = layout.buildDirectory.dir('ore-source-fixture') + from classesDir + from "src/${folder}/resources" + } +} + +def oreSourceRuns = [:] + +// This manual profile keeps its own worlds and config between launches. +runs { + testOreSourcesClient { + runType 'client' + workingDirectory layout.projectDirectory.dir('run-ore-sources') + modSource project.sourceSets.main + } +} + +def prepareOreSourcesClient = tasks.register('prepareOreSourcesClient') { + group = 'verification' + description = 'Stages the two future-provider mods for the manual Ore Sources client.' + dependsOn oreSourceFixtures.values() + doLast { + copy { + oreSourceFixtures.values().each { from it.flatMap { jar -> jar.archiveFile } } + into file('run-ore-sources/mods') + } + } +} + +tasks.configureEach { + if (name == 'runTestOreSourcesClient') dependsOn prepareOreSourcesClient +} + +['One': ['Minerals'], 'Both': ['Minerals', 'Advantage']].each { scenario, labels -> + File runDir = layout.buildDirectory.dir( + "ore-source-${scenario.toLowerCase(Locale.ROOT)}-run").get().asFile + def prepare = tasks.register("prepareOreSource${scenario}") { + labels.each { dependsOn oreSourceFixtures[it] } + doLast { + delete runDir + runDir.mkdirs() + copy { + labels.each { from oreSourceFixtures[it].flatMap { jar -> jar.archiveFile } } + into new File(runDir, 'mods') + } + new File(runDir, 'server.properties').setText('''\ +level-name=ore-source-world +level-seed=49110 +level-type=minecraft:normal +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDir, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + String fresh = "runOreSource${scenario}Fresh" + String reload = "runOreSource${scenario}Reload" + tasks.configureEach { + if (name == fresh) dependsOn prepare + if (name == reload) dependsOn fresh + } + oreSourceRuns[scenario] = [runDir: runDir, reload: reload] + tasks.register("oreSource${scenario}IntegrationTest") { + group = 'verification' + description = "Checks ${scenario.toLowerCase(Locale.ROOT)} future ore-provider scenario on a fresh world and exact reload." + dependsOn reload + doLast { + File marker = new File(runDir, 'ore-source-world/oresourceprobe.properties') + if (!marker.isFile()) throw new GradleException("Ore-source marker is missing: ${marker}") + Properties results = new Properties() + marker.withInputStream { results.load(it) } + if (results.getProperty('reload_verified') != 'true') { + throw new GradleException("Ore-source reload was not verified: ${marker}") + } + } + } +} + +File bothRun = oreSourceRuns.Both.runDir +File advantageJar = new File(bothRun, 'mods/oresource-advantage.jar') +File parkedJar = new File(bothRun, 'oresource-advantage.jar') +tasks.configureEach { + if (name.startsWith('runOreSource')) { + doFirst { ext.oreSourcePriorCrashes = project.ext.oreSpawnBuild.runtimeCrashSnapshot(workingDir) } + doLast { + project.ext.oreSpawnBuild.assertRuntimeLogsClean(workingDir, name, + ext.oreSourcePriorCrashes as Set) + } + } + if (name == 'runOreSourceBothMissing') { + dependsOn oreSourceRuns.Both.reload + doFirst { java.nio.file.Files.move(advantageJar.toPath(), parkedJar.toPath()) } + } else if (name == 'runOreSourceBothRestored') { + dependsOn 'runOreSourceBothMissing' + doFirst { java.nio.file.Files.move(parkedJar.toPath(), advantageJar.toPath()) } + } +} +tasks.register('oreSourceFixtureIntegrationTest') { + group = 'verification' + description = 'Checks one and two future providers, then removes and restores one on the same save.' + dependsOn tasks.named('oreSourceOneIntegrationTest'), 'runOreSourceBothRestored' + doLast { + Properties results = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { + results.load(it) + } + if (results.getProperty('restored_verified') != 'true') { + throw new GradleException('Restored provider was not verified') + } + } +} diff --git a/gradle/verification/packaged-runtime.gradle b/gradle/verification/packaged-runtime.gradle new file mode 100644 index 00000000..08a09f87 --- /dev/null +++ b/gradle/verification/packaged-runtime.gradle @@ -0,0 +1,38 @@ +// Launch the installed NeoForge runtime, not the development classpath. +def shared = project.ext.oreSpawnBuild + +shared.prepareTestNeoForgeConfig = { File runDirectory -> + String buildRoot = layout.buildDirectory.get().asFile.canonicalPath + File.separator + if (!runDirectory.canonicalPath.startsWith(buildRoot)) { + throw new GradleException('Automatic test settings must stay inside the build directory') + } +} + +shared.neoForgeServerCommand = { File runtime, File runDirectory, List extraJvmArguments -> + String argumentName = org.gradle.internal.os.OperatingSystem.current().isWindows() + ? 'win_args.txt' : 'unix_args.txt' + File installedArguments = new File(runtime, + "libraries/net/neoforged/neoforge/${neo_version}/${argumentName}") + if (!installedArguments.isFile()) { + throw new GradleException("Missing official NeoForge arguments: ${installedArguments}") + } + String libraryRoot = runtime.canonicalPath.replace('\\', '/') + '/libraries' + String arguments = installedArguments.getText('UTF-8') + .replace('libraries/', libraryRoot + '/') + .replace('-DlibraryDirectory=libraries', '-DlibraryDirectory=' + libraryRoot) + arguments = arguments.readLines().collect { line -> + def classpath = line =~ /^(-p|--module-path|--class-path|-cp)\s+(.+)$/ + if (classpath.matches()) { + return classpath[0][1] + '\n"' + classpath[0][2].replace('"', '\\"') + '"' + } + line.startsWith('-D') && line.contains(libraryRoot) && line.contains(' ') + ? '"' + line.replace('"', '\\"') + '"' : line + }.join('\n') + '\n' + File argumentFile = new File(runDirectory, 'server.args') + argumentFile.setText(arguments, 'UTF-8') + File java = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.ADOPTIUM + }.get().executablePath.asFile + [java.absolutePath, '-Xmx3G'] + extraJvmArguments + ['@' + argumentFile.absolutePath, 'nogui'] +} diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..7427214e --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,263 @@ +import java.security.MessageDigest + +// Sealed fixtures and runtime-log checks are shared by client and server gates. +def shared = project.ext.oreSpawnBuild +def mineralogy5OracleJar = shared.mineralogy5OracleJar +def mineralogy5OracleSha256 = shared.mineralogy5OracleSha256 +tasks.register('verifyLegacyFixtures') { + group = 'verification' + description = 'Verifies the sealed Mineralogy 1.18.2 5.4.0 oracle used only by isolated tests.' + inputs.file mineralogy5OracleJar + doLast { + if (!mineralogy5OracleJar.isFile()) { + throw new GradleException("Missing mandatory Mineralogy oracle: ${mineralogy5OracleJar}") + } + MessageDigest digest = MessageDigest.getInstance('SHA-256') + mineralogy5OracleJar.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 actual = digest.digest().encodeHex().toString().toUpperCase() + if (actual != mineralogy5OracleSha256) { + throw new GradleException("Mineralogy oracle checksum mismatch: ${actual}") + } + } +} +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + dependsOn tasks.named('verifyLegacyFixtures') + doLast { + configurations.findAll { it.canBeResolved }.each { configuration -> + if (configuration.files.any { it.canonicalFile == mineralogy5OracleJar.canonicalFile }) { + throw new GradleException("Mineralogy oracle leaked into ${configuration.name}") + } + } + } +} + +// Several registry-focused tests initialize the real global config singleton. +// Keep that target-native coverage without creating or changing a developer's +// checkout config as a side effect of `test` or `build`. +def unitTestWorldgenConfig = file('config/orespawn-worldgen.json') +def unitTestWorldgenConfigWasPresent = false +byte[] unitTestWorldgenConfigBytes = null +tasks.named('test', Test).configure { + doFirst { + unitTestWorldgenConfigWasPresent = unitTestWorldgenConfig.isFile() + unitTestWorldgenConfigBytes = unitTestWorldgenConfigWasPresent + ? unitTestWorldgenConfig.bytes : null + } +} +def preserveDeveloperWorldgenConfig = tasks.register('preserveDeveloperWorldgenConfig') { + doLast { + if (unitTestWorldgenConfigWasPresent) { + byte[] after = unitTestWorldgenConfig.isFile() ? unitTestWorldgenConfig.bytes : null + if (after == null || !java.util.Arrays.equals(unitTestWorldgenConfigBytes, after)) { + unitTestWorldgenConfig.parentFile.mkdirs() + unitTestWorldgenConfig.bytes = unitTestWorldgenConfigBytes + throw new GradleException('Unit tests changed config/orespawn-worldgen.json; the original was restored') + } + } else if (unitTestWorldgenConfig.isFile()) { + delete unitTestWorldgenConfig + } + } +} +tasks.named('test') { + finalizedBy preserveDeveloperWorldgenConfig +} + +// A NeoForge process is not green merely because it returns exit code zero. +// The loader can log a worldgen/linkage failure and still shut down normally. +// NeoForge 20.6 probes the macOS kqueue and Linux epoll transports on Windows. +// Their bounded DEBUG/Appender stack blocks are harmless when followed by the +// normal NIO fallback. Do not broaden this to standalone initializer/linkage +// failures or to any other ERROR/FATAL record. + +def runtimeCrashSnapshot = { File runDirectory -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (!crashDirectory.isDirectory()) return [] as Set + return fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() }.collect { it.absolutePath } as Set +} + +def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (crashDirectory.isDirectory()) { + def crashes = fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } + if (!crashes.isEmpty()) { + throw new GradleException("${context} produced crash report ${crashes.first()}") + } + } + + File logsDirectory = new File(runDirectory, 'logs') + if (!logsDirectory.isDirectory()) return + def failures = [] + [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] + .findAll { it.isFile() }.each { File log -> + int lineNumber = 0 + boolean nativeTransportFallback = false + log.eachLine('UTF-8') { String line -> + lineNumber++ + boolean nativeTransportStart = + line.contains('[io.ne.ch.kq.KQueue/]: KQueue support is not available') || + line.contains('[io.ne.ch.ep.Epoll/]: Epoll support is not available') + boolean nativeTransportAppender = nativeTransportFallback && + line.contains('Server thread ERROR An exception occurred processing Appender DebugFile') + boolean startsLogRecord = line ==~ /^\[[0-9]{2}:[0-9]{2}:[0-9]{2}\].*/ || + line ==~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T.* (?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL) .*/ + if (startsLogRecord && !nativeTransportStart && !nativeTransportAppender) { + nativeTransportFallback = false + } + if (nativeTransportStart) nativeTransportFallback = true + boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ + boolean knownNoise = nativeTransportFallback + boolean fatalText = line.contains('Encountered an unexpected exception') || + line.contains('Exception stopping the server') || + line.contains('Migration audit failed') || + line.contains('java.lang.Error:') || + line.contains('NoSuchMethodError') || + line.contains('NoClassDefFoundError') || + line.contains('ExceptionInInitializerError') || + line.contains('Tried to assign a mutable BlockPos') || + line.contains('causing cascading worldgen lag') + if ((unexpectedSeverity || fatalText) && !knownNoise) { + failures.add("${log.name}:${lineNumber}: ${line}") + } + } + } + if (!failures.isEmpty()) { + throw new GradleException("${context} logged unexpected errors:\n" + + failures.take(20).join('\n')) + } +} + +tasks.register('runtimeLogScannerTest') { + group = 'verification' + description = 'Proves runtime log validation rejects real NeoForge failures.' + doLast { + File probe = file("${buildDir}/runtime-log-scanner-test") + delete probe + File logs = new File(probe, 'logs'); logs.mkdirs() + new File(logs, 'latest.log').setText( + '[Server thread/INFO] [neoforge]: Done\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-clean-log-probe', [] as Set) + new File(logs, 'latest.log').setText( + '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + boolean rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') + new File(logs, 'latest.log').setText( + '[Server thread/DEBUG] [neoforge]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') + new File(logs, 'latest.log').setText( + '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + new File(logs, 'latest.log').setText( + '[10:00:00] [Server thread/DEBUG] [io.ne.ch.kq.KQueue/]: KQueue support is not available\n' + + 'java.lang.ExceptionInInitializerError: null\n' + + '2026-08-17T10:00:00Z Server thread ERROR An exception occurred processing Appender DebugFile\n' + + 'Caused by: java.lang.NoClassDefFoundError: Could not initialize class io.netty.channel.kqueue.Native\n' + + '[10:00:01] [Server thread/DEBUG] [io.ne.ch.ni.NioIoHandler/]: NIO fallback active\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-windows-native-transport-probe', [] as Set) + new File(logs, 'latest.log').setText( + '[Server thread/ERROR] [example]: java.lang.ExceptionInInitializerError: real failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-standalone-initializer-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a standalone initializer failure') + delete probe + } +} + +tasks.named('check') { + dependsOn 'runtimeLogScannerTest' +} + +tasks.register('verifyMineralogyOracleIsolation') { + group = 'verification' + description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' + doLast { + configurations.each { configuration -> + if (configuration.canBeResolved && + configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { + throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") + } + } + } +} + +tasks.named('check') { + dependsOn 'verifyMineralogyOracleIsolation' +} + +['runClient', 'runServer', 'runGameTestServer', 'runClientData', 'runData'].each { String taskName -> + tasks.matching { it.name == taskName }.configureEach { JavaExec runTask -> + doFirst { + new File(runTask.workingDir, 'mods').mkdirs() + if (taskName == 'runGameTestServer') { + File serverProperties = new File(runTask.workingDir, 'server.properties') + if (!serverProperties.isFile()) { + serverProperties.setText('# Generated for the OreSpawn GameTest gate\n', 'UTF-8') + } + } + runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + } + doLast { + assertRuntimeLogsClean(runTask.workingDir, taskName, + runTask.ext.oreSpawnCrashSnapshot as Set) + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific wrappers in generic Gradle task execution.' + doLast { + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('gradle') { include '**/*.gradle' }.files) + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + } +} + +tasks.register('verifyColdNeoForgeBootstrapContract') { + group = 'verification' + description = 'Verifies that CI qualifies NeoGradle from an isolated Java 21 cache without Forge tooling.' + doLast { + String workflow = file('.github/workflows/ci.yml').getText('UTF-8') + [ + 'Cold NeoForge bootstrap', + 'orespawn-neoforge-cold-cache', + '--no-build-cache', + '--offline', + "java-version: '21.0.7+6.0.LTS'" + ].each { required -> + if (!workflow.contains(required)) { + throw new GradleException("Cold NeoForge bootstrap is missing ${required}") + } + } + ['MinecraftMavenizer', 'mavenizer', 'java-version: \'25', 'net.minecraftforge.gradle'].each { forbidden -> + if (workflow.contains(forbidden)) { + throw new GradleException("Cold NeoForge bootstrap contains Forge-only tooling: ${forbidden}") + } + } + } +} + +shared.assertRuntimeLogsClean = assertRuntimeLogsClean +shared.runtimeCrashSnapshot = runtimeCrashSnapshot diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java index a00f1e4d..031da51a 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -14,6 +14,7 @@ import com.google.gson.JsonPrimitive; import net.minecraft.ChatFormatting; +import net.minecraft.core.registries.Registries; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; @@ -56,6 +57,11 @@ public final class ClientProbeTestMod { private int editorFrames; private boolean worldSettingsOpened; private boolean longEditorRoundTrip; + private boolean oreSourcesOpened; + private boolean modsOpened; + private int creationBiomeCount; + private int staticBiomeCount; + private int loadedBiomeCount; private List worldCreationButtons; public ClientProbeTestMod() { @@ -90,6 +96,9 @@ public static void onWorldRendered(RenderLevelStageEvent event) { public static void onClientTick(ClientTickEvent.Post event) { ClientProbeTestMod probe = instance; if (probe == null || !Boolean.getBoolean("clientprobe.enabled")) return; + if (Boolean.getBoolean("clientprobe.hideWindow")) { + org.lwjgl.glfw.GLFW.glfwHideWindow(Minecraft.getInstance().getWindow().getWindow()); + } probe.handleClientTick(); } @@ -118,6 +127,12 @@ && activateWorldSettings(minecraft.screen, worldCreationButtons)) { break; case 2: if (minecraft.screen instanceof CreateWorldScreen && stateTicks >= 2) { + CreateWorldScreen creation = (CreateWorldScreen) minecraft.screen; + creationBiomeCount = creation.getUiState().getSettings() + .worldgenLoadContext().registryOrThrow(Registries.BIOME).size(); + // NeoForge exposes these biomes through the creation registry, not a static registry. + staticBiomeCount = 0; + MainMenuLayoutProbe.verify(creation); validateCaptions(minecraft.screen); validateLongEditorRoundTrip(minecraft, minecraft.screen); nextState(3); @@ -126,10 +141,22 @@ && activateWorldSettings(minecraft.screen, worldCreationButtons)) { case 3: if (minecraft.screen instanceof CreateWorldScreen) { CreateWorldScreen root = (CreateWorldScreen) minecraft.screen; + if (!modsOpened) { + Button mods = findButton(root, "Mods"); + if (mods != null) { + mods.onPress(); + if (isScreen(minecraft.screen, "OreSpawnModsScreen")) { + modsOpened = true; + editorRoutes.add("OreSpawnModsScreen"); + nextState(4); + break; + } + } + } Button target = nextNavigationButton(root); if (target == null) { - if (editorRoutes.size() < 5) fail(minecraft, - "Only exercised " + editorRoutes.size() + " editor routes: " + editorRoutes); + if (editorRoutes.size() < 7 || !oreSourcesOpened || !modsOpened) fail(minecraft, + "Ore Sources, Mods or another editor route was unreachable: " + editorRoutes); root.getUiState().setSeed(WORLD_SEED); pressCreateWorld(root); nextState(6); @@ -145,6 +172,24 @@ && activateWorldSettings(minecraft.screen, worldCreationButtons)) { } break; case 4: + if (isScreen(minecraft.screen, "GeologyMaterialsScreen") && !oreSourcesOpened) { + Button sources = findButton(minecraft.screen, "Ore Sources"); + if (sources != null) { + sources.onPress(); + break; + } + Button ores = findButton(minecraft.screen, "Ores"); + if (ores == null) fail(minecraft, "ORES tab is missing from the materials screen"); + ores.onPress(); + break; + } + if (isScreen(minecraft.screen, "OreSourceListScreen")) { + validateCaptions(minecraft.screen); + editorRoutes.add("OreSourceListScreen"); + oreSourcesOpened = true; + minecraft.screen.onClose(); + break; + } if (isOreSpawnEditor(minecraft.screen) && editorFrames >= 2) { validateCaptions(minecraft.screen); minecraft.screen.onClose(); @@ -154,6 +199,8 @@ && activateWorldSettings(minecraft.screen, worldCreationButtons)) { case 5: break; case 6: + if (minecraft.level != null) loadedBiomeCount = minecraft.level.registryAccess() + .registryOrThrow(Registries.BIOME).size(); if (minecraft.screen instanceof ConfirmScreen) { pressWorldCreationConfirmation((ConfirmScreen) minecraft.screen); } @@ -222,16 +269,30 @@ private Button nextNavigationButton(CreateWorldScreen root) { private static void validateCaptions(Screen screen) { for (AbstractWidget widget : widgets(screen)) { + // Text fields carry their value rather than a caption. Compact lists + // and cogs explain themselves through row text, icons, and tooltips. + if (!(widget instanceof Button) && !(widget instanceof CycleButton)) continue; + if (isVisualControl(widget)) continue; String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); if (caption == null || caption.trim().isEmpty() || caption.contains("options.generic_value") || caption.startsWith("button.orespawn.") || caption.startsWith("option.orespawn.")) { - throw new IllegalStateException("Invalid client caption: " + widget.getMessage()); + throw new IllegalStateException("Invalid client caption in " + + screen.getClass().getSimpleName() + " / " + + widget.getClass().getName() + ": " + widget.getMessage()); } } } + private static boolean isVisualControl(AbstractWidget widget) { + for (Class type = widget.getClass(); type != null; type = type.getSuperclass()) { + String name = type.getSimpleName(); + if ("CogButton".equals(name) || "CompactScrollList".equals(name)) return true; + } + return false; + } + private void validateLongEditorRoundTrip(Minecraft minecraft, Screen parent) { JsonObject root = WorldGeologyProfile.recommended(true).rootCopy(); JsonObject ores = new JsonObject(); @@ -384,7 +445,7 @@ private static void initializeScreen(Screen screen, Minecraft minecraft) { throw new IllegalStateException("Could not initialize target-native editor", failure); } } - throw new IllegalStateException("Could not locate Forge 50 Screen initialization method"); + throw new IllegalStateException("Could not locate NeoForge 20.6 Screen initialization method"); } private static void pressDone(Screen screen) { @@ -405,6 +466,20 @@ private static boolean isOreSpawnEditor(Screen screen) { "zone.moddev.mc.orespawn.client."); } + private static boolean isScreen(Screen screen, String simpleName) { + return screen != null && simpleName.equals(screen.getClass().getSimpleName()); + } + + private static Button findButton(Screen screen, String captionPrefix) { + for (AbstractWidget widget : widgets(screen)) { + if (widget instanceof Button && widget.visible && widget.active) { + String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); + if (caption != null && caption.startsWith(captionPrefix)) return (Button) widget; + } + } + return null; + } + private static boolean isWorldSettingsControl(AbstractWidget widget) { return (widget instanceof Button || widget instanceof TabButton) && ChatFormatting.stripFormatting(widget.getMessage().getString()) @@ -512,9 +587,15 @@ private static void stopIntegratedServer(Minecraft minecraft) { private void writeMarker() throws IOException { Properties values = new Properties(); + values.setProperty("main_menu_layout", Boolean.toString(MainMenuLayoutProbe.completed())); values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened)); values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip)); + values.setProperty("creation_biome_count", Integer.toString(creationBiomeCount)); + values.setProperty("static_biome_count", Integer.toString(staticBiomeCount)); + values.setProperty("loaded_biome_count", Integer.toString(loadedBiomeCount)); values.setProperty("editor_routes", Integer.toString(editorRoutes.size())); + values.setProperty("ore_sources_opened", Boolean.toString(oreSourcesOpened)); + values.setProperty("mods_opened", Boolean.toString(modsOpened)); values.setProperty("editor_classes", editorRoutes.toString()); values.setProperty("first_world_rendered", Boolean.toString(firstWorldFrames >= 8)); values.setProperty("reload_rendered", Boolean.toString(reloadWorldFrames >= 8)); diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/MainMenuLayoutProbe.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/MainMenuLayoutProbe.java new file mode 100644 index 00000000..bba3130f --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/MainMenuLayoutProbe.java @@ -0,0 +1,170 @@ +package zone.moddev.mc.orespawn.clientprobe; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.tabs.GridLayoutTab; +import net.minecraft.client.gui.components.tabs.TabNavigationBar; +import net.minecraft.client.gui.layouts.HeaderAndFooterLayout; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import zone.moddev.mc.orespawn.client.OreSpawnWorldSettingsScreen; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; + +/** Checks the actual menu widgets against Minecraft's tab area and footer. */ +final class MainMenuLayoutProbe { + private static boolean completed; + + private MainMenuLayoutProbe() { } + + static boolean completed() { return completed; } + + static void verify(CreateWorldScreen parent) { + try { + verifyLayouts(parent); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not inspect the main-menu layout", failure); + } + } + + private static void verifyLayouts(CreateWorldScreen parent) throws ReflectiveOperationException { + String pendingBefore = WorldGeologyProfileManager.pendingNewWorldProfile().rootCopy().toString(); + Class tabType = Class.forName("zone.moddev.mc.orespawn.client.OreSpawnWorldCreationTab"); + Constructor constructor = tabType.getDeclaredConstructor(CreateWorldScreen.class, WorldGeologyProfile.class, List.class); + constructor.setAccessible(true); + HeaderAndFooterLayout footer = field(parent, HeaderAndFooterLayout.class); + TabNavigationBar navigation = field(parent, TabNavigationBar.class); + int footerInset = footer.getFooterHeight(); + int tabTop = navigation.getRectangle().bottom(); + int cases = 0; + for (int[] size : new int[][] {{426, 240}, {426, 265}, {320, 240}}) { + for (boolean terrain : new boolean[] {true, false}) { + for (boolean template : new boolean[] {false, true}) { + for (boolean fluids : new boolean[] {false, true}) { + WorldGeologyProfile profile = profile(terrain, template, fluids); + GridLayoutTab tab = (GridLayoutTab) constructor.newInstance(parent, profile, List.of("minecraft:overworld")); + ScreenRectangle area = new ScreenRectangle(0, tabTop, size[0], size[1] - footerInset - tabTop); + tab.doLayout(area); + List controls = new ArrayList<>(); + tab.visitChildren(controls::add); + assertFits(controls, 0, tabTop, size[0], area.bottom() - 2); + assertHeader(controls); + // Reuse the tab after resizing to catch stale widths and clipped labels. + tab.doLayout(new ScreenRectangle(0, tabTop, 320, 240 - footerInset - tabTop)); + tab.doLayout(area); + assertFits(controls, 0, tabTop, size[0], area.bottom() - 2); + assertHeader(controls); + + Screen fallback = new OreSpawnWorldSettingsScreen(parent, profile); + fallback.init(Minecraft.getInstance(), size[0], size[1]); + controls = fallback.children().stream().filter(AbstractWidget.class::isInstance) + .map(AbstractWidget.class::cast).filter(widget -> widget.getY() < size[1] - 28).toList(); + assertFits(controls, 0, 0, size[0], size[1] - 32); + assertHeader(controls); + cases++; + } + } + } + } + if (!pendingBefore.equals(WorldGeologyProfileManager.pendingNewWorldProfile().rootCopy().toString())) { + throw new IllegalStateException("Layout checks changed the pending world profile"); + } + completed = true; + System.out.println("MAIN_MENU_LAYOUT_PASS cases=" + cases + " native_and_fallback=true header_pair=true footer_clear=true"); + } + + private static WorldGeologyProfile profile(boolean terrain, boolean template, boolean fluids) throws ReflectiveOperationException { + Class type = Class.forName("zone.moddev.mc.orespawn.client.GeologyEditorSession"); + Constructor constructor = type.getDeclaredConstructor(WorldGeologyProfile.class); + constructor.setAccessible(true); + Object session = constructor.newInstance(WorldGeologyProfile.recommended(fluids)); + if (terrain) { + Method configure = type.getDeclaredMethod("configureDefaultVanillaStrata"); + configure.setAccessible(true); + configure.invoke(session); + } + Method currentProfile = type.getDeclaredMethod("profile"); + currentProfile.setAccessible(true); + WorldGeologyProfile profile = (WorldGeologyProfile) currentProfile.invoke(session); + JsonObject root = profile.rootCopy(); + if (template) root.addProperty("selected_template", "orespawn:layout_probe"); + if (fluids) { + JsonObject dimension = new JsonObject(); + dimension.addProperty("enabled", true); + dimension.addProperty("min_y", 16); + dimension.addProperty("max_y", 32); + dimension.addProperty("frequency", 0.05); + dimension.addProperty("min_radius", 3); + dimension.addProperty("max_radius", 5); + dimension.addProperty("min_vertical_radius", 2); + dimension.addProperty("max_vertical_radius", 3); + dimension.addProperty("max_lobes", 1); + dimension.addProperty("min_solid_cover", 1); + JsonArray hosts = new JsonArray(); + hosts.add("minecraft:stone"); + dimension.add("host_blocks", hosts); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", dimension); + JsonObject fluid = new JsonObject(); + fluid.addProperty("enabled", true); + fluid.addProperty("block", "minecraft:water"); + fluid.add("dimensions", dimensions); + JsonObject deposits = new JsonObject(); + deposits.add("orespawn:layout_water", fluid); + root.add("fluid_deposits", deposits); + } + return profile.withRoot(root); + } + + private static T field(CreateWorldScreen screen, Class type) throws ReflectiveOperationException { + for (Field field : CreateWorldScreen.class.getDeclaredFields()) { + if (field.getType() != type) continue; + field.setAccessible(true); + return type.cast(field.get(screen)); + } + throw new NoSuchFieldException(type.getName()); + } + + private static void assertHeader(List controls) { + AbstractWidget recommended = named(controls, "Recommended Defaults"); + AbstractWidget help = named(controls, "Help & Guide"); + if (recommended.getY() != help.getY() || recommended.getX() + recommended.getWidth() > help.getX()) { + throw new IllegalStateException("Help and Recommended Defaults do not share a clear header row"); + } + } + + private static AbstractWidget named(List controls, String label) { + List matches = controls.stream() + .filter(widget -> widget.visible && widget.getMessage().getString().equals(label)).toList(); + if (matches.size() != 1) throw new IllegalStateException("Expected one main-menu action: " + label); + return matches.get(0); + } + + private static void assertFits(List controls, int left, int top, int right, int bottom) { + List visible = controls.stream().filter(widget -> widget.visible).toList(); + for (int index = 0; index < visible.size(); index++) { + AbstractWidget widget = visible.get(index); + if (widget.getX() < left || widget.getY() < top || widget.getX() + widget.getWidth() > right + || widget.getY() + widget.getHeight() > bottom) { + throw new IllegalStateException("Main control crosses the content boundary: " + widget.getMessage().getString() + + " at " + widget.getRectangle() + " limit=" + left + "," + top + "," + right + "," + bottom); + } + for (int other = index + 1; other < visible.size(); other++) { + AbstractWidget peer = visible.get(other); + if (widget.getX() < peer.getX() + peer.getWidth() && widget.getX() + widget.getWidth() > peer.getX() + && widget.getY() < peer.getY() + peer.getHeight() && widget.getY() + widget.getHeight() > peer.getY()) { + throw new IllegalStateException("Main controls overlap: " + widget.getMessage().getString() + " / " + peer.getMessage().getString()); + } + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index 25de6aec..bb1f45ed 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -66,6 +66,7 @@ public OreSpawn(IEventBus modBus, ModContainer modContainer) { NeoForge.EVENT_BUS.addListener(WorldMaterialWeather::onChunkLoad); NeoForge.EVENT_BUS.addListener(WorldMaterialWeather::onWorldTick); NeoForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerAboutToStart); + NeoForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStarting); NeoForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onWorldLoad); NeoForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStopped); NeoForge.EVENT_BUS.addListener(OreRetrogenManager::onChunkLoad); diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java new file mode 100644 index 00000000..88be3e79 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.orespawn.api; + +import java.util.Optional; + +import net.minecraft.resources.ResourceLocation; + +/** + * Extended placement context supplied by OreSpawn 4.1 and later. + * + *

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

+ * + *

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

+ */ +public interface OreGenerationContext extends OrePlacementContext { + /** + * Attempts placement using a stable identity shared by every slice of one + * logical deposit body. Implementations compiled before this overload retain + * the original placement behaviour. + */ + default boolean tryPlace(int x, int y, int z, long outputIdentity) { + return tryPlace(x, y, z); + } + + /** Stable seed of the world being generated. */ + long worldSeed(); + + /** Stable namespaced identity of the dimension being generated. */ + ResourceLocation dimension(); + + /** X coordinate of the current chunk, not its minimum block coordinate. */ + int chunkX(); + + /** Z coordinate of the current chunk, not its minimum block coordinate. */ + int chunkZ(); + + /** + * Returns the active allocation-light geology sampler when the dimension has + * an OreSpawn geology configuration. Pattern implementations should cache no + * world or implementation objects beyond the placement call. + */ + Optional geologySampler(); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java index a44c8b6a..9b9c2d68 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java @@ -9,11 +9,13 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.function.Consumer; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; import net.minecraft.resources.ResourceLocation; @@ -45,7 +47,7 @@ public int revision() { return revision; } - /** Returns a defensive JSON representation matching provider schema 4. */ + /** Returns a defensive JSON representation matching provider schema 5. */ public JsonObject toJson() { return definition.deepCopy(); } @@ -207,7 +209,7 @@ public WorldgenProvider build() { requireOwned(dimensionMaterials.keySet(), "dimension materials"); requireOwned(templates.keySet(), "template"); JsonObject root = new JsonObject(); - root.addProperty("schema_version", 4); + root.addProperty("schema_version", 5); root.addProperty("provider_modid", modId); root.addProperty("provider_revision", revision); root.add("rocks", object(rocks)); @@ -345,6 +347,7 @@ public RockDefinition build() { public static final class OreDefinition implements JsonDefinition { private final ResourceLocation id; private final ResourceLocation block; + private final ResourceLocation material; private final boolean enabled; private final boolean nativeGeneration; private final ResourceLocation deepOutput; @@ -358,6 +361,7 @@ public static final class OreDefinition implements JsonDefinition { private OreDefinition(Builder builder) { id = builder.id; block = builder.block; + material = builder.material; enabled = builder.enabled; nativeGeneration = builder.nativeGeneration; deepOutput = builder.deepOutput; @@ -373,6 +377,8 @@ private OreDefinition(Builder builder) { public static Builder builder(ResourceLocation id, ResourceLocation block) { return new Builder(id, block); } public ResourceLocation id() { return id; } public ResourceLocation block() { return block; } + /** Material shared by interchangeable ore outputs. */ + public Optional material() { return Optional.ofNullable(material); } public boolean enabled() { return enabled; } public List outputs() { return outputs; } public boolean suppressVanilla() { return suppressVanilla; } @@ -384,6 +390,7 @@ private OreDefinition(Builder builder) { public JsonObject toJson() { JsonObject json = new JsonObject(); json.addProperty("block", block.toString()); + if (material != null) json.addProperty("material", material.toString()); json.addProperty("enabled", enabled); json.addProperty("native_generation", nativeGeneration); json.addProperty("suppress_vanilla", suppressVanilla); @@ -409,6 +416,7 @@ public JsonObject toJson() { public static final class Builder { private final ResourceLocation id; private final ResourceLocation block; + private ResourceLocation material; private boolean enabled = true; private boolean nativeGeneration; private ResourceLocation deepOutput; @@ -425,6 +433,7 @@ private Builder(ResourceLocation id, ResourceLocation block) { this.block = Objects.requireNonNull(block, "block"); } public Builder enabled(boolean value) { enabled = value; return this; } + public Builder material(ResourceLocation value) { material = Objects.requireNonNull(value); return this; } public Builder nativeGeneration(boolean value) { nativeGeneration = value; return this; } public Builder suppressVanilla(boolean value) { suppressVanilla = value; return this; } public Builder retrogen(boolean value) { retrogen = value; return this; } @@ -516,6 +525,7 @@ public static final class OreDimensionDefinition implements JsonDefinition { private final int maxQuantity; private final OrePattern pattern; private final ResourceLocation patternType; + private final ResourceLocation placementChannel; private final JsonObject patternSettings; private final OreHeightDistribution heightDistribution; private final double discardChanceOnAirExposure; @@ -543,7 +553,10 @@ private OreDimensionDefinition(Builder builder) { maxQuantity = builder.maxQuantity; pattern = builder.pattern; patternType = builder.patternType; - patternSettings = builder.patternSettings.deepCopy(); + placementChannel = builder.placementChannel != null ? builder.placementChannel + : builder.patternType != null ? builder.patternType + : new ResourceLocation("orespawn", "standard"); + patternSettings = JsonCopies.copy(builder.patternSettings); heightDistribution = builder.heightDistribution; discardChanceOnAirExposure = builder.discardChanceOnAirExposure; spread = builder.spread; @@ -574,7 +587,9 @@ private OreDimensionDefinition(Builder builder) { public int maxQuantity() { return maxQuantity; } public OrePattern pattern() { return pattern; } public ResourceLocation patternType() { return patternType; } - public JsonObject patternSettings() { return patternSettings.deepCopy(); } + /** Placement frequency, shape and host budget are selected per channel. */ + public ResourceLocation placementChannel() { return placementChannel; } + public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); } public OreHeightDistribution heightDistribution() { return heightDistribution; } public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; } public int spread() { return spread; } @@ -612,6 +627,7 @@ public JsonObject toJson() { configuredPattern.add("settings", patternSettings.deepCopy()); json.add("pattern", configuredPattern); } + json.addProperty("placement_channel", placementChannel.toString()); json.addProperty("height_distribution", heightDistribution.configName()); json.addProperty("discard_chance_on_air_exposure", discardChanceOnAirExposure); json.addProperty("spread", spread); @@ -640,6 +656,7 @@ public static final class Builder { private int maxQuantity = 8; private OrePattern pattern = OrePattern.VEIN; private ResourceLocation patternType; + private ResourceLocation placementChannel; private JsonObject patternSettings = new JsonObject(); private OreHeightDistribution heightDistribution = OreHeightDistribution.UNIFORM; private double discardChanceOnAirExposure; @@ -676,6 +693,10 @@ public Builder pattern(ResourceLocation type, JsonObject settings) { patternSettings = Objects.requireNonNull(settings, "settings").deepCopy(); return this; } + public Builder placementChannel(ResourceLocation value) { + placementChannel = Objects.requireNonNull(value); + return this; + } public Builder heightDistribution(OreHeightDistribution value) { heightDistribution = Objects.requireNonNull(value); return this; } public Builder discardChanceOnAirExposure(double value) { discardChanceOnAirExposure = value; return this; } public Builder spread(int horizontal, int vertical) { spread = horizontal; verticalSpread = vertical; return this; } diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java new file mode 100644 index 00000000..81feffae --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.Objects; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; + +/** Immutable registered action displayed by OreSpawn's world-settings screen. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtension { + private final ResourceLocation id; + private final String buttonTranslationKey; + private final WorldSettingsScreenFactory screenFactory; + + WorldSettingsExtension(ResourceLocation id, String buttonTranslationKey, + WorldSettingsScreenFactory screenFactory) { + this.id = Objects.requireNonNull(id, "id"); + this.buttonTranslationKey = Objects.requireNonNull(buttonTranslationKey, + "buttonTranslationKey"); + this.screenFactory = Objects.requireNonNull(screenFactory, "screenFactory"); + } + + public ResourceLocation id() { return id; } + public String buttonTranslationKey() { return buttonTranslationKey; } + + /** Creates the extension screen with OreSpawn's Mods directory as its parent. */ + public Screen createScreen(Screen parent) { + return screenFactory.create(Objects.requireNonNull(parent, "parent")); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java new file mode 100644 index 00000000..ba0c1a5c --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java @@ -0,0 +1,79 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +import net.minecraft.resources.ResourceLocation; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; + +/** Client-only registry for add-on screens in OreSpawn's Mods directory. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtensionRegistry { + private static final Pattern MOD_ID = Pattern.compile("^[a-z][a-z0-9_.-]{1,63}$"); + private static final Map EXTENSIONS = + new LinkedHashMap<>(); + + private WorldSettingsExtensionRegistry() { + } + + /** + * Registers the one optional OreSpawn-linked configuration screen owned by a + * loaded mod. Registration must occur during client initialization. + * + * @throws IllegalArgumentException if the mod identifier is not canonical + * @throws IllegalStateException if the mod already registered a screen + */ + public static synchronized WorldSettingsExtension registerConfigScreen(String modId, + WorldSettingsScreenFactory screenFactory) { + validateModId(modId); + return registerOwned(modId, new ResourceLocation(modId, "configuration"), + "button.orespawn.mod.configure", screenFactory); + } + + /** + * Retains the original translated world-settings registration descriptor for + * existing add-ons. The identifier namespace owns the one permitted screen. + * Registration must occur during client initialization. + * + * @throws IllegalStateException if the identifier is already registered + */ + public static synchronized WorldSettingsExtension register(ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(id, "id"); + if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) { + throw new IllegalArgumentException("buttonTranslationKey cannot be blank"); + } + String modId = id.getNamespace(); + validateModId(modId); + return registerOwned(modId, id, buttonTranslationKey, screenFactory); + } + + private static WorldSettingsExtension registerOwned(String modId, ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(screenFactory, "screenFactory"); + if (EXTENSIONS.containsKey(modId)) { + throw new IllegalStateException("Duplicate OreSpawn configuration screen for mod: " + modId); + } + WorldSettingsExtension extension = new WorldSettingsExtension(id, buttonTranslationKey, + screenFactory); + EXTENSIONS.put(modId, extension); + return extension; + } + + private static void validateModId(String modId) { + if (modId == null || !MOD_ID.matcher(modId).matches()) { + throw new IllegalArgumentException("modId must be a canonical lower-case mod identifier"); + } + } + + /** Returns an immutable snapshot in deterministic registration order. */ + public static synchronized List extensions() { + return Collections.unmodifiableList(new ArrayList<>(EXTENSIONS.values())); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java new file mode 100644 index 00000000..41c55bb5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java @@ -0,0 +1,12 @@ +package zone.moddev.mc.orespawn.api.client; + +import net.minecraft.client.gui.screens.Screen; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; + +/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */ +@FunctionalInterface +@OnlyIn(Dist.CLIENT) +public interface WorldSettingsScreenFactory { + Screen create(Screen parent); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java new file mode 100644 index 00000000..9aa1f7e7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -0,0 +1,385 @@ +package zone.moddev.mc.orespawn.client; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; +import zone.moddev.mc.orespawn.util.JsonCopies; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.minecraft.tags.BiomeTags; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.Biomes; +import net.neoforged.fml.ModList; +import net.neoforged.neoforgespi.language.IModInfo; +import zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess; + +/** Immutable projection of loaded biomes, profile palettes, and provider defaults. */ +final class BiomeDirectoryModel { + private static final String OVERWORLD = "minecraft:overworld"; + private static final String NETHER = "minecraft:the_nether"; + private static final String END = "minecraft:the_end"; + private static final Method BIOME_NAME = findBiomeNameMethod(); + + private BiomeDirectoryModel() { } + + static Registry worldCreationRegistry(CreateWorldScreen screen) { + return screen.getUiState().getSettings().worldgenLoadContext() + .registryOrThrow(Registries.BIOME); + } + + static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults, + Iterable dimensions) { + return snapshot(profile, defaults, dimensions, null); + } + + static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults, + Iterable dimensions, Registry biomeRegistry) { + List loaded = new ArrayList<>(); + Map mods = new LinkedHashMap<>(); + ModList modList = ModList.get(); + if (modList != null) { + for (IModInfo mod : modList.getMods()) { + mods.put(mod.getModId(), new ModInfo(mod.getModId(), mod.getDisplayName(), + mod.getVersion().toString())); + } + } + mods.putIfAbsent("minecraft", new ModInfo("minecraft", "Minecraft", "1.20.6")); + Map, Biome> biomes = new LinkedHashMap<>(); + if (biomeRegistry != null) { + for (Entry, Biome> entry : biomeRegistry.entrySet()) + biomes.put(entry.getKey(), entry.getValue()); + } else { + for (ResourceLocation id : BiomeRegistryAccess.keys()) + biomes.put(ResourceKey.create(Registries.BIOME, id), BiomeRegistryAccess.get(id)); + } + for (Entry, Biome> entry : biomes.entrySet()) { + ResourceLocation id = entry.getKey().location(); + ModInfo owner = mods.get(id.getNamespace()); + loaded.add(new LoadedBiome(id.toString(), biomeName(id, entry.getValue()), + owner == null ? id.getNamespace() : owner.name, + owner == null ? "?" : owner.version, + routineDimension(entry.getKey(), biomeRegistry))); + } + return assemble(profile, defaults.biomePalettesCopy(), + defaults.activeProviderIds(), loaded, dimensions); + } + + private static String biomeName(ResourceLocation id, Biome biome) { + if (BIOME_NAME == null) return friendly(id.toString()); + try { + return (String) BIOME_NAME.invoke(biome); + } catch (IllegalAccessException | InvocationTargetException exception) { + return friendly(id.toString()); + } + } + + private static Method findBiomeNameMethod() { + for (String name : new String[] { "getBiomeName", "func_185359_l" }) { + try { + Method method = Biome.class.getDeclaredMethod(name); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException ignored) { + // Use the registry name when this runtime has no public biome-name method. + } + } + return null; + } + + static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, + Set activeProviders, List loadedBiomes, + Iterable requestedDimensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedBiome biome : loadedBiomes) loaded.put(biome.id, biome); + Set dimensions = new LinkedHashSet<>(); + for (String dimension : requestedDimensions) dimensions.add(dimension); + JsonObject profilePalettes = object(profile, "biome_palettes"); + List palettes = new ArrayList<>(); + int order = 0; + for (Entry entry : profilePalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", "minecraft:overworld"); + dimensions.add(dimension); + palettes.add(new Palette(entry.getKey(), dimension, + string(palette, "source_provider", owner(entry.getKey())), + order++, bool(palette, "enabled", true), + BiomeReplacementRules.isOverridePalette(entry.getKey()), + palette.has("source_provider"), palette)); + } + + Map> providerBiomes = providerBiomes(providerPalettes); + Map> declaredDimensions = declaredDimensions(palettes, providerBiomes); + List entries = new ArrayList<>(); + for (String dimension : dimensions) { + Map> placements = placements(palettes, dimension); + Map replacements = BiomeReplacementRules.read(profile, dimension); + Set ids = new LinkedHashSet<>(); + for (LoadedBiome biome : loaded.values()) { + Set declared = declaredDimensions.getOrDefault(biome.id, Collections.emptySet()); + if (declared.isEmpty() ? dimension.equals(biome.routineDimension) + : declared.contains(dimension)) ids.add(biome.id); + } + ids.addAll(placements.keySet()); + ids.addAll(providerBiomes.getOrDefault(dimension, Collections.emptySet())); + ids.addAll(replacements.keySet()); + ids.addAll(replacements.values()); + for (String id : ids) { + LoadedBiome biome = loaded.get(id); + List rules = placements.getOrDefault(id, Collections.emptyList()); + boolean managed = providerBiomes.getOrDefault(dimension, Collections.emptySet()).contains(id); + boolean enabled = false; + boolean missingProvider = false; + for (Placement placement : rules) enabled |= placement.enabled; + for (Placement placement : rules) missingProvider |= placement.providerOwned + && !activeProviders.contains(placement.owner); + Status status; + if (biome == null || missingProvider) status = Status.MISSING; + else if (replacements.containsKey(id)) status = Status.USER_REPLACED; + else if (rules.size() > 1) status = Status.LAYERED; + else if (!rules.isEmpty() && !enabled) status = Status.DISABLED; + else if (managed || !rules.isEmpty()) status = Status.PROVIDER_MANAGED; + else status = Status.UNMANAGED; + entries.add(new BiomeEntry(dimension, id, + biome == null ? friendly(id) : biome.name, + biome == null ? owner(id) : biome.ownerName, + biome == null ? "?" : biome.ownerVersion, + status, rules, replacements.get(id), managed)); + } + } + Comparator entryOrder = Comparator + .comparingInt((BiomeEntry entry) -> entry.status.priority) + .thenComparing(entry -> entry.name.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.id) + .thenComparing(entry -> entry.dimension); + entries.sort(entryOrder); + palettes.sort(Comparator.comparingInt(palette -> palette.order)); + return new Snapshot(entries, palettes, dimensions, activeProviders); + } + + static String routineDimension(ResourceKey biome) { + return routineDimension(biome, null); + } + + static String routineDimension(ResourceKey biome, Registry biomeRegistry) { + if ((biomeRegistry == null ? BiomeRegistryAccess.holder(biome) + : biomeRegistry.getHolder(biome)) + .map(holder -> holder.is(BiomeTags.IS_NETHER)).orElse(false)) return NETHER; + if ((biomeRegistry == null ? BiomeRegistryAccess.holder(biome) + : biomeRegistry.getHolder(biome)) + .map(holder -> holder.is(BiomeTags.IS_END)).orElse(false)) return END; + // Dynamic tags are not always bound while the world-creation screen is open. + if (biome.equals(Biomes.NETHER_WASTES) || biome.equals(Biomes.CRIMSON_FOREST) + || biome.equals(Biomes.WARPED_FOREST) || biome.equals(Biomes.SOUL_SAND_VALLEY) + || biome.equals(Biomes.BASALT_DELTAS)) return NETHER; + if (biome.equals(Biomes.THE_END) || biome.equals(Biomes.END_HIGHLANDS) + || biome.equals(Biomes.END_MIDLANDS) || biome.equals(Biomes.SMALL_END_ISLANDS) + || biome.equals(Biomes.END_BARRENS)) return END; + return OVERWORLD; + } + + private static Map> declaredDimensions(List palettes, + Map> providerBiomes) { + Map> result = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (palette.override) continue; + for (String id : JsonCopies.keys(object(palette.definition, "biomes"))) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(palette.dimension); + } + } + for (Entry> dimension : providerBiomes.entrySet()) { + for (String id : dimension.getValue()) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(dimension.getKey()); + } + } + return result; + } + + private static Map> providerBiomes(JsonObject providerPalettes) { + Map> result = new LinkedHashMap<>(); + for (Entry entry : providerPalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", ""); + Set values = result.computeIfAbsent(dimension, ignored -> new LinkedHashSet<>()); + for (Entry biome : object(palette, "biomes").entrySet()) { + values.add(biome.getKey()); + } + } + return result; + } + + private static Map> placements(List palettes, String dimension) { + Map> result = new LinkedHashMap<>(); + Map lastSurface = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (!dimension.equals(palette.dimension) || palette.override) continue; + for (Entry entry : object(palette.definition, "biomes").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject placement = entry.getValue().getAsJsonObject(); + List list = result.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()); + boolean hasSurface = placement.has("surface") && placement.get("surface").isJsonObject() + && !placement.getAsJsonObject("surface").entrySet().isEmpty(); + list.add(new Placement(palette.id, palette.owner, palette.order, + palette.enabled && bool(placement, "enabled", true), hasSurface, false, + palette.providerOwned, placement)); + if (palette.enabled && bool(placement, "enabled", true) && hasSurface) { + lastSurface.put(entry.getKey(), list.size() - 1); + } + } + } + for (Entry> entry : result.entrySet()) { + Integer effective = lastSurface.get(entry.getKey()); + if (effective == null) continue; + List updated = new ArrayList<>(); + for (int i = 0; i < entry.getValue().size(); i++) { + Placement value = entry.getValue().get(i); + updated.add(value.withEffectiveSurface(i == effective)); + } + entry.setValue(updated); + } + return result; + } + + private static String owner(String id) { + try { return new ResourceLocation(id).getNamespace(); } + catch (RuntimeException ignored) { return "unknown"; } + } + + private static String friendly(String id) { + String value; + try { value = new ResourceLocation(id).getPath(); } + catch (RuntimeException ignored) { value = id; } + StringBuilder result = new StringBuilder(); + for (String part : value.replace('/', '_').split("_")) { + if (part.isEmpty()) continue; + if (result.length() > 0) result.append(' '); + result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return result.length() == 0 ? id : result.toString(); + } + + private static JsonObject object(JsonObject root, String key) { + return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject(); + } + private static String string(JsonObject root, String key, String fallback) { + return root.has(key) ? root.get(key).getAsString() : fallback; + } + private static boolean bool(JsonObject root, String key, boolean fallback) { + return root.has(key) ? root.get(key).getAsBoolean() : fallback; + } + + enum Status { + USER_REPLACED(0), LAYERED(1), DISABLED(2), MISSING(3), PROVIDER_MANAGED(4), UNMANAGED(5); + final int priority; + Status(int priority) { this.priority = priority; } + } + + static final class Snapshot { + final List entries; + final List palettes; + final Set dimensions; + final Set activeProviders; + Snapshot(List entries, List palettes, Set dimensions, + Set activeProviders) { + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + this.palettes = Collections.unmodifiableList(new ArrayList<>(palettes)); + this.dimensions = Collections.unmodifiableSet(new LinkedHashSet<>(dimensions)); + this.activeProviders = Collections.unmodifiableSet(new LinkedHashSet<>(activeProviders)); + } + List entries(String dimension, boolean showAll) { + List result = new ArrayList<>(); + for (BiomeEntry entry : entries) { + if (dimension.equals(entry.dimension) && (showAll || entry.status != Status.UNMANAGED)) result.add(entry); + } + return Collections.unmodifiableList(result); + } + List palettes(String dimension) { + List result = new ArrayList<>(); + for (Palette palette : palettes) if (dimension.equals(palette.dimension)) result.add(palette); + return Collections.unmodifiableList(result); + } + } + + static final class LoadedBiome { + final String id, name, ownerName, ownerVersion, routineDimension; + LoadedBiome(String id, String name, String ownerName, String ownerVersion) { + this(id, name, ownerName, ownerVersion, OVERWORLD); + } + LoadedBiome(String id, String name, String ownerName, String ownerVersion, + String routineDimension) { + this.id = id; this.name = name; this.ownerName = ownerName; this.ownerVersion = ownerVersion; + this.routineDimension = routineDimension; + } + } + + static final class BiomeEntry { + final String dimension, id, name, ownerName, ownerVersion; + final Status status; + final List placements; + final String replacementTarget; + final boolean providerDeclared; + BiomeEntry(String dimension, String id, String name, String ownerName, String ownerVersion, + Status status, List placements, String replacementTarget, + boolean providerDeclared) { + this.dimension = dimension; this.id = id; this.name = name; + this.ownerName = ownerName; this.ownerVersion = ownerVersion; this.status = status; + this.placements = Collections.unmodifiableList(new ArrayList<>(placements)); + this.replacementTarget = replacementTarget; this.providerDeclared = providerDeclared; + } + } + + static final class Palette { + final String id, dimension, owner; + final int order; + final boolean enabled, override, providerOwned; + final JsonObject definition; + Palette(String id, String dimension, String owner, int order, boolean enabled, + boolean override, boolean providerOwned, JsonObject definition) { + this.id = id; this.dimension = dimension; this.owner = owner; this.order = order; + this.enabled = enabled; this.override = override; this.providerOwned = providerOwned; + this.definition = JsonCopies.copy(definition); + } + } + + static final class Placement { + final String paletteId, owner; + final int order; + final boolean enabled, hasSurface, effectiveSurface, providerOwned; + final JsonObject definition; + Placement(String paletteId, String owner, int order, boolean enabled, + boolean hasSurface, boolean effectiveSurface, boolean providerOwned, + JsonObject definition) { + this.paletteId = paletteId; this.owner = owner; this.order = order; + this.enabled = enabled; this.hasSurface = hasSurface; + this.effectiveSurface = effectiveSurface; this.providerOwned = providerOwned; + this.definition = JsonCopies.copy(definition); + } + Placement withEffectiveSurface(boolean value) { + return new Placement(paletteId, owner, order, enabled, hasSurface, value, + providerOwned, definition); + } + } + + private static final class ModInfo { + final String id, name, version; + ModInfo(String id, String name, String version) { this.id = id; this.name = name; this.version = version; } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java index 913c3d4b..c8d3bec4 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java @@ -1,22 +1,29 @@ package zone.moddev.mc.orespawn.client; +import java.util.ArrayList; import java.util.List; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.components.Button; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Palette; + +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; +/** Lists every palette in effective profile order for one dimension. */ final class BiomePaletteScreen extends OreSpawnScreen { private final Screen parent; private final GeologyEditorSession session; private final String dimension; - private int page; + private CompactScrollList list; + private int scroll; + private String armedPalette; + private int resetScope; BiomePaletteScreen(Screen parent, GeologyEditorSession session, String dimension) { - super(Component.translatable("screen.orespawn.biome_palette")); + super(Component.translatable("screen.orespawn.biome_palettes")); this.parent = parent; this.session = session; this.dimension = dimension; @@ -24,49 +31,84 @@ 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; - addRenderableWidget(OreSpawnScreenLayout.button(this, font, left, y, - contentWidth - removeWidth - 5, 20, Component.literal(id), - button -> minecraft.setScreen(new BiomePlacementScreen(this, session, - dimension, id)))); - addRenderableWidget(OreSpawnScreenLayout.plainButton(left + contentWidth - removeWidth, y, removeWidth, 20, - Component.translatable("button.orespawn.remove"), - button -> { session.removeBiomePlacement(dimension, id); rebuildWidgets(); })); - } - Button previous = addRenderableWidget(OreSpawnScreenLayout.plainButton(left, controlsY, 45, 20, - Component.literal("<"), button -> { page--; rebuildWidgets(); })); - Button next = addRenderableWidget(OreSpawnScreenLayout.plainButton(left + 50, controlsY, 45, 20, - Component.literal(">"), button -> { page++; rebuildWidgets(); })); - previous.active = page > 0; - next.active = page + 1 < pageCount; - addRenderableWidget(OreSpawnScreenLayout.button(this, font, - left + contentWidth - 150, controlsY, 150, 20, - Component.translatable("button.orespawn.add_biome"), - button -> minecraft.setScreen(new BiomePickerScreen(this, session, id -> { - session.addBiomePlacement(dimension, id); - minecraft.setScreen(new BiomePlacementScreen(this, session, dimension, id)); - })))); - addRenderableWidget(OreSpawnScreenLayout.plainButton(width / 2 - 75, height - 28, 150, 20, + List palettes = session.biomeDirectory().palettes(dimension); + int listTop = 38; + int listHeight = Math.max(32, height - listTop - 62); + list = addButton(new CompactScrollList(this, left, listTop, contentWidth, listHeight) { + @Override protected int size() { return palettes.size(); } + @Override protected String rowText(int index) { + Palette palette = palettes.get(index); + return (palette.enabled ? "[x] " : "[ ] ") + (palette.order + 1) + ". " + palette.id; + } + @Override protected boolean rowEnabled(int index) { return !palettes.get(index).override; } + @Override protected int rowColor(int index) { + return palettes.get(index).override ? 0x55FFFF : palettes.get(index).enabled ? 0xFFFFFF : 0x888888; + } + @Override protected void onRowPressed(int index) { + Palette palette = palettes.get(index); + if (!palette.override) minecraft.setScreen(new BiomePaletteSettingsScreen( + BiomePaletteScreen.this, session, palette.id)); + } + @Override protected int rowActionWidth(int index) { return 18; } + @Override protected void drawRowAction(Minecraft minecraft, int index, int x, int y, + int width, int height, boolean hovered) { + drawCenteredString(font, armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "!" : "R", x + width / 2, y + 4, hovered ? 0xFFFFFF : 0xAAAAAA); + } + @Override protected void onRowActionPressed(int index) { resetPalette(palettes.get(index)); } + @Override protected List rowTooltip(int index) { + Palette palette = palettes.get(index); + List lines = new ArrayList<>(); + lines.add(palette.id); + lines.add(net.minecraft.client.resources.language.I18n.get( + "label.orespawn.biome.palette_owner_order", palette.owner, palette.order + 1)); + if (palette.override) lines.add(net.minecraft.client.resources.language.I18n.get( + "tooltip.orespawn.biome.override_palette")); + return lines; + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList(net.minecraft.client.resources.language.I18n.get( + armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "button.orespawn.biome.confirm_reset_palette" + : "button.orespawn.biome.reset_palette")); + } + }); + list.setFirstIndex(scroll); + int half = (contentWidth - 4) / 2; + int resetY = height - 52; + addButton(OreSpawnScreenLayout.button(this, font, left, resetY, half, 20, + Component.translatable(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, + Component.translatable(resetScope == 2 ? "button.orespawn.biome.confirm_reset_all" + : "button.orespawn.biome.reset_all"), button -> resetAll())); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, CommonComponents.GUI_DONE, button -> onClose())); } - protected void rebuildWidgets() { - clearWidgets(); - init(); + private void resetPalette(Palette palette) { + resetScope = 0; + if (!palette.id.equals(armedPalette)) { armedPalette = palette.id; rebuild(); return; } + session.resetBiomePalette(palette.id); armedPalette = null; rebuild(); + } + + private void resetDimension() { + armedPalette = null; + if (resetScope != 1) { resetScope = 1; rebuild(); return; } + session.resetBiomeDimension(dimension); resetScope = 0; rebuild(); } + private void resetAll() { + armedPalette = null; + if (resetScope != 2) { resetScope = 2; rebuild(); return; } + session.resetAllBiomeManagement(); resetScope = 0; rebuild(); + } + + private void rebuild() { if (list != null) scroll = list.firstIndex(); clearWidgets(); init(); } + @Override public void onClose() { minecraft.setScreen(parent); } @Override 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..0cff6ebd --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java @@ -0,0 +1,164 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.gui.GuiGraphics; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.network.chat.Component; + +/** Edits the complete set of controls already supported by one biome palette. */ +final class BiomePaletteSettingsScreen extends OreSpawnScreen { + private final Screen parent; + private final GeologyEditorSession session; + private final String paletteId; + private TextFieldWidget coverage; + private TextFieldWidget fallback; + private TextFieldWidget included; + private TextFieldWidget excluded; + + BiomePaletteSettingsScreen(Screen parent, GeologyEditorSession session, String paletteId) { + super(Component.translatable("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, Component.translatable("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, Component.translatable("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, + Component.translatable("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, + Component.translatable("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, + Component.translatable("option.orespawn.biome.include_namespaces"))); + included.setValue(join(palette.get("include_namespaces"))); + y += 24; + excluded = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + Component.translatable("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, Component.translatable("label.orespawn.biome.palette_entries", + session.biomePlacementIdsForPalette(paletteId).size()), button -> { }, + "tooltip.orespawn.biome.palette_entries_read_only")); + info.active = false; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private TextFieldWidget field(int x, int y, int width, double value, String label) { + TextFieldWidget field = addButton(new TextFieldWidget(font, x, y, width, 20, + Component.translatable(label))); + field.setValue(Double.toString(value)); + return field; + } + + private void save() { + JsonObject palette = session.biomePaletteById(paletteId); + if (palette == null) return; + putDecimal(palette, "coverage", coverage, 0.0D, 1.0D); + putDecimal(palette, "fallback_weight", fallback, 0.0D, 1000.0D); + palette.add("include_namespaces", namespaces(included)); + palette.add("exclude_namespaces", namespaces(excluded)); + } + + private static void putDecimal(JsonObject root, String key, TextFieldWidget field, + double min, double max) { + try { + double value = Double.parseDouble(field.getValue().trim()); + if (Double.isFinite(value)) root.addProperty(key, Math.max(min, Math.min(max, value))); + } catch (RuntimeException ignored) { } + } + + private static JsonArray namespaces(TextFieldWidget field) { + Set values = new LinkedHashSet<>(); + if (field != null) for (String raw : field.getValue().split(",")) { + String value = raw.trim().toLowerCase(java.util.Locale.ROOT); + if (value.matches("[a-z][a-z0-9_.-]{0,63}")) values.add(value); + } + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String join(JsonElement element) { + if (element == null || !element.isJsonArray()) return ""; + StringBuilder result = new StringBuilder(); + for (JsonElement value : element.getAsJsonArray()) { + if (result.length() > 0) result.append(", "); + result.append(value.getAsString()); + } + return result.toString(); + } + + private Component modeName(String value) { + return Component.translatable("value.orespawn.biome_mode." + value); + } + private Component scopeName(String value) { + return Component.translatable("value.orespawn.biome_scope." + value); + } + private Component regionName(String value) { + return Component.translatable("value.orespawn.preset." + value); + } + + @Override public void onClose() { save(); minecraft.setScreen(parent); } + + @Override + protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, Component.literal(paletteId), width / 2, 22, 0xAAAAAA); + 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 2e3eb859..2199ddf2 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java @@ -6,7 +6,6 @@ import com.google.gson.JsonObject; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.CycleButton; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.Screen; @@ -19,6 +18,7 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } private final Screen parent; private final GeologyEditorSession session; private final String dimension; + private final String paletteId; private final String biomeId; private Tab tab = Tab.PLACEMENT; private EditBox weight; @@ -30,10 +30,21 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } BiomePlacementScreen(Screen parent, GeologyEditorSession session, String dimension, String biomeId) { + this(parent, session, dimension, paletteId(session, dimension), biomeId); + } + + private static String paletteId(GeologyEditorSession session, String dimension) { + if (session.biomePaletteId(dimension) == null) session.biomePalette(dimension, true); + return session.biomePaletteId(dimension); + } + + BiomePlacementScreen(Screen parent, GeologyEditorSession session, + String dimension, String paletteId, String biomeId) { super(Component.translatable("screen.orespawn.biome_placement")); this.parent = parent; this.session = session; this.dimension = dimension; + this.paletteId = paletteId; this.biomeId = biomeId; } @@ -51,21 +62,21 @@ protected void init() { selected -> { saveFields(); tab = value; rebuildWidgets(); })); button.active = value != tab; } - JsonObject placement = session.biomePlacement(dimension, biomeId); - addRenderableWidget(OreSpawnScreenLayout.explain( - CycleButton.onOffBuilder(bool(placement, "enabled", true)).create( - left, 64, contentWidth, 20, + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); + OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.onOffBuilder( + bool(placement, "enabled", true)) + .create(left, 64, contentWidth, 20, Component.translatable("option.orespawn.enabled"), - (button, value) -> placement.addProperty("enabled", value)), - "tooltip.orespawn.enabled")); + (button, value) -> placement.addProperty("enabled", value))), + "tooltip.orespawn.enabled"); if (tab == Tab.PLACEMENT) initPlacement(left, half, placement); else if (tab == Tab.CLIMATE) initClimate(left, half, placement); else initSurface(left, contentWidth, placement); addRenderableWidget(OreSpawnScreenLayout.plainButton(left, OreSpawnScreenLayout.footerY(height), half, 20, CommonComponents.GUI_DONE, button -> { saveFields(); onClose(); })); - addRenderableWidget(OreSpawnScreenLayout.plainButton(left + half + 5, OreSpawnScreenLayout.footerY(height), + addRenderableWidget(new Button(left + half + 5, OreSpawnScreenLayout.footerY(height), half, 20, Component.translatable("button.orespawn.remove"), button -> { - session.removeBiomePlacement(dimension, biomeId); + session.removeBiomePlacementByPalette(paletteId, biomeId); onClose(); })); } @@ -159,7 +170,7 @@ private void label(int x, int y, int labelWidth, String key) { } private void saveFields() { - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); if (weight != null) putDouble(placement, "weight", weight, 0.0D, 1000.0D); if (minTemperature != null) putDouble(placement, "min_temperature", minTemperature, -2.0D, 2.0D); if (maxTemperature != null) putDouble(placement, "max_temperature", maxTemperature, -2.0D, 2.0D); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java new file mode 100644 index 00000000..ec7f0dfa --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java @@ -0,0 +1,153 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.resources.ResourceLocation; + +/** Encodes exact new-terrain biome replacements in a terminal UI palette. */ +final class BiomeReplacementRules { + static final String PREFIX = "orespawn:ui/biome_overrides/"; + + private BiomeReplacementRules() { } + + static String paletteId(String dimension) { + return PREFIX + dimension.toLowerCase(java.util.Locale.ROOT) + .replace(':', '_').replaceAll("[^a-z0-9_./-]", "_"); + } + + static boolean isOverridePalette(String id) { + return id != null && id.startsWith(PREFIX); + } + + static Map read(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes == null) return Collections.emptyMap(); + JsonElement value = palettes.get(paletteId(dimension)); + if (value == null || !value.isJsonObject()) return Collections.emptyMap(); + JsonObject palette = value.getAsJsonObject(); + Map result = new LinkedHashMap<>(); + JsonObject biomes = object(palette, "biomes", false); + if (biomes == null) return Collections.emptyMap(); + for (Entry target : biomes.entrySet()) { + if (!target.getValue().isJsonObject()) continue; + JsonElement sources = target.getValue().getAsJsonObject().get("similar_biomes"); + if (sources == null || !sources.isJsonArray()) continue; + for (JsonElement source : sources.getAsJsonArray()) { + try { result.put(new ResourceLocation(source.getAsString()).toString(), + new ResourceLocation(target.getKey()).toString()); } + catch (RuntimeException ignored) { } + } + } + return Collections.unmodifiableMap(result); + } + + static void set(JsonObject root, String dimension, String source, String target) { + String normalizedSource = new ResourceLocation(source).toString(); + String normalizedTarget = new ResourceLocation(target).toString(); + if (normalizedSource.equals(normalizedTarget)) { + throw new IllegalArgumentException("A biome cannot replace itself"); + } + Map mappings = new LinkedHashMap<>(read(root, dimension)); + String terminal = terminal(mappings, normalizedTarget, normalizedSource); + for (Entry entry : new ArrayList<>(mappings.entrySet())) { + if (normalizedSource.equals(entry.getValue())) mappings.put(entry.getKey(), terminal); + } + mappings.put(normalizedSource, terminal); + flatten(mappings); + write(root, dimension, mappings); + } + + static void remove(JsonObject root, String dimension, String source) { + Map mappings = new LinkedHashMap<>(read(root, dimension)); + mappings.remove(source); + write(root, dimension, mappings); + } + + static void clearDimension(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes != null) palettes.remove(paletteId(dimension)); + } + + private static String terminal(Map mappings, String start, String rejected) { + Set seen = new LinkedHashSet<>(); + String current = start; + while (mappings.containsKey(current)) { + if (!seen.add(current) || rejected.equals(current)) { + throw new IllegalArgumentException("Biome replacement cycle"); + } + current = mappings.get(current); + } + if (rejected.equals(current)) throw new IllegalArgumentException("Biome replacement cycle"); + return current; + } + + private static void flatten(Map mappings) { + for (String source : new ArrayList<>(mappings.keySet())) { + mappings.put(source, terminal(mappings, mappings.get(source), source)); + } + } + + private static void write(JsonObject root, String dimension, Map mappings) { + JsonObject palettes = object(root, "biome_palettes", true); + String id = paletteId(dimension); + if (mappings.isEmpty()) { + palettes.remove(id); + return; + } + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", dimension); + palette.addProperty("enabled", true); + palette.addProperty("mode", "replace"); + palette.addProperty("scope", "all"); + palette.addProperty("region_size", "tiny"); + palette.addProperty("coverage", 1.0D); + palette.addProperty("fallback_weight", 0.0D); + palette.add("include_namespaces", new JsonArray()); + palette.add("exclude_namespaces", new JsonArray()); + JsonObject biomes = new JsonObject(); + Map> byTarget = new LinkedHashMap<>(); + List sources = new ArrayList<>(mappings.keySet()); + Collections.sort(sources); + for (String source : sources) { + byTarget.computeIfAbsent(mappings.get(source), ignored -> new ArrayList<>()).add(source); + } + List targets = new ArrayList<>(byTarget.keySet()); + Collections.sort(targets); + for (String target : targets) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray similar = new JsonArray(); + for (String source : byTarget.get(target)) similar.add(new JsonPrimitive(source)); + placement.add("similar_biomes", similar); + placement.add("required_similar_biomes", new JsonArray()); + placement.addProperty("min_temperature", -2.0D); + placement.addProperty("max_temperature", 2.0D); + placement.addProperty("min_downfall", 0.0D); + placement.addProperty("max_downfall", 1.0D); + biomes.add(target, placement); + } + palette.add("biomes", biomes); + palettes.add(id, palette); + } + + private static JsonObject object(JsonObject root, String key, boolean create) { + if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key); + if (!create) return null; + JsonObject value = new JsonObject(); + root.add(key, value); + return value; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java index b04dfd6c..64ecfaeb 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java @@ -1,25 +1,46 @@ package zone.moddev.mc.orespawn.client; -import java.util.Arrays; +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; import java.util.List; import com.google.gson.JsonObject; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.CycleButton; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.BiomeEntry; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Placement; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Snapshot; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Status; + import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.CommonComponents; +import net.minecraft.client.resources.language.I18n; import net.minecraft.network.chat.Component; -/** Dimension-level entry point for biome placement and world materials. */ +/** Responsive directory for biome placement, exact replacements, and diagnostics. */ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { + static final int TWO_PANE_MINIMUM = 400; + private static final int CONTENT_TOP = 48; private final Screen parent; private final GeologyEditorSession session; private String dimension; + private String selectedId; + private boolean showAll; + private boolean compactDetail; + private boolean resetArmed; + private String message; + private int biomeScroll; + private int ruleScroll; + private CompactScrollList biomeList; + private CompactScrollList ruleList; + private int detailX; + private int detailWidth; + private int detailRuleTop; + private int detailMessageTop; + private List detailMessageLines = java.util.Collections.emptyList(); BiomeWorldMaterialsScreen(Screen parent, GeologyEditorSession session) { - super(Component.translatable("screen.orespawn.biomes_world_materials")); + super(Component.translatable("screen.orespawn.biomes")); this.parent = parent; this.session = session; this.dimension = session.availableDimensionIds().get(0); @@ -27,133 +48,297 @@ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { @Override protected void init() { - int contentWidth = Math.min(390, Math.max(280, width - 24)); - int left = (width - contentWidth) / 2; - int half = (contentWidth - 5) / 2; - List dimensions = session.availableDimensionIds(); + OreSpawnScreenLayout.beginHelp(this); + 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); - addRenderableWidget(CycleButton.builder(this::dimensionName) - .withValues(dimensions).withInitialValue(dimension) - .withTooltip(value -> net.minecraft.client.gui.components.Tooltip.create( - OreSpawnScreenLayout.tooltip(Arrays.asList( - Component.literal(value), - Component.translatable("tooltip.orespawn.biome.dimension"))))) - .create(left, 34, contentWidth, 20, - Component.translatable("option.orespawn.dimension"), - (button, value) -> { dimension = value; rebuildWidgets(); })); - - 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; - addRenderableWidget(CycleButton.onOffBuilder(active) - .withTooltip(value -> net.minecraft.client.gui.components.Tooltip.create( - Component.translatable("tooltip.orespawn.biome.palette_enabled"))) - .create(left, y, contentWidth, 20, - Component.translatable("option.orespawn.biome_palette"), - (button, value) -> setPaletteEnabled(value))); - y += 26; - addRenderableWidget(CycleButton.builder(this::modeName) - .withValues(Arrays.asList("augment", "replace")).withInitialValue(mode) - .withTooltip(value -> net.minecraft.client.gui.components.Tooltip.create( - Component.translatable("tooltip.orespawn.biome.mode"))) - .create(left, y, half, 20, - Component.translatable("option.orespawn.biome_mode"), - (button, value) -> setPalette("mode", value))); - addRenderableWidget(CycleButton.builder(this::scopeName) - .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) - .withInitialValue(scope) - .withTooltip(value -> net.minecraft.client.gui.components.Tooltip.create( - Component.translatable("tooltip.orespawn.biome.scope"))) - .create(left + half + 5, y, half, 20, - Component.translatable("option.orespawn.biome_scope"), - (button, value) -> setPalette("scope", value))); - y += 26; - addRenderableWidget(CycleButton.builder(this::regionName) - .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) - .withInitialValue(size) - .withTooltip(value -> net.minecraft.client.gui.components.Tooltip.create( - Component.translatable("tooltip.orespawn.biome.region_size"))) - .create(left, y, contentWidth, 20, - Component.translatable("option.orespawn.biome_region_size"), - (button, value) -> setPalette("region_size", value))); - y += 30; - addRenderableWidget(OreSpawnScreenLayout.explain(OreSpawnScreenLayout.button( - this, font, left, y, half, 20, - Component.translatable("button.orespawn.biome_palette_count", - session.biomePlacementIds(dimension).size()), - button -> minecraft.setScreen(new BiomePaletteScreen(this, session, dimension))), - "tooltip.orespawn.biome.entries")); - addRenderableWidget(OreSpawnScreenLayout.explain(OreSpawnScreenLayout.button( - this, font, left + half + 5, y, half, 20, - Component.translatable("button.orespawn.dimension_materials"), - button -> minecraft.setScreen(new DimensionMaterialsScreen(this, session, dimension))), + List entries = snapshot.entries(dimension, showAll); + if (selectedId == null || find(entries, selectedId) == null) { + selectedId = entries.isEmpty() ? null : entries.get(0).id; + } + boolean compact = width < TWO_PANE_MINIMUM; + if (compact && compactDetail && selectedId != null) initDetail(snapshot, true); + else initDirectory(snapshot, entries, compact); + } + + private void initDirectory(Snapshot snapshot, List entries, boolean compact) { + int contentWidth = contentWidth(width); + int left = (width - contentWidth) / 2; + int leftWidth = compact ? contentWidth : leftPaneWidth(width); + int headerWidth = Math.max(76, Math.min(leftWidth - 74, 150)); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, 24, headerWidth, 20, + dimensionSelectorLabel(dimension), button -> cycleDimension(snapshot), + "tooltip.orespawn.biome.dimension")); + addButton(OreSpawnScreenLayout.button(this, font, left + leftWidth - 68, 24, 68, 20, + Component.translatable(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, + Component.translatable("button.orespawn.dimension_materials_named", + dimensionName(dimension)), + button -> minecraft.setScreen( + new DimensionMaterialsScreen(this, session, dimension)), "tooltip.orespawn.biome.dimension_materials")); - y += 26; - addRenderableWidget(OreSpawnScreenLayout.explain(OreSpawnScreenLayout.button( - this, font, left, y, contentWidth, 20, - Component.translatable("button.orespawn.geome_influences"), - button -> minecraft.setScreen(new GeomeBiomeScreen(this, session))), - "tooltip.orespawn.biome.geome_influences")); - addRenderableWidget(OreSpawnScreenLayout.plainButton(width / 2 - 75, OreSpawnScreenLayout.footerY(height), - 150, 20, CommonComponents.GUI_DONE, button -> onClose())); - } - - private void setPaletteEnabled(boolean enabled) { - JsonObject palette = session.biomePalette(dimension, true); - if (enabled && session.biomePlacementIds(dimension).isEmpty()) { - minecraft.setScreen(new BiomePickerScreen(this, session, - id -> { - session.addBiomePlacement(dimension, id); - minecraft.setScreen(new BiomePlacementScreen(this, session, dimension, id)); + if (!compact) initDetail(snapshot, false); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void initDetail(Snapshot snapshot, boolean compact) { + BiomeEntry selected = find(snapshot.entries(dimension, true), selectedId); + if (selected == null) return; + int contentWidth = contentWidth(width); + int contentLeft = (width - contentWidth) / 2; + int leftWidth = compact ? 0 : leftPaneWidth(width); + int x = compact ? contentLeft : contentLeft + leftWidth + 10; + int paneWidth = compact ? contentWidth : contentWidth - leftWidth - 10; + detailX = x; + detailWidth = paneWidth; + int y = detailTop(compact); + if (compact) { + addButton(OreSpawnScreenLayout.button(this, font, contentLeft, 8, 48, 20, + Component.translatable("button.orespawn.back"), button -> { + captureScroll(); compactDetail = false; rebuild(); })); - return; } - palette.addProperty("enabled", enabled); + Component replacement = selected.replacementTarget == null + ? Component.translatable("button.orespawn.biome.replace_new_terrain") + : Component.translatable("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, + Component.translatable("button.orespawn.biome.leave_original"), + button -> { session.leaveBiomeOriginal(dimension, selected.id); clearMessage(); rebuild(); }, + "tooltip.orespawn.biome.no_retrogen")); + } + detailMessageLines = wrapMessage(message, paneWidth - 6); + detailMessageTop = detailMessageTop(compact, selected.replacementTarget != null); + int ruleTop = detailRuleTop(compact, selected.replacementTarget != null, + detailMessageLines.size()); + detailRuleTop = ruleTop; + int controlsTop = controlsTop(height, ruleTop); + int ruleHeight = Math.max(16, controlsTop - ruleTop - 4); + List placements = selected.placements; + ruleList = addButton(new CompactScrollList(this, x, ruleTop, paneWidth, ruleHeight) { + @Override protected int size() { return placements.size(); } + @Override protected String rowText(int index) { + Placement rule = placements.get(index); + return (rule.enabled ? "[x] " : "[ ] ") + rule.paletteId + " #" + (rule.order + 1) + + (rule.effectiveSurface ? " *" : ""); + } + @Override protected int rowColor(int index) { return placements.get(index).enabled ? 0xFFFFFF : 0x888888; } + @Override protected void onRowPressed(int index) { + Placement rule = placements.get(index); + minecraft.setScreen(new BiomePlacementScreen(BiomeWorldMaterialsScreen.this, + session, dimension, rule.paletteId, selected.id)); + } + @Override protected List rowTooltip(int index) { return placementTooltip(placements.get(index)); } + }); + ruleList.setFirstIndex(ruleScroll); + int half = (paneWidth - 4) / 2; + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop, half, 20, + Component.translatable("button.orespawn.biome.palettes", snapshot.palettes(dimension).size()), + button -> minecraft.setScreen(new BiomePaletteScreen(this, session, dimension)))); + addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop, half, 20, + Component.translatable(resetArmed ? "button.orespawn.biome.confirm_reset" + : "button.orespawn.biome.reset_selected"), button -> resetSelected(selected))); + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop + 24, paneWidth, 20, + Component.translatable("button.orespawn.geome_influences"), + button -> minecraft.setScreen(new GeomeBiomeScreen(this, session)))); + } + + private void chooseReplacement(BiomeEntry selected) { + captureScroll(); + minecraft.setScreen(new BiomePickerScreen(this, session, target -> { + try { + session.replaceBiome(dimension, selected.id, target); + clearMessage(); + } catch (IllegalArgumentException failure) { + message = failure.getMessage(); + } + minecraft.setScreen(this); + })); } - private void setPalette(String key, String value) { - session.biomePalette(dimension, true).addProperty(key, value); + private void resetSelected(BiomeEntry selected) { + if (!resetArmed) { resetArmed = true; rebuild(); return; } + session.resetBiome(dimension, selected.id); + resetArmed = false; clearMessage(); rebuild(); } - private Component dimensionName(String value) { - return Component.literal(value); + private void cycleDimension(Snapshot snapshot) { + dimension = nextDimension(snapshot.dimensions, dimension); + selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; clearMessage(); rebuild(); } - private Component modeName(String value) { - return Component.translatable("value.orespawn.biome_mode." + value); + private void clearMessage() { + message = null; } - private Component scopeName(String value) { - return Component.translatable("value.orespawn.biome_scope." + value); + private List wrapMessage(String text, int maximumWidth) { + if (text == null || text.isEmpty()) return java.util.Collections.emptyList(); + int width = Math.max(40, maximumWidth); + List wrapped = new ArrayList<>(); + String remaining = text.trim(); + while (!remaining.isEmpty() && wrapped.size() < 2) { + String line = font.plainSubstrByWidth(remaining, width); + if (line.length() == remaining.length()) { + wrapped.add(line); + break; + } + int space = line.lastIndexOf(' '); + if (space > 0) line = line.substring(0, space); + wrapped.add(line); + remaining = remaining.substring(line.length()).trim(); + } + if (!remaining.isEmpty() && !wrapped.isEmpty()) { + int last = wrapped.size() - 1; + wrapped.set(last, OreSpawnScreenLayout.fit(font, + Component.literal(wrapped.get(last) + "..."), width).getString()); + } + return wrapped; } - private Component regionName(String value) { - return Component.translatable("value.orespawn.preset." + value); + static String nextDimension(Iterable available, String current) { + List dimensions = new ArrayList<>(); + for (String candidate : available) dimensions.add(candidate); + if (dimensions.isEmpty()) return "minecraft:overworld"; + int index = dimensions.indexOf(current); + return dimensions.get((Math.max(-1, index) + 1) % dimensions.size()); } - protected void rebuildWidgets() { - clearWidgets(); - init(); + private static BiomeEntry find(List entries, String id) { + if (id != null) for (BiomeEntry entry : entries) if (id.equals(entry.id)) return entry; + return null; } - @Override - public void onClose() { - minecraft.setScreen(parent); + static Component dimensionName(String dimension) { + switch (dimension) { + case "minecraft:overworld": return Component.translatable("value.orespawn.dimension.overworld"); + case "minecraft:the_nether": return Component.translatable("value.orespawn.dimension.the_nether"); + case "minecraft:the_end": return Component.translatable("value.orespawn.dimension.the_end"); + default: return Component.literal(dimension); + } } + private static Component dimensionSelectorLabel(String dimension) { + return Component.literal("< " + dimensionName(dimension).getString() + " >"); + } + + private static String statusMarker(Status status) { + switch (status) { + case USER_REPLACED: return "R"; + case LAYERED: return "!"; + case DISABLED: return "-"; + case MISSING: return "?"; + case PROVIDER_MANAGED: return "+"; + default: return " "; + } + } + + private static int statusColor(Status status) { + switch (status) { + case MISSING: return 0xFF5555; + case USER_REPLACED: return 0x55FFFF; + case LAYERED: return 0xFFFF55; + case DISABLED: return 0xAAAAAA; + case PROVIDER_MANAGED: return 0x55FF55; + default: return 0xFFFFFF; + } + } + + private static List biomeTooltip(BiomeEntry entry) { + List lines = new ArrayList<>(); + lines.add(entry.name + " (" + entry.id + ")"); + lines.add(entry.ownerName + " " + entry.ownerVersion); + lines.add(I18n.get("value.orespawn.biome.status." + entry.status.name().toLowerCase(java.util.Locale.ROOT))); + lines.add(I18n.get("label.orespawn.biome.rule_count", entry.placements.size())); + if (entry.replacementTarget != null) lines.add(I18n.get("label.orespawn.biome.replaced_by", entry.replacementTarget)); + return lines; + } + + private static List placementTooltip(Placement rule) { + List lines = new ArrayList<>(); + lines.add(rule.paletteId); + lines.add(I18n.get("label.orespawn.biome.palette_owner_order", rule.owner, rule.order + 1)); + if (rule.hasSurface) lines.add(I18n.get(rule.effectiveSurface + ? "label.orespawn.biome.surface_effective" : "label.orespawn.biome.surface_overridden")); + return lines; + } + + private void captureScroll() { + if (biomeList != null) biomeScroll = biomeList.firstIndex(); + if (ruleList != null) ruleScroll = ruleList.firstIndex(); + } + + private void rebuild() { captureScroll(); clearWidgets(); init(); } + + @Override public void onClose() { minecraft.setScreen(parent); } + @Override protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { graphics.drawCenteredString(font, title, width / 2, 14, OreSpawnScreenLayout.TEXT_PRIMARY); + 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); + graphics.drawString(font, Component.literal(selected.name), x + 3, y, OreSpawnScreenLayout.TEXT_PRIMARY); + graphics.drawString(font, Component.literal(selected.id), x + 3, y + 11, OreSpawnScreenLayout.TEXT_SOFT); + graphics.drawString(font, Component.translatable("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++) { + graphics.drawString(font, Component.literal(detailMessageLines.get(line)), + detailX + 3, detailMessageTop + (line * 10), OreSpawnScreenLayout.TEXT_ERROR); + } + graphics.drawString(font, Component.translatable("label.orespawn.biome.placement_rules"), + detailX + 3, detailRuleTop - 10, OreSpawnScreenLayout.TEXT_SECONDARY); + } + } + + static int contentWidth(int width) { return Math.min(520, Math.max(300, width - 20)); } + static int leftPaneWidth(int width) { return Math.max(132, (contentWidth(width) * 43) / 100); } + static int listHeight(int height) { return Math.max(32, materialsButtonY(height) - CONTENT_TOP - 4); } + static int materialsButtonY(int height) { return height - 52; } + static int detailTop(boolean compact) { return compact ? 34 : 24; } + static int detailRuleTop(boolean compact, boolean hasReplacement) { + return detailTop(compact) + (hasReplacement ? 95 : 71); + } + + static int detailMessageTop(boolean compact, boolean hasReplacement) { + return detailRuleTop(compact, hasReplacement) - 12; } - private static String string(JsonObject root, String key, String fallback) { - return root.has(key) ? root.get(key).getAsString() : fallback; + static int detailRuleTop(boolean compact, boolean hasReplacement, int messageLines) { + return detailRuleTop(compact, hasReplacement) + (Math.max(0, Math.min(2, messageLines)) * 10); } - private static boolean bool(JsonObject root, String key, boolean fallback) { - return root.has(key) ? root.get(key).getAsBoolean() : fallback; + static int controlsTop(int height, int ruleTop) { + return Math.max(ruleTop + 34, height - 76); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/Button.java b/src/main/java/zone/moddev/mc/orespawn/client/Button.java new file mode 100644 index 00000000..7b1e57af --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/Button.java @@ -0,0 +1,63 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.Font; +import net.minecraft.network.chat.Component; + +/** Keeps the compact editor controls on Minecraft's native drawing API. */ +class Button extends net.minecraft.client.gui.components.Button { + private final Tooltip tooltip; + private GuiGraphics currentGraphics; + + Button(int x, int y, int width, int height, String message, OnPress onPress) { + this(x, y, width, height, Component.literal(message), onPress, null); + } + + Button(int x, int y, int width, int height, Component message, OnPress onPress) { + this(x, y, width, height, message, onPress, null); + } + + Button(int x, int y, int width, int height, String message, OnPress onPress, + Tooltip tooltip) { + this(x, y, width, height, Component.literal(message), onPress, tooltip); + } + + Button(int x, int y, int width, int height, Component message, OnPress onPress, + Tooltip tooltip) { + super(x, y, width, height, message, onPress, + net.minecraft.client.gui.components.Button.DEFAULT_NARRATION); + this.tooltip = tooltip; + } + + @Override + public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + currentGraphics = graphics; + try { + render(mouseX, mouseY, partialTick); + } finally { + currentGraphics = null; + } + } + + public void render(int mouseX, int mouseY, float partialTick) { + super.renderWidget(currentGraphics, mouseX, mouseY, partialTick); + if (tooltip != null && isHoveredOrFocused()) tooltip.render(this, mouseX, mouseY); + } + + protected final GuiGraphics currentGraphics() { + return currentGraphics; + } + + protected final void fill(int x1, int y1, int x2, int y2, int color) { + currentGraphics.fill(x1, y1, x2, y2, color); + } + + protected final void drawCenteredString(Font font, String text, + int x, int y, int color) { + currentGraphics.drawCenteredString(font, text, x, y, color); + } + + interface Tooltip { + void render(Button button, int mouseX, int mouseY); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java new file mode 100644 index 00000000..90df3450 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java @@ -0,0 +1,27 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.gui.GuiGraphics; + +/** Compact settings button used in the Mods directory. */ +final class CogButton extends Button { + CogButton(int x, int y, OnPress onPress, Tooltip tooltip) { + super(x, y, 22, 20, "", onPress, tooltip); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + super.render(mouseX, mouseY, partialTick); + if (!visible) return; + drawCog(currentGraphics(), getX() + 11, getY() + 10, active); + } + + static void drawCog(GuiGraphics graphics, int centerX, int centerY, boolean active) { + int color = active ? 0xFFE0E0E0 : 0xFF777777; + graphics.fill(centerX - 4, centerY - 4, centerX + 5, centerY + 5, color); + graphics.fill(centerX - 1, centerY - 6, centerX + 2, centerY - 4, color); + graphics.fill(centerX - 1, centerY + 5, centerX + 2, centerY + 7, color); + graphics.fill(centerX - 6, centerY - 1, centerX - 4, centerY + 2, color); + graphics.fill(centerX + 5, centerY - 1, centerX + 7, centerY + 2, color); + graphics.fill(centerX - 2, centerY - 2, centerX + 3, centerY + 3, 0xFF303030); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java new file mode 100644 index 00000000..d569d7e4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java @@ -0,0 +1,298 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.Collections; +import java.util.List; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; + +/** + * Compact text list with independent row and scrollbar input. + * It deliberately avoids GuiSlot, whose legacy coordinate handling assumes a + * full-width screen and is unsuitable for two adjacent panes. + */ +abstract class CompactScrollList extends Button { + static final int DEFAULT_ROW_HEIGHT = 16; + private static final int SCROLLBAR_WIDTH = 10; + private static final int ARROW_HEIGHT = 9; + private static final int MIN_THUMB_HEIGHT = 8; + + private final OreSpawnScreen owner; + private final int x; + private final int y; + private final int rowHeight; + private int firstIndex; + private boolean dragging; + private int dragOffset; + private int hoveredIndex = -1; + private boolean hoveredAction; + private boolean pointerInside; + + CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height) { + this(owner, x, y, width, height, DEFAULT_ROW_HEIGHT); + } + + CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height, int rowHeight) { + super(x, y, width, height, "", null); + this.owner = owner; + this.x = x; + this.y = y; + this.rowHeight = Math.max(12, rowHeight); + } + + protected abstract int size(); + + protected abstract String rowText(int index); + + protected void onRowPressed(int index) { } + + protected boolean rowEnabled(int index) { + return true; + } + + protected boolean rowSelected(int index) { + return false; + } + + protected int rowColor(int index) { + return rowEnabled(index) ? 0xFFFFFF : 0x777777; + } + + protected List rowTooltip(int index) { + return Collections.singletonList(rowText(index)); + } + + protected int rowActionWidth(int index) { + return 0; + } + + protected void drawRowAction(Minecraft minecraft, int index, int x, int y, + int width, int height, boolean hovered) { } + + protected void onRowActionPressed(int index) { } + + protected List rowActionTooltip(int index) { + return rowTooltip(index); + } + + @Override + public void render(int mouseX, int mouseY, float partialTicks) { + if (!visible) return; + Minecraft minecraft = Minecraft.getInstance(); + clampFirstIndex(); + pointerInside = contains(mouseX, mouseY); + + fill(x, y, x + width, y + height, 0x76000000); + hoveredIndex = rowAt(mouseX, mouseY); + hoveredAction = hoveredIndex >= 0 && isInRowAction(hoveredIndex, mouseX); + Font font = minecraft.font; + int rows = visibleRows(); + int contentRight = contentRight(); + for (int row = 0; row < rows; row++) { + int index = firstIndex + row; + if (index >= size()) break; + int rowY = y + (row * rowHeight); + boolean hovered = index == hoveredIndex; + if (rowSelected(index)) { + fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0xA052648C); + } else if (hovered) { + fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0x80404040); + } + int actionWidth = Math.max(0, rowActionWidth(index)); + int textWidth = Math.max(0, contentRight - x - actionWidth - 8); + String text = fit(font, rowText(index), textWidth); + currentGraphics().drawString(font, text, x + 4, + rowY + Math.max(2, (rowHeight - 8) / 2), rowColor(index) | 0xFF000000); + if (actionWidth > 0) { + int actionX = contentRight - actionWidth; + drawRowAction(minecraft, index, actionX, rowY, + actionWidth, rowHeight - 1, hovered && mouseX >= actionX); + } + } + drawScrollbar(minecraft, mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button != 0 || !visible || !active || !contains((int) mouseX, (int) mouseY)) return false; + int x = (int) mouseX; + int y = (int) mouseY; + if (x >= contentRight()) { + pressScrollbar(y); + return true; + } + int index = rowAt(x, y); + if (index < 0) return true; + if (isInRowAction(index, x)) onRowActionPressed(index); + else if (rowEnabled(index)) onRowPressed(index); + return true; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + dragging = false; + return button == 0 && contains((int) mouseX, (int) mouseY); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, + double deltaX, double deltaY) { + if (!dragging || button != 0) return false; + dragTo((int) mouseY - dragOffset); + return true; + } + + void renderTooltip(int mouseX, int mouseY) { + if (!visible || hoveredIndex < 0 || !contains(mouseX, mouseY)) return; + List lines = hoveredAction ? rowActionTooltip(hoveredIndex) + : rowTooltip(hoveredIndex); + if (lines != null && !lines.isEmpty()) owner.renderStringTooltip(lines, mouseX, mouseY); + } + + boolean mouseWheel(int mouseX, int mouseY, int delta) { + if (!visible || delta == 0 || !contains(mouseX, mouseY)) return false; + scrollBy(delta > 0 ? -3 : 3); + return true; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double deltaX, double deltaY) { + if (!visible || !active || !contains((int) mouseX, (int) mouseY) || deltaY == 0.0D) return false; + scrollBy(deltaY > 0.0D ? -3 : 3); + return true; + } + + void scrollBy(int rows) { + setFirstIndex(firstIndex + rows); + } + + int firstIndex() { + return firstIndex; + } + + void setFirstIndex(int index) { + firstIndex = Math.max(0, Math.min(index, maxFirst(size(), visibleRows()))); + } + + void ensureVisible(int index) { + if (index < firstIndex) setFirstIndex(index); + else if (index >= firstIndex + visibleRows()) setFirstIndex(index - visibleRows() + 1); + } + + int visibleRows() { + return visibleRows(height, rowHeight); + } + + int rowY(int index) { + return index < firstIndex || index >= firstIndex + visibleRows() + ? -1 : y + ((index - firstIndex) * rowHeight); + } + + int contentRight() { + return x + width - SCROLLBAR_WIDTH; + } + + private boolean contains(int mouseX, int mouseY) { + return mouseX >= x && mouseX < x + width + && mouseY >= y && mouseY < y + height; + } + + private int rowAt(int mouseX, int mouseY) { + if (!contains(mouseX, mouseY) || mouseX >= contentRight()) return -1; + int index = firstIndex + ((mouseY - y) / rowHeight); + return index >= 0 && index < size() ? index : -1; + } + + private boolean isInRowAction(int index, int mouseX) { + int actionWidth = Math.max(0, rowActionWidth(index)); + return actionWidth > 0 && mouseX >= contentRight() - actionWidth; + } + + private void pressScrollbar(int mouseY) { + if (mouseY < y + ARROW_HEIGHT) { + scrollBy(-1); + return; + } + if (mouseY >= y + height - ARROW_HEIGHT) { + scrollBy(1); + return; + } + int thumbY = thumbY(); + int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight()); + if (mouseY >= thumbY && mouseY < thumbY + thumbHeight) { + dragging = true; + dragOffset = mouseY - thumbY; + } else { + scrollBy(mouseY < thumbY ? -visibleRows() : visibleRows()); + } + } + + private void dragTo(int thumbTop) { + int maximum = maxFirst(size(), visibleRows()); + int track = trackHeight(); + int thumb = thumbHeight(size(), visibleRows(), track); + int travel = Math.max(1, track - thumb); + int relative = Math.max(0, Math.min(travel, thumbTop - (y + ARROW_HEIGHT))); + setFirstIndex(firstFromThumb(relative, travel, maximum)); + } + + private int trackHeight() { + return Math.max(1, height - (ARROW_HEIGHT * 2)); + } + + private int thumbY() { + int maximum = maxFirst(size(), visibleRows()); + int track = trackHeight(); + int thumb = thumbHeight(size(), visibleRows(), track); + int travel = Math.max(0, track - thumb); + return y + ARROW_HEIGHT + (maximum == 0 ? 0 + : Math.round((float) firstIndex * travel / maximum)); + } + + private void drawScrollbar(Minecraft minecraft, int mouseX, int mouseY) { + int left = contentRight(); + int right = x + width; + fill(left, y, right, y + height, 0xB0202020); + Font font = minecraft.font; + drawCenteredString(font, "^", left + (SCROLLBAR_WIDTH / 2), y, 0xDDDDDD); + drawCenteredString(font, "v", left + (SCROLLBAR_WIDTH / 2), + y + height - ARROW_HEIGHT, 0xDDDDDD); + int thumbY = thumbY(); + int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight()); + fill(left + 1, thumbY, right - 1, thumbY + thumbHeight, + mouseX >= left && mouseX < right && mouseY >= thumbY + && mouseY < thumbY + thumbHeight ? 0xFFE0E0E0 : 0xFF909090); + } + + private void clampFirstIndex() { + setFirstIndex(firstIndex); + } + + private static String fit(Font font, String text, int width) { + if (width <= 0) return ""; + if (font.width(text) <= width) return text; + String suffix = "..."; + return font.plainSubstrByWidth(text, + Math.max(0, width - font.width(suffix))) + suffix; + } + + static int visibleRows(int height, int rowHeight) { + return Math.max(1, Math.max(1, height) / Math.max(1, rowHeight)); + } + + static int maxFirst(int entries, int visibleRows) { + return Math.max(0, Math.max(0, entries) - Math.max(1, visibleRows)); + } + + static int thumbHeight(int entries, int visibleRows, int trackHeight) { + if (entries <= visibleRows) return Math.max(1, trackHeight); + return Math.max(MIN_THUMB_HEIGHT, + Math.min(trackHeight, (trackHeight * Math.max(1, visibleRows)) / Math.max(1, entries))); + } + + static int firstFromThumb(int relative, int travel, int maximum) { + if (travel <= 0 || maximum <= 0) return 0; + return Math.max(0, Math.min(maximum, + Math.round((float) relative * maximum / travel))); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index eeb2d7dc..8e3ca078 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -3,7 +3,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -15,6 +17,8 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; +import com.google.gson.JsonPrimitive; import zone.moddev.mc.orespawn.worldgen.OreHeightDistribution; import zone.moddev.mc.orespawn.worldgen.OrePattern; import zone.moddev.mc.orespawn.worldgen.GeomeConfig; @@ -22,16 +26,21 @@ import zone.moddev.mc.orespawn.init.OreSpawnPatterns; import zone.moddev.mc.orespawn.api.OreDimensionSelector; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; import net.minecraft.resources.ResourceLocation; import net.minecraft.core.BlockPos; +import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.EmptyBlockGetter; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.EntityBlock; import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.item.Items; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Mutable client-side copy used until the Create World settings are accepted. */ final class GeologyEditorSession { @@ -56,6 +65,9 @@ enum MaterialTab { private final WorldGeologyProfile originalProfile; private final JsonObject original; private final JsonObject root; + private JsonObject oreSourceOriginal; + private final BiomeProviderDefaultsSnapshot biomeProviderDefaults; + private final Registry loadedBiomes; private final Set availableDimensionIds = new TreeSet<>(); GeologyEditorSession(WorldGeologyProfile profile) { @@ -63,11 +75,30 @@ enum MaterialTab { } GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions) { + this(profile, dimensions, WorldgenIntegrationManager.biomeProviderDefaults(), null); + } + + GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions, + Registry loadedBiomes) { + this(profile, dimensions, WorldgenIntegrationManager.biomeProviderDefaults(), loadedBiomes); + } + + GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions, + BiomeProviderDefaultsSnapshot defaults) { + this(profile, dimensions, defaults, null); + } + + GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions, + BiomeProviderDefaultsSnapshot defaults, Registry loadedBiomes) { originalProfile = profile; + biomeProviderDefaults = defaults; + this.loadedBiomes = loadedBiomes; original = profile.rootCopy(); root = profile.rootCopy(); normalizeRegistrySections(original); normalizeRegistrySections(root); + oreSourceOriginal = profile.rootCopy(); + normalizeRegistrySections(oreSourceOriginal); rememberDimension("minecraft:overworld"); rememberDimension("minecraft:the_nether"); rememberDimension("minecraft:the_end"); @@ -90,6 +121,44 @@ void applyProfile(WorldGeologyProfile profile) { normalizeRegistrySections(root); } + void setManageVanillaOres(boolean enabled) { + WorldGeologyProfile current = profile(); + JsonObject refreshed = current.rootCopy(); + refreshed.addProperty("manage_vanilla_ores", enabled); + applyProfile(WorldGeologyProfile.fromGlobalConfig(refreshed, + current.geologyMode(), current.placeFluidDeposits())); + synchronizeVanillaPlacementSources(enabled); + } + + private void synchronizeVanillaPlacementSources(boolean enabled) { + JsonObject ores = section("ores"); + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + JsonObject placements = objectEntry(policy, "placement_sources"); + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + if (!ores.has(sourceId) || !ores.get(sourceId).isJsonObject() + || !bool(ores.getAsJsonObject(sourceId), "native_generation", false)) continue; + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (enabled && bool(candidate, "loaded", false) + && bool(candidate, "placement_active", false) + && !bool(candidate, "external", false) + && !bool(candidate, "enrichment", false) + && !placements.has(channel)) { + placements.addProperty(channel, sourceId); + } else if (!enabled && placements.has(channel) + && sourceId.equals(string(placements, channel, ""))) { + placements.remove(channel); + } + } + refreshOreSourceStatus(policy); + } + } + JsonObject section(String key) { return object(root, key); } @@ -120,6 +189,13 @@ List availableDimensionIds() { if (validResource(id)) result.add(new ResourceLocation(id).toString()); } } + for (String sectionName : Arrays.asList("biome_palettes", "dimension_materials")) { + for (Entry entry : section(sectionName).entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + String id = string(entry.getValue().getAsJsonObject(), "dimension", ""); + if (validResource(id)) result.add(new ResourceLocation(id).toString()); + } + } List ordered = new ArrayList<>(); for (String vanilla : Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")) { ordered.add(vanilla); @@ -344,6 +420,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 = OreSourceEditorView.policy(root, entry.getValue().getAsJsonObject()); + String material = string(policy, "material", ""); + String domain = string(policy, "domain", ""); + if (!validResource(material) || !validResource(domain)) continue; + JsonObject groupDefinitions = section("ore_material_groups"); + JsonObject groupDefinition = groupDefinitions.has(material) + && groupDefinitions.get(material).isJsonObject() + ? groupDefinitions.getAsJsonObject(material) : new JsonObject(); + String displayName = string(groupDefinition, "display_name", displayName(material)); + List groupDictionary = stringList(groupDefinition, "block_tag_entries"); + boolean curated = "orespawn:sulfur".equals(material) || "orespawn:aluminum".equals(material); + List candidates = new ArrayList<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String registryId = string(candidate, "registry_id", ""); + if (sourceId.isEmpty() || !validResource(channel) || !validResource(registryId)) continue; + List dictionary = new ArrayList<>(); + if (candidate.has("block_tags") && candidate.get("block_tags").isJsonArray()) { + for (JsonElement name : candidate.getAsJsonArray("block_tags")) { + try { dictionary.add(name.getAsString()); } + catch (RuntimeException ignored) { } + } + } + candidates.add(new OreSourceCandidate(sourceId, + string(candidate, "owner", ""), string(candidate, "owner_name", ""), + string(candidate, "owner_version", ""), registryId, + integer(candidate, "metadata", 0), channel, dictionary, + bool(candidate, "loaded", false), bool(candidate, "placement_active", true), + bool(candidate, "external", false), + bool(candidate, "enrichment", false), + bool(candidate, "review_required", false))); + } + } + Collections.sort(candidates, (left, right) -> { + int owner = left.owner.compareTo(right.owner); + return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId); + }); + JsonObject viewPolicy = JsonCopies.copy(policy); + refreshOreSourceStatus(viewPolicy); + OreSourceGroup group = new OreSourceGroup(entry.getKey(), material, domain, + displayName, groupDictionary, curated, + string(policy, "mode", "keep_separate"), outputMode(policy), + string(viewPolicy, "status", "review_required"), + candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")); + if (!bool(policy, "dormant", false)) result.add(group); + } + Collections.sort(result, (left, right) -> { + int name = left.displayName.compareToIgnoreCase(right.displayName); + if (name != 0) return name; + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return Collections.unmodifiableList(result); + } + + String addOreMaterialGroup() { + JsonObject groups = section("ore_material_groups"); + JsonObject policies = section("ore_source_policies"); + String base = "orespawn:custom/custom_group"; + String id = base; + for (int suffix = 2; groups.has(id) + || policies.has(id + "|minecraft:overworld"); suffix++) id = base + '_' + suffix; + JsonObject definition = new JsonObject(); + definition.addProperty("display_name", "Custom Group"); + definition.add("block_tag_entries", new JsonArray()); + groups.add(id, definition); + String key = id + "|minecraft:overworld"; + JsonObject policy = objectEntry(policies, key); + policy.remove("dormant"); + policy.addProperty("material", id); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + policy.addProperty("status", "separate"); + policy.addProperty("review_required", false); + policy.add("outputs", new JsonObject()); + policy.add("placement_sources", new JsonObject()); + policy.add("candidates", new JsonArray()); + return key; + } + + void renameOreMaterialGroup(String material, String displayName) { + if (!validResource(material)) return; + String value = displayName == null ? "" : displayName.trim(); + if (value.isEmpty()) return; + objectEntry(section("ore_material_groups"), material) + .addProperty("display_name", value.length() > 64 ? value.substring(0, 64) : value); + } + + String blockTagOwner(String alias) { + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + if (stringList(entry.getValue().getAsJsonObject(), "block_tag_entries").contains(alias)) { + return entry.getKey(); + } + } + return null; + } + + boolean vanillaOreManagementRequiredForAliasMove(String material, String alias) { + if (bool(root, "manage_vanilla_ores", false)) return false; + String owner = blockTagOwner(alias); + if (owner == null || owner.equals(material)) return false; + JsonObject ores = section("ores"); + for (OreSourceGroup group : oreSourceGroups()) { + if (!owner.equals(group.material)) continue; + for (OreSourceCandidate candidate : group.candidates) { + if (!candidate.loaded || candidate.external || !candidate.blockTag.contains(alias) + || !ores.has(candidate.sourceId) || !ores.get(candidate.sourceId).isJsonObject()) continue; + if (bool(ores.getAsJsonObject(candidate.sourceId), "native_generation", false)) return true; + } + } + return false; + } + + boolean addOreMaterialAlias(String material, String alias, boolean moveExisting) { + if (!validResource(material) || !validBlockTagName(alias)) return false; + String owner = blockTagOwner(alias); + if (owner != null && !owner.equals(material)) { + if (!moveExisting) return false; + if (vanillaOreManagementRequiredForAliasMove(material, alias)) return false; + removeOreMaterialAlias(owner, alias); + } + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (!names.contains(alias)) names.add(alias); + Collections.sort(names); + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + return true; + } + + void removeOreMaterialAlias(String material, String alias) { + if (!validResource(material)) return; + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (names.remove(alias)) { + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + } + } + + void resetOreMaterialGroup(String material) { + if ("orespawn:sulfur".equals(material)) { + setOreMaterialGroup(material, "Sulfur", + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")); + } else if ("orespawn:aluminum".equals(material)) { + setOreMaterialGroup(material, "Aluminum", + Arrays.asList("forge:ores/aluminum", "forge:ores/aluminium")); + } + rebuildOreSourcePolicies(); + } + + boolean deleteEmptyOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject() + || !stringList(groups.getAsJsonObject(material), "block_tag_entries").isEmpty()) { + return false; + } + groups.remove(material); + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + boolean dissolveOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject()) return false; + List aliases = stringList(groups.getAsJsonObject(material), "block_tag_entries"); + if (aliases.isEmpty()) return false; + groups.remove(material); + for (String alias : aliases) { + String restoredMaterial = inferredMaterialForAlias(alias); + JsonObject definition = objectEntry(groups, restoredMaterial); + if (!definition.has("display_name")) { + definition.addProperty("display_name", displayName(restoredMaterial)); + } + List restoredAliases = stringList(definition, "block_tag_entries"); + if (!restoredAliases.contains(alias)) restoredAliases.add(alias); + Collections.sort(restoredAliases); + definition.add("block_tag_entries", strings(restoredAliases)); + } + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + private void markMaterialPoliciesDormant(String material) { + for (Entry entry : section("ore_source_policies").entrySet()) { + if (entry.getValue().isJsonObject() + && material.equals(string(entry.getValue().getAsJsonObject(), "material", ""))) { + entry.getValue().getAsJsonObject().addProperty("dormant", true); + } + } + } + + boolean oreMaterialGroupsChanged() { + return !sectionCopy(original, "ore_material_groups").equals(sectionCopy(root, "ore_material_groups")); + } + + JsonObject oreMaterialGroupsCopy() { + return sectionCopy(root, "ore_material_groups"); + } + + void resetOreSourcesToDefaults() { + WorldGeologyProfile current = profile(); + JsonObject reset = current.rootCopy(); + reset.remove("ore_material_groups"); + reset.remove("ore_source_policies"); + WorldGeologyProfile defaults = WorldGeologyProfile.fromGlobalConfig(reset, + current.geologyMode(), current.placeFluidDeposits()); + applyProfile(defaults); + oreSourceOriginal = defaults.rootCopy(); + normalizeRegistrySections(oreSourceOriginal); + } + + private void setOreMaterialGroup(String material, String displayName, List aliases) { + JsonObject definition = objectEntry(section("ore_material_groups"), material); + definition.addProperty("display_name", displayName); + definition.add("block_tag_entries", strings(aliases)); + } + + private void rebuildOreSourcePolicies() { + JsonObject policies = section("ore_source_policies"); + Map> previousMembers = new LinkedHashMap<>(); + Map candidates = new LinkedHashMap<>(); + for (Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + Set members = new LinkedHashSet<>(); + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue; + boolean hasCandidates = policy.getAsJsonArray("candidates").size() > 0; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = JsonCopies.copy(element.getAsJsonObject()); + String identity = candidateIdentity(candidate, string(policy, "domain", "")); + members.add(identity); + candidates.put(identity, candidate); + } + previousMembers.put(entry.getKey(), members); + String material = string(policy, "material", ""); + if (!hasCandidates && section("ore_material_groups").has(material)) { + policy.remove("dormant"); + } else { + policy.addProperty("dormant", true); + } + } + Map> regrouped = new LinkedHashMap<>(); + for (JsonObject candidate : candidates.values()) { + String material = string(candidate, "material", ""); + if (!bool(candidate, "material_declared", false)) { + material = materialForAliases(stringList(candidate, "block_tags")); + if (!material.isEmpty()) candidate.addProperty("material", material); + } + String domain = string(candidate, "domain", "minecraft:overworld"); + if (!validResource(material) || !validResource(domain)) continue; + regrouped.computeIfAbsent(material + '|' + domain, ignored -> new ArrayList<>()).add(candidate); + } + for (Entry> entry : regrouped.entrySet()) { + int split = entry.getKey().indexOf('|'); + String material = entry.getKey().substring(0, split); + String domain = entry.getKey().substring(split + 1); + JsonObject policy = policies.has(entry.getKey()) && policies.get(entry.getKey()).isJsonObject() + ? policies.getAsJsonObject(entry.getKey()) : new JsonObject(); + policy.remove("dormant"); + policy.addProperty("material", material); + policy.addProperty("domain", domain); + if (!policy.has("mode")) policy.addProperty("mode", "keep_separate"); + if (!policy.has("output_mode")) policy.addProperty("output_mode", "custom"); + JsonArray array = new JsonArray(); + Set currentMembers = new LinkedHashSet<>(); + for (JsonObject candidate : entry.getValue()) { + array.add(candidate); + currentMembers.add(candidateIdentity(candidate, domain)); + } + policy.add("candidates", array); + reconcileOreSourceOutputs(policy); + reconcileOreSourcePlacements(policy); + Set previous = previousMembers.get(entry.getKey()); + boolean membershipChanged = previous == null || !previous.equals(currentMembers); + if (membershipChanged) { + policy.addProperty("review_required", outputCandidateJson(policy).size() > 1); + } + refreshOreSourceStatus(policy); + policies.add(entry.getKey(), policy); + } + } + + private static void reconcileOreSourceOutputs(JsonObject policy) { + List candidates = outputCandidateJson(policy); + Set candidateIds = allOutputCandidateIds(policy); + JsonObject outputs = objectEntry(policy, "outputs"); + for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) { + if (!candidateIds.contains(sourceId)) outputs.remove(sourceId); + } + String mode = string(policy, "mode", "keep_separate"); + String outputMode = outputMode(policy); + if ("keep_separate".equals(mode) || "balanced".equals(outputMode)) { + for (JsonObject candidate : candidates) { + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty() && bool(candidate, "loaded", false)) { + outputs.addProperty(sourceId, 1.0D); + } + } + } else if ("single".equals(outputMode)) { + String selected = ""; + for (String sourceId : JsonCopies.keys(outputs)) { + if (candidateIds.contains(sourceId)) { + selected = sourceId; + break; + } + } + if (selected.isEmpty()) { + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + } + + private static void reconcileOreSourcePlacements(JsonObject policy) { + Map> retainedChoices = new LinkedHashMap<>(); + Map> loadedChoices = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "external", false) || bool(candidate, "enrichment", false)) continue; + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) { + retainedChoices.computeIfAbsent(channel, ignored -> new LinkedHashSet<>()).add(sourceId); + } + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false)) { + loadedChoices.computeIfAbsent(channel, ignored -> new ArrayList<>()).add(candidate); + } + } + } + JsonObject placements = objectEntry(policy, "placement_sources"); + for (String channel : new ArrayList<>(JsonCopies.keys(placements))) { + String selected = string(placements, channel, ""); + if (!retainedChoices.getOrDefault(channel, Collections.emptySet()).contains(selected)) { + placements.remove(channel); + } + } + for (Entry> entry : loadedChoices.entrySet()) { + if (!placements.has(entry.getKey()) && !entry.getValue().isEmpty()) { + placements.addProperty(entry.getKey(), string(entry.getValue().get(0), "source_id", "")); + } + } + } + + private static Set allOutputCandidateIds(JsonObject policy) { + Set result = new LinkedHashSet<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) result.add(sourceId); + } + } + return result; + } + + private static String candidateIdentity(JsonObject candidate, String fallbackDomain) { + return string(candidate, "source_id", "") + '|' + + string(candidate, "placement_channel", "orespawn:standard") + '|' + + string(candidate, "domain", fallbackDomain); + } + + private String materialForAliases(List aliases) { + Set materials = new LinkedHashSet<>(); + for (String alias : aliases) { + String owner = blockTagOwner(alias); + if (owner != null) materials.add(owner); + else if (validBlockTagName(alias)) { + materials.add("orespawn:" + oreTagMaterial(alias)); + } + } + return materials.size() == 1 ? materials.iterator().next() + : materials.size() > 1 ? provisionalMaterialForAliases(aliases) : ""; + } + + private static String provisionalMaterialForAliases(List aliases) { + Set unique = new LinkedHashSet<>(); + for (String alias : aliases) if (validBlockTagName(alias)) unique.add(alias); + List exact = new ArrayList<>(unique); + Collections.sort(exact); + String token = exact.isEmpty() ? "unknown" : oreTagMaterial(exact.get(0)); + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return "orespawn:review/" + token + '-' + padded.append(value).toString(); + } + + private static boolean isCustomMaterial(String material) { + return material != null && material.startsWith("orespawn:custom/") && validResource(material); + } + + private static String inferredMaterialForAlias(String alias) { + if ("forge:ores/sulfur".equals(alias) || "forge:ores/sulphur".equals(alias)) return "orespawn:sulfur"; + if ("forge:ores/aluminum".equals(alias) || "forge:ores/aluminium".equals(alias)) return "orespawn:aluminum"; + return "orespawn:" + oreTagMaterial(alias); + } + + void setOreSourceMode(String key, boolean consolidated) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate"); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void acceptOreSourcePolicy(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void restoreOreSourceOriginalMode(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject originalPolicies = object(oreSourceOriginal, "ore_source_policies"); + JsonObject originalPolicy = originalPolicies.has(key) && originalPolicies.get(key).isJsonObject() + ? originalPolicies.getAsJsonObject(key) : null; + policy.addProperty("mode", "keep_separate"); + if (originalPolicy != null) { + policy.addProperty("output_mode", outputMode(originalPolicy)); + policy.add("outputs", sectionCopy(originalPolicy, "outputs")); + policy.add("placement_sources", sectionCopy(originalPolicy, "placement_sources")); + } else { + JsonObject outputs = new JsonObject(); + JsonObject placements = new JsonObject(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) + || !bool(candidate, "placement_active", false) + || bool(candidate, "external", false) + || bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + if (!sourceId.isEmpty() && !placements.has(channel)) { + placements.addProperty(channel, sourceId); + } + } + } + policy.addProperty("output_mode", "custom"); + policy.add("outputs", outputs); + policy.add("placement_sources", placements); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutputMode(String key, String outputMode) { + if (!"balanced".equals(outputMode) && !"single".equals(outputMode) + && !"custom".equals(outputMode)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + if (!hasManagedPlacementSource(policy)) return; + policy.addProperty("mode", "consolidated"); + policy.addProperty("output_mode", outputMode); + JsonObject outputs = objectEntry(policy, "outputs"); + if ("balanced".equals(outputMode)) { + outputs.entrySet().clear(); + for (JsonObject candidate : outputCandidateJson(policy)) { + if (bool(candidate, "external", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + } + } else if ("single".equals(outputMode)) { + String selected = preferredSingleOutput(policy, outputs); + if (!loadedOutput(policy, selected)) { + List candidates = outputCandidateJson(policy); + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + private static String preferredSingleOutput(JsonObject policy, JsonObject outputs) { + JsonObject placements = objectEntry(policy, "placement_sources"); + for (Entry entry : placements.entrySet()) { + try { + String sourceId = entry.getValue().getAsString(); + if (outputs.has(sourceId) && loadedOutput(policy, sourceId)) return sourceId; + } catch (RuntimeException ignored) { } + } + return outputs.entrySet().isEmpty() ? "" : outputs.entrySet().iterator().next().getKey(); + } + + void setOreSourcePlacement(String key, String channel, String sourceId) { + if (!validResource(channel)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + objectEntry(policy, "placement_sources").addProperty(channel, sourceId); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutput(String key, String sourceId, boolean enabled, double weight) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject outputs = objectEntry(policy, "outputs"); + String outputMode = outputMode(policy); + if (!enabled) outputs.remove(sourceId); + else if ("single".equals(outputMode)) { + outputs.entrySet().clear(); + outputs.addProperty(sourceId, 1.0D); + } else outputs.addProperty(sourceId, "balanced".equals(outputMode) ? 1.0D + : Math.max(0.001D, Math.min(1000000.0D, weight))); + refreshOreSourceStatus(policy); + } + + private static List outputCandidateJson(JsonObject policy) { + Map unique = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) || bool(candidate, "enrichment", false)) continue; + String identity = string(candidate, "registry_id", "") + '|' + + integer(candidate, "metadata", 0); + JsonObject previous = unique.get(identity); + if (previous == null || (!bool(previous, "placement_active", false) + && bool(candidate, "placement_active", false)) + || (bool(previous, "external", false) && !bool(candidate, "external", false))) { + unique.put(identity, candidate); + } + } + } + return new ArrayList<>(unique.values()); + } + + private static boolean loadedOutput(JsonObject policy, String sourceId) { + if (sourceId == null || sourceId.isEmpty()) return false; + for (JsonObject candidate : outputCandidateJson(policy)) { + if (sourceId.equals(string(candidate, "source_id", ""))) return true; + } + return false; + } + + private static boolean hasManagedPlacementSource(JsonObject policy) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false) + && !bool(candidate, "external", false) + && !bool(candidate, "enrichment", false)) return true; + } + return false; + } + + private static void refreshOreSourceStatus(JsonObject policy) { + Set loaded = new HashSet<>(); + Set loadedPlacements = 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)) { + loaded.add(sourceId); + loadedPlacements.add(sourceId + "|" + string(candidate, "placement_channel", "orespawn:standard")); + } + 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 (!loaded.contains(id)) { policy.addProperty("status", "missing_source"); return; } + } + for (Entry placement : placements.entrySet()) { + if (!loadedPlacements.contains(placement.getValue() + "|" + placement.getKey())) { + policy.addProperty("status", "missing_source"); return; + } + } + policy.addProperty("status", external ? "external_generation" : review ? "review_required" + : "consolidated".equals(string(policy, "mode", "keep_separate")) + ? "consolidated" : "separate"); + } + + private static Map decimalMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static Map stringMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { result.put(entry.getKey(), entry.getValue().getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + static final class OreSourceGroup { + final String key; + final String material; + final String domain; + final String displayName; + final List blockTagEntries; + final boolean curated; + final String mode; + final String outputMode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + OreSourceGroup(String key, String material, String domain, String displayName, + List blockTagEntries, boolean curated, String mode, String outputMode, String status, + List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.curated = curated; + this.mode = mode; + this.outputMode = outputMode; + this.status = status; + this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates)); + this.outputs = outputs; + this.placements = placements; + } + + List channels() { + Set result = new TreeSet<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.external && candidate.active) result.add(candidate.channel); + } + return Collections.unmodifiableList(new ArrayList<>(result)); + } + + List outputCandidates() { + Map unique = new LinkedHashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (candidate.enrichment) continue; + String identity = candidate.registryId + '|' + candidate.metadata; + OreSourceCandidate previous = unique.get(identity); + if (previous == null || (!previous.active && candidate.active) + || (previous.external && !candidate.external)) unique.put(identity, candidate); + } + return Collections.unmodifiableList(new ArrayList<>(unique.values())); + } + + boolean needsAttention() { + return needsReview() || "missing_source".equals(status) + || outputCandidates().isEmpty(); + } + + boolean needsReview() { + return "review_required".equals(status); + } + + boolean hasManagedPlacementSource() { + for (OreSourceCandidate candidate : candidates) { + if (candidate.loaded && candidate.active && !candidate.external + && !candidate.enrichment) return true; + } + return false; + } + + boolean isRoutineSingleSource() { + return !needsAttention() && blockTagEntries.size() == 1 + && outputCandidates().size() == 1; + } + + boolean isCustom() { + return isCustomMaterial(material); + } + + boolean canDeleteEmpty() { + return isCustom() && blockTagEntries.isEmpty(); + } + + boolean canDissolve() { + return isCustom() && !blockTagEntries.isEmpty(); + } + + boolean isRedundantSeparatePolicy() { + if (!"keep_separate".equals(mode) || candidates.isEmpty()) return false; + Set outputs = new HashSet<>(); + Map> ownersByChannel = new HashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.loaded || candidate.external || candidate.enrichment + || candidate.reviewRequired) return false; + outputs.add(candidate.registryId + '|' + candidate.metadata); + ownersByChannel.computeIfAbsent(candidate.channel, ignored -> new HashSet<>()) + .add(candidate.owner); + } + if (outputs.size() > 1) return false; + for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return false; + return true; + } + } + + static final class OreSourceCandidate { + final String sourceId; + final String owner; + final String ownerName; + final String ownerVersion; + final String registryId; + final int metadata; + final String channel; + final List blockTag; + final boolean loaded; + final boolean active; + final boolean external; + final boolean enrichment; + final boolean reviewRequired; + + OreSourceCandidate(String sourceId, String owner, String ownerName, String ownerVersion, + String registryId, int metadata, String channel, List blockTag, + boolean loaded, boolean active, boolean external, boolean enrichment, boolean reviewRequired) { + this.sourceId = sourceId; + this.owner = owner; + this.ownerName = ownerName; + this.ownerVersion = ownerVersion; + this.registryId = registryId; + this.metadata = metadata; + this.channel = channel; + this.blockTag = Collections.unmodifiableList(new ArrayList<>(blockTag)); + this.loaded = loaded; + this.active = active; + this.external = external; + this.enrichment = enrichment; + this.reviewRequired = reviewRequired; + } + } + void resetEntry(String section, String id) { JsonObject originalSection = object(original, section); if (originalSection.has(id)) { @@ -391,14 +1238,175 @@ void setPlaceFluidDeposits(boolean enabled) { List installedBiomeIds() { List result = new ArrayList<>(); - for (net.minecraft.world.level.biome.Biome biome : zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.values()) { - ResourceLocation id = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(biome); - if (id != null) result.add(id.toString()); + if (loadedBiomes != null) { + for (ResourceLocation id : loadedBiomes.keySet()) result.add(id.toString()); + } else { + for (ResourceLocation id : zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.keys()) + result.add(id.toString()); } Collections.sort(result); return result; } + BiomeDirectoryModel.Snapshot biomeDirectory() { + return BiomeDirectoryModel.snapshot(root, biomeProviderDefaults, + availableDimensionIds(), loadedBiomes); + } + + 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 boolean loadedBiome(String id) { + try { + ResourceLocation key = new ResourceLocation(id); + return loadedBiomes == null + ? zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.contains(key) + : loadedBiomes.containsKey(key); + } + catch (RuntimeException ignored) { return false; } + } + String biomePaletteId(String dimensionId) { for (Entry entry : section("biome_palettes").entrySet()) { if (entry.getValue().isJsonObject() && dimensionId.equals(string( @@ -614,11 +1622,11 @@ List geomeIds() { List configuredBiomeIds() { TreeSet ids = new TreeSet<>(section("biomes").keySet()); - for (net.minecraft.world.level.biome.Biome biome : zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.values()) { - ResourceLocation id = zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.id(biome); - if (id != null) { + if (loadedBiomes != null) { + for (ResourceLocation id : loadedBiomes.keySet()) ids.add(id.toString()); + } else { + for (ResourceLocation id : zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.keys()) ids.add(id.toString()); - } } return new ArrayList<>(ids); } @@ -635,7 +1643,7 @@ JsonObject weightMap(String section, String id) { } void addGeome(String id) { - String normalized = id.trim().toLowerCase(Locale.ROOT); + String normalized = id == null ? "" : id.trim(); if (!validGeomeId(normalized) || section("geomes").has(normalized)) { return; } @@ -720,8 +1728,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) { @@ -755,14 +1765,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() @@ -812,7 +1825,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")) { @@ -825,6 +1839,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()) { @@ -834,7 +1850,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")) { @@ -851,7 +1868,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 +1904,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 +1923,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()); } @@ -911,7 +1933,9 @@ List validate() { ? palette.getAsJsonObject("biomes") : new JsonObject(); if (biomes.size() == 0) errors.add("Enabled biome palette has no biomes: " + entry.getKey()); for (Entry biome : biomes.entrySet()) { - if (!knownBiome(biome.getKey()) + // A saved reference may belong to a missing mod. Keep it dormant until + // that mod returns; the picker still requires a currently loaded biome. + if (!validResource(biome.getKey()) || !biome.getValue().isJsonObject()) { errors.add("Invalid biome placement: " + biome.getKey()); continue; @@ -932,16 +1956,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()); } } @@ -949,6 +1976,62 @@ > decimal(placement, "max_downfall", 1.0D)) { return errors; } + private void validateOreMaterialGroups(List errors) { + Set aliases = new HashSet<>(); + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!validResource(entry.getKey()) || !entry.getValue().isJsonObject()) { + errors.add("Invalid ore material group: " + entry.getKey()); + continue; + } + JsonObject group = entry.getValue().getAsJsonObject(); + if (string(group, "display_name", "").trim().isEmpty()) { + errors.add("Ore material group has no display name: " + entry.getKey()); + } + for (String alias : stringList(group, "block_tag_entries")) { + if (!validBlockTagName(alias)) { + errors.add("Invalid block tag in " + entry.getKey() + ": " + alias); + } else if (!aliases.add(alias)) { + errors.add("Block tag belongs to more than one material group: " + alias); + } + } + } + } + + private void validateOreSourcePolicies(List errors) { + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) { + errors.add("Invalid ore source policy: " + entry.getKey()); + continue; + } + JsonObject policy = entry.getValue().getAsJsonObject(); + if (bool(policy, "dormant", false) + || !"consolidated".equals(string(policy, "mode", "keep_separate"))) continue; + JsonObject outputs = policy.has("outputs") && policy.get("outputs").isJsonObject() + ? policy.getAsJsonObject("outputs") : new JsonObject(); + JsonObject placements = policy.has("placement_sources") + && policy.get("placement_sources").isJsonObject() + ? policy.getAsJsonObject("placement_sources") : new JsonObject(); + String outputMode = outputMode(policy); + if (outputs.entrySet().isEmpty()) { + errors.add("Consolidated ore source policy has no selected outputs: " + entry.getKey()); + } + if (placements.entrySet().isEmpty()) { + errors.add("Consolidated ore source policy has no placement source: " + entry.getKey()); + } + if ("single".equals(outputMode) && outputs.entrySet().size() != 1) { + errors.add("Single-output ore source policy must select exactly one output: " + entry.getKey()); + } + for (Entry output : outputs.entrySet()) { + try { + double value = output.getValue().getAsDouble(); + if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException(); + } catch (RuntimeException invalid) { + errors.add("Ore source output weight must be positive: " + entry.getKey()); + } + } + } + } + private static void validateOrePattern(JsonObject rule) { if (!OreSpawnPatterns.isBuiltIn(rule)) { OreSpawnPatterns.decode(rule); @@ -1007,6 +2090,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()) { @@ -1018,6 +2106,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; } @@ -1026,6 +2118,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); @@ -1174,8 +2275,7 @@ private static boolean validFluidBlock(String id) { } private static boolean isFluidBlock(Block block) { - return block != null && block != Blocks.AIR - && (block instanceof LiquidBlock || !block.defaultBlockState().getFluidState().isEmpty()); + return FluidBlocks.isFluidBlock(block); } private static Iterable registeredBlocks() { @@ -1196,15 +2296,68 @@ private static ResourceLocation blockId(Block block) { return id != null ? id : BuiltInRegistries.BLOCK.getKey(block); } - private static boolean knownBiome(String id) { + private boolean knownBiome(String id) { if (!validResource(id)) return false; + if (loadedBiomes != null) return loadedBiomes.containsKey(new ResourceLocation(id)); // Biomes are dynamic in 1.20.6. Before the live registry is available, // retain syntactically valid provider IDs for server-side resolution. return zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.keys().isEmpty() || zone.moddev.mc.orespawn.worldgen.BiomeRegistryAccess.contains(new ResourceLocation(id)); } + private static boolean validBlockTagName(String name) { + if (name == null || name.length() > 128 || !validResource(name)) return false; + String path = new ResourceLocation(name).getPath(); + return path.startsWith("ores/") && path.length() > "ores/".length(); + } + + private static String oreTagMaterial(String tag) { + return new ResourceLocation(tag).getPath().substring("ores/".length()); + } + + private static JsonObject sectionCopy(JsonObject parent, String key) { + return parent.has(key) && parent.get(key).isJsonObject() + ? JsonCopies.copy(parent.getAsJsonObject(key)) : new JsonObject(); + } + + private static List stringList(JsonObject parent, String key) { + List result = new ArrayList<>(); + if (parent.has(key) && parent.get(key).isJsonArray()) { + for (JsonElement element : parent.getAsJsonArray(key)) { + try { + String value = element.getAsString(); + if (!result.contains(value)) result.add(value); + } catch (RuntimeException ignored) { } + } + } + return result; + } + + private static JsonArray strings(Iterable values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String displayName(String id) { + int split = id.indexOf(':'); + String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static String outputMode(JsonObject policy) { + String configured = string(policy, "output_mode", ""); + if ("balanced".equals(configured) || "single".equals(configured) + || "custom".equals(configured)) return configured; + Map outputs = decimalMap(policy, "outputs"); + if (outputs.size() <= 1) return "single"; + double first = outputs.values().iterator().next(); + for (double value : outputs.values()) if (Double.compare(first, value) != 0) return "custom"; + return "balanced"; + } + private static boolean validResource(String id) { + if (id == null || !id.matches("[a-z0-9_.-]+:[a-z0-9_./-]+")) return false; try { new ResourceLocation(id); return true; 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 bd77340d..4de8b4a9 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java @@ -102,9 +102,17 @@ protected void init() { previous.active = page > 0; next.active = page + 1 < pageCount; - int addWidth = Math.min(150, contentWidth - 105); + int addWidth = tab == MaterialTab.ORES ? Math.max(50, (contentWidth - 105) / 2) + : Math.min(150, contentWidth - 105); + int addX = contentLeft + contentWidth - addWidth; + Button sources = addRenderableWidget(OreSpawnScreenLayout.button(this, font, + addX - addWidth - 5, controlsY, addWidth, 20, + Component.translatable("button.orespawn.ore_sources"), + button -> minecraft.setScreen(new OreSourceListScreen(this, session)))); + sources.visible = tab == MaterialTab.ORES; + OreSpawnScreenLayout.explain(sources, "tooltip.orespawn.ore_sources"); Button add = addRenderableWidget(OreSpawnScreenLayout.button(this, font, - contentLeft + contentWidth - addWidth, controlsY, addWidth, 20, + addX, controlsY, addWidth, 20, Component.translatable("button.orespawn.add_block"), button -> openBlockPicker())); add.visible = tab != MaterialTab.UNASSIGNED; OreSpawnScreenLayout.explain(add, "tooltip.orespawn.material.add_block"); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceEditorView.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceEditorView.java new file mode 100644 index 00000000..0bb26129 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceEditorView.java @@ -0,0 +1,120 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; + +/** Presents old inferred channel names without rewriting the world's saved choices. */ +final class OreSourceEditorView { + private static final String STANDARD = "orespawn:standard"; + private static final Set OLD_CHANNELS = new HashSet<>(Arrays.asList( + "orespawn:default", "orespawn:vein", "orespawn:normal_cloud", + "orespawn:precision", "orespawn:clusters", "orespawn:underfluids")); + + private OreSourceEditorView() { } + + static JsonObject policy(JsonObject root, JsonObject saved) { + JsonObject view = JsonCopies.copy(saved); + JsonObject placements = object(view, "placement_sources"); + JsonArray candidates = view.has("candidates") && view.get("candidates").isJsonArray() + ? view.getAsJsonArray("candidates") : new JsonArray(); + String domain = text(view, "domain"); + List aliases = new ArrayList<>(); + + for (Map.Entry entry : placements.entrySet()) { + String channel = entry.getKey(); + if (!OLD_CHANNELS.contains(channel) || !entry.getValue().isJsonPrimitive()) continue; + + String source = entry.getValue().getAsString(); + if (inferredAlias(root, source, domain, channel) + && loadedCandidate(candidates, source, STANDARD) != null) { + aliases.add(channel); + } + } + + // More than one old pattern may have been selected deliberately. Keep + // those rows separate rather than choosing a winner on the player's behalf. + boolean combine = placements.has(STANDARD) || aliases.size() == 1; + if (combine) { + for (String alias : aliases) { + if (!placements.has(STANDARD)) placements.add(STANDARD, placements.get(alias)); + placements.remove(alias); + } + } + + JsonArray visible = new JsonArray(); + for (JsonElement element : candidates) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String source = text(candidate, "source_id"); + String channel = text(candidate, "placement_channel"); + JsonObject current = loadedCandidate(candidates, source, STANDARD); + + if (!combine && !aliases.isEmpty() && STANDARD.equals(channel) && current != null) { + boolean projected = false; + for (String alias : aliases) { + if (!inferredAlias(root, source, domain, alias)) continue; + + JsonObject retained = JsonCopies.copy(current); + retained.addProperty("placement_channel", alias); + visible.add(retained); + projected = true; + } + if (projected) continue; + } + + if (current != null && OLD_CHANNELS.contains(channel) + && inferredAlias(root, source, domain, channel)) { + continue; + } + visible.add(candidate); + } + + view.add("candidates", visible); + view.add("placement_sources", placements); + return view; + } + + private static boolean inferredAlias(JsonObject root, String source, String domain, String channel) { + JsonObject ore = object(object(root, "ores"), source); + JsonObject dimensions = object(ore, "dimensions"); + JsonObject rule = dimensions.has(domain) ? object(dimensions, domain) + : object(object(ore, "dimension_selectors"), domain); + if (rule.has("placement_channel")) return false; + + JsonElement pattern = rule.get("pattern"); + if (pattern == null || (!pattern.isJsonObject() && !pattern.isJsonPrimitive())) return false; + return channel.equals(pattern.isJsonObject() + ? text(pattern.getAsJsonObject(), "type") : pattern.getAsString()); + } + + private static JsonObject loadedCandidate(JsonArray candidates, String source, String channel) { + for (JsonElement element : candidates) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (source.equals(text(candidate, "source_id")) + && channel.equals(text(candidate, "placement_channel")) + && candidate.has("loaded") && candidate.get("loaded").getAsBoolean()) { + return candidate; + } + } + return null; + } + + private static JsonObject object(JsonObject parent, String key) { + return parent.has(key) && parent.get(key).isJsonObject() + ? parent.getAsJsonObject(key) : new JsonObject(); + } + + private static String text(JsonObject parent, String key) { + return parent.has(key) && parent.get(key).isJsonPrimitive() ? parent.get(key).getAsString() : ""; + } +} 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..bbfd3b46 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -0,0 +1,396 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.gui.GuiGraphics; + +import net.minecraft.network.chat.Component; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.resources.language.I18n; + +/** Infrequent group metadata and placement ownership controls opened by the group cog. */ +final class OreSourceGroupSettingsScreen extends OreSpawnScreen { + private static final int MARGIN = 10; + private static final int NAME_TOP = 24; + private static final int ALIAS_LABEL_TOP = 48; + private static final int ALIAS_TOP = 58; + private final Screen parent; + private final GeologyEditorSession session; + private final String groupKey; + private String pendingAliasMove; + private String pendingAliasValue; + private boolean pendingDissolve; + private String error; + private int aliasScroll; + private int placementScroll; + private int contentX; + private int contentWidth; + private int aliasListHeight; + private int addAliasY; + private int placementLabelY; + private int placementListY; + private TextFieldWidget nameField; + private TextFieldWidget aliasField; + private CompactScrollList aliasList; + private CompactScrollList placementList; + + OreSourceGroupSettingsScreen(Screen parent, GeologyEditorSession session, String groupKey) { + super(Component.translatable("screen.orespawn.ore_source.group_settings")); + this.parent = parent; + this.session = session; + this.groupKey = groupKey; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + OreSourceGroup group = selectedGroup(); + if (group == null) { + minecraft.setScreen(parent); + return; + } + contentWidth = Math.max(240, Math.min(600, width - (MARGIN * 2))); + contentX = (width - contentWidth) / 2; + boolean hasGroupAction = group.curated || group.canDeleteEmpty() || group.canDissolve(); + String groupActionKey = group.curated ? "button.orespawn.reset" + : group.canDissolve() ? (pendingDissolve + ? "button.orespawn.ore_source.confirm_dissolve" + : "button.orespawn.ore_source.dissolve_group") + : "button.orespawn.ore_source.delete_group"; + int actionWidth = hasGroupAction + ? Math.min(120, Math.max(55, font.width(I18n.get(groupActionKey)) + 10)) : 0; + int nameLabelWidth = font.width( + I18n.get("option.orespawn.ore_source.group_name")) + 8; + + nameField = addButton(new TextFieldWidget(font, contentX + nameLabelWidth, NAME_TOP, + contentWidth - nameLabelWidth - actionWidth - (hasGroupAction ? 5 : 0), 20, + Component.translatable("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, + Component.translatable(groupActionKey), button -> handleGroupAction(group))); + OreSpawnScreenLayout.explain(this, groupAction, group.curated + ? "tooltip.orespawn.ore_source.reset_group" + : group.canDissolve() ? "tooltip.orespawn.ore_source.dissolve_group" + : "tooltip.orespawn.ore_source.delete_group"); + } + + aliasListHeight = aliasListHeight(height); + final List aliases = group.blockTagEntries; + aliasList = addButton(new CompactScrollList(this, contentX, ALIAS_TOP, + contentWidth, aliasListHeight) { + @Override protected int size() { return aliases.size(); } + @Override protected String rowText(int index) { return aliases.get(index); } + @Override protected List rowTooltip(int index) { + return java.util.Collections.singletonList(aliases.get(index)); + } + @Override protected int rowActionWidth(int index) { return 18; } + @Override protected void drawRowAction(net.minecraft.client.Minecraft minecraft, + int index, int x, int y, int actionWidth, int rowHeight, boolean hovered) { + if (hovered) fill(x, y, x + actionWidth, y + rowHeight, 0x805F5F5F); + drawCenteredString(minecraft.font, "-", x + (actionWidth / 2), y + 3, 0xFFFFFF); + } + @Override protected void onRowActionPressed(int index) { + syncName(); + session.removeOreMaterialAlias(group.material, aliases.get(index)); + pendingAliasMove = null; + pendingAliasValue = null; + pendingDissolve = false; + error = null; + captureScroll(); + rebuild(); + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList( + I18n.get("tooltip.orespawn.ore_source.remove_alias", aliases.get(index))); + } + }); + aliasList.setFirstIndex(aliasScroll); + + addAliasY = ALIAS_TOP + aliasListHeight + 4; + aliasField = addButton(new TextFieldWidget(font, contentX, addAliasY, + contentWidth - 46, 20, + Component.translatable("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, + Component.literal(pendingAliasMove == null ? "+" + : I18n.get("button.orespawn.ore_source.move")), button -> addAlias(group))); + OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.ore_source.alias"); + + final List channels = placementChannels(group); + placementLabelY = addAliasY + 27; + int helpX = contentX + font.width( + I18n.get("label.orespawn.ore_source.placement_rules")) + 8; + addButton(new Button(helpX, placementLabelY - 4, 16, 16, + Component.literal("?"), button -> { }, (button, mouseX, mouseY) -> { + List lines = new ArrayList<>(); + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_rules")); + lines.add(placementStatus(group, channels)); + lines.add(I18n.get("tooltip.orespawn.ore_source.external_excluded")); + renderStringTooltip(lines, mouseX, mouseY); + })); + placementListY = placementLabelY + 14; + placementList = addButton(new CompactScrollList(this, contentX, placementListY, + contentWidth, Math.max(16, height - 32 - placementListY)) { + @Override protected int size() { return channels.size(); } + @Override protected String rowText(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + OreSourceCandidate selected = selectedPlacementCandidate(group, channel); + String source = selected == null + ? I18n.get("label.orespawn.ore_source.missing") + : OreSourceListScreen.candidateLabel(selected); + return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + + placementSourceLabel(source, choices.size()); + } + @Override protected boolean rowEnabled(int index) { + String channel = channels.get(index); + return placementSelectable(group, channel); + } + @Override protected int rowColor(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + return choices.isEmpty() ? 0x777777 + : placementSelectable(group, channel) ? 0xFFFFFF : 0xC0C0C0; + } + @Override protected void onRowPressed(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + if (choices.size() <= 1) return; + int selected = OreSourceListScreen.indexOf(choices, group.placements.get(channel)); + OreSourceCandidate next = choices.get((selected + 1 + choices.size()) % choices.size()); + syncName(); + session.setOreSourcePlacement(group.key, channel, next.sourceId); + captureScroll(); + rebuild(); + } + @Override protected List rowTooltip(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + List lines = new ArrayList<>(); + lines.add(channelLabel(channel) + " (" + channel + ")"); + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_rules")); + if ("keep_separate".equals(group.mode)) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_requires_consolidated")); + } else if (selectedPlacementCandidate(group, channel) != null + && !selectedPlacementCandidate(group, channel).active) { + lines.add(I18n.get("tooltip.orespawn.ore_source.output_only")); + } else if (choices.isEmpty()) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_missing")); + } else if (choices.size() == 1) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_readonly")); + } else { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_select")); + } + lines.add(I18n.get("tooltip.orespawn.ore_source.external_excluded")); + return lines; + } + }); + placementList.setFirstIndex(placementScroll); + + int doneWidth = doneButtonWidth(contentWidth); + addButton(new Button(contentX + contentWidth - doneWidth, height - 28, doneWidth, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void handleGroupAction(OreSourceGroup group) { + syncName(); + pendingAliasMove = null; + pendingAliasValue = null; + if (group.curated) { + session.resetOreMaterialGroup(group.material); + pendingDissolve = false; + error = null; + rebuild(); + return; + } + if (group.canDeleteEmpty()) { + if (session.deleteEmptyOreMaterialGroup(group.material)) minecraft.setScreen(parent); + return; + } + if (!group.canDissolve()) return; + if (!pendingDissolve) { + pendingDissolve = true; + error = I18n.get("error.orespawn.ore_source.confirm_dissolve"); + rebuild(); + return; + } + if (session.dissolveOreMaterialGroup(group.material)) minecraft.setScreen(parent); + } + + private void addAlias(OreSourceGroup group) { + syncName(); + String alias = aliasField == null ? "" : aliasField.getValue().trim(); + String owner = session.blockTagOwner(alias); + String move = owner == null || owner.equals(group.material) ? null : alias + '|' + owner; + if (move != null && session.vanillaOreManagementRequiredForAliasMove(group.material, alias)) { + pendingDissolve = false; + pendingAliasMove = null; + pendingAliasValue = alias; + error = I18n.get("error.orespawn.ore_source.manage_vanilla_first"); + rebuild(); + return; + } + if (move != null && !move.equals(pendingAliasMove)) { + pendingDissolve = false; + pendingAliasMove = move; + pendingAliasValue = alias; + error = I18n.get("error.orespawn.ore_source.alias_owned", + OreSourceListScreen.display(owner)); + rebuild(); + return; + } + if (!session.addOreMaterialAlias(group.material, alias, move != null)) { + error = I18n.get("error.orespawn.ore_source.alias"); + return; + } + pendingAliasMove = null; + pendingAliasValue = null; + pendingDissolve = false; + error = null; + rebuild(); + } + + private OreSourceGroup selectedGroup() { + for (OreSourceGroup group : session.oreSourceGroups()) { + if (groupKey.equals(group.key)) return group; + } + return null; + } + + private void syncName() { + OreSourceGroup group = selectedGroup(); + if (group != null && nameField != null) { + session.renameOreMaterialGroup(group.material, nameField.getValue()); + } + } + + private void captureScroll() { + if (aliasList != null) aliasScroll = aliasList.firstIndex(); + if (placementList != null) placementScroll = placementList.firstIndex(); + } + + private void rebuild() { + clearWidgets(); + init(); + } + + static int aliasListHeight(int height) { + return height >= 260 ? 80 : 64; + } + + static int doneButtonWidth(int contentWidth) { + return Math.min(120, Math.max(80, contentWidth / 4)); + } + + static int validationMessageWidth(int contentWidth) { + return Math.max(40, contentWidth - doneButtonWidth(contentWidth) - 8); + } + + static List placementChannels(OreSourceGroup group) { + Set channels = new TreeSet<>(group.channels()); + channels.addAll(group.placements.keySet()); + return new ArrayList<>(channels); + } + + static String channelLabel(String channel) { + return "orespawn:standard".equals(channel) + ? I18n.get("label.orespawn.ore_source.standard_veins") + : OreSourceListScreen.display(channel); + } + + static String placementSourceLabel(String source, int choices) { + return choices > 1 + ? I18n.get("label.orespawn.ore_source.placement_choice_count", source, choices) + : source; + } + + static boolean placementSelectable(OreSourceGroup group, String channel) { + return !"keep_separate".equals(group.mode) + && OreSourceListScreen.placementCandidates(group, channel).size() > 1; + } + + static OreSourceCandidate selectedPlacementCandidate(OreSourceGroup group, String channel) { + String selected = group.placements.get(channel); + for (OreSourceCandidate candidate : group.candidates) { + if (candidate.loaded && candidate.sourceId.equals(selected) && candidate.channel.equals(channel)) { + return candidate; + } + } + return null; + } + + static String placementStatus(OreSourceGroup group, List channels) { + if (channels.isEmpty()) return I18n.get("label.orespawn.ore_source.no_placement_rules"); + if ("keep_separate".equals(group.mode)) { + return I18n.get("tooltip.orespawn.ore_source.placement_requires_consolidated"); + } + boolean selectable = false; + for (String channel : channels) { + int choices = OreSourceListScreen.placementCandidates(group, channel).size(); + if (choices == 0) { + return I18n.get(selectedPlacementCandidate(group, channel) == null + ? "tooltip.orespawn.ore_source.placement_missing" + : "tooltip.orespawn.ore_source.output_only"); + } + selectable |= choices > 1; + } + return I18n.get(selectable ? "tooltip.orespawn.ore_source.placement_select" + : "tooltip.orespawn.ore_source.placement_readonly"); + } + + @Override + public void onClose() { + syncName(); + captureScroll(); + minecraft.setScreen(parent); + } + + @Override + protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, Component.translatable("option.orespawn.ore_source.group_name"), + contentX + 2, NAME_TOP + 6, 0xA0A0A0); + drawString(font, Component.translatable("label.orespawn.ore_source.aliases"), + contentX + 2, ALIAS_LABEL_TOP, 0xA0A0A0); + drawString(font, Component.translatable("label.orespawn.ore_source.placement_rules"), + contentX + 2, placementLabelY, 0xFFFFFF); + OreSourceGroup group = selectedGroup(); + if (group != null && group.blockTagEntries.isEmpty()) { + drawCenteredString(font, Component.translatable("label.orespawn.ore_source.no_aliases"), + contentX + (contentWidth / 2), ALIAS_TOP + 4, 0x777777); + } + if (error != null) { + int messageWidth = validationMessageWidth(contentWidth); + Component message = Component.literal(error); + drawString(font, OreSpawnScreenLayout.fit(font, + message, messageWidth), contentX + 2, height - 22, 0xFF5555); + } + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + if (error != null) { + int messageWidth = validationMessageWidth(contentWidth); + if (mouseX >= contentX && mouseX < contentX + messageWidth + && mouseY >= height - 26 && mouseY < height - 8) { + renderStringTooltip(java.util.Collections.singletonList(error), mouseX, mouseY); + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java new file mode 100644 index 00000000..c40d8b17 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -0,0 +1,500 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.network.chat.Component; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.util.FormattedCharSequence; + +/** Two-pane material-group editor backed by compact independently scrolling lists. */ +final class OreSourceListScreen extends OreSpawnScreen { + private static final int HORIZONTAL_MARGIN = 10; + private static final int PANE_GAP = 6; + private static final int CONTENT_TOP = 24; + private final Screen parent; + private final GeologyEditorSession session; + private final Map weights = new LinkedHashMap<>(); + private String selectedKey; + private String error; + private boolean showAll; + private boolean resetArmed; + private int groupScroll; + private int outputScroll; + private int leftPaneX; + private int leftPaneWidth; + private int rightPaneX; + private int rightPaneWidth; + private int paneBottom; + private CompactScrollList groupList; + private CompactScrollList outputList; + private List visibleCandidates = java.util.Collections.emptyList(); + + OreSourceListScreen(Screen parent, GeologyEditorSession session) { + super(Component.translatable("screen.orespawn.ore_sources")); + this.parent = parent; + this.session = session; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + weights.clear(); + List groups = orderedGroups(); + if (selected(groups) == null && !groups.isEmpty()) selectedKey = groups.get(0).key; + + int contentWidth = contentWidth(width); + leftPaneX = (width - contentWidth) / 2; + leftPaneWidth = leftPaneWidth(width); + rightPaneX = leftPaneX + leftPaneWidth + PANE_GAP; + rightPaneWidth = contentWidth - leftPaneWidth - PANE_GAP; + paneBottom = height - 32; + + initGroups(groups); + initOutputs(selected(groups)); + String resetLabel = I18n.get(resetArmed + ? "button.orespawn.ore_source.confirm_reset_all" + : "button.orespawn.ore_source.reset_all"); + int resetWidth = Math.min(leftPaneWidth, Math.max(54, font.width(resetLabel) + 10)); + Button reset = addButton(OreSpawnScreenLayout.button(this, font, + leftPaneX, height - 28, resetWidth, 20, + Component.literal(resetLabel), button -> resetAll())); + OreSpawnScreenLayout.explain(this, reset, "tooltip.orespawn.ore_source.reset_all"); + int doneWidth = Math.min(150, rightPaneWidth); + addButton(new Button(rightPaneX + rightPaneWidth - doneWidth, height - 28, doneWidth, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void resetAll() { + if (!resetArmed) { + resetArmed = true; + error = null; + rebuild(); + return; + } + session.resetOreSourcesToDefaults(); + selectedKey = null; + groupScroll = 0; + outputScroll = 0; + showAll = false; + resetArmed = false; + error = null; + rebuild(); + } + + private void initGroups(final List groups) { + int addX = leftPaneX + font.width(I18n.get("label.orespawn.ore_source.groups")) + 8; + Button add = addButton(new Button(addX, 4, 16, 16, + Component.literal("+"), button -> { + if (!syncWeights()) return; + captureScroll(); + selectedKey = session.addOreMaterialGroup(); + outputScroll = 0; + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, add, "button.orespawn.ore_source.add_group"); + int showX = addX + 20; + String showLabel = I18n.get(showAll + ? "button.orespawn.ore_source.hide_single" + : "button.orespawn.show_all"); + int showWidth = compactFilterWidth(leftPaneX + leftPaneWidth - showX, + font.width(showLabel)); + Button show = addButton(OreSpawnScreenLayout.button(this, font, + showX, 4, showWidth, 16, + Component.literal(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(currentGraphics(), x, y, actionWidth, rowHeight, hovered); + } + + @Override protected void onRowActionPressed(int index) { + if (!syncWeights()) return; + captureScroll(); + selectedKey = groups.get(index).key; + minecraft.setScreen(new OreSourceGroupSettingsScreen( + OreSourceListScreen.this, session, selectedKey)); + } + + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList( + I18n.get("tooltip.orespawn.ore_source.group_settings")); + } + }); + groupList.setFirstIndex(groupScroll); + int selected = groupIndexOf(groups, selectedKey); + if (selected >= 0) groupList.ensureVisible(selected); + } + + private void initOutputs(final OreSourceGroup group) { + if (group == null) { + visibleCandidates = java.util.Collections.emptyList(); + return; + } + String mode = "keep_separate".equals(group.mode) ? "keep_original" : group.outputMode; + String acceptLabel = I18n.get("button.orespawn.ore_source.accept"); + int acceptWidth = group.needsReview() + ? Math.min(Math.max(42, font.width(acceptLabel) + 10), rightPaneWidth / 3) + : 0; + int modeWidth = rightPaneWidth - (acceptWidth == 0 ? 0 : acceptWidth + 4); + Button modeButton = addButton(OreSpawnScreenLayout.button(this, font, + rightPaneX, CONTENT_TOP, modeWidth, 20, + Component.translatable("option.orespawn.ore_source.output_mode", + Component.translatable("mode.orespawn.ore_source." + mode).getString()), + button -> { + if (!syncWeights()) return; + cycleMode(group); + outputScroll = 0; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, modeButton, "tooltip.orespawn.ore_source.output_mode"); + if (acceptWidth > 0) { + Button accept = addButton(OreSpawnScreenLayout.button(this, font, + rightPaneX + modeWidth + 4, CONTENT_TOP, acceptWidth, 20, + Component.literal(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, + Component.translatable("option.orespawn.ore_source.weight"))); + weight.setMaxLength(12); + weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); + weight.active = candidate.loaded && !candidate.enrichment; + weight.visible = false; + weights.put(candidate.sourceId, weight); + } + } + } + + private void updateWeightBounds() { + if (outputList == null) return; + for (int index = 0; index < visibleCandidates.size(); index++) { + OreSourceCandidate candidate = visibleCandidates.get(index); + TextFieldWidget weight = weights.get(candidate.sourceId); + if (weight == null) continue; + int y = outputList.rowY(index); + weight.visible = y >= 0; + if (weight.visible) weight.setBounds(outputList.contentRight() - 45, y + 1, 42, 14); + } + } + + private void cycleMode(OreSourceGroup group) { + resetArmed = false; + if (!group.hasManagedPlacementSource()) { + if (!"keep_separate".equals(group.mode)) session.restoreOreSourceOriginalMode(group.key); + error = I18n.get("label.orespawn.ore_source.no_placement_rules"); + return; + } + error = null; + if ("keep_separate".equals(group.mode)) session.setOreSourceOutputMode(group.key, "balanced"); + else if ("balanced".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "single"); + else if ("single".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "custom"); + else session.restoreOreSourceOriginalMode(group.key); + } + + private boolean syncWeights() { + OreSourceGroup group = selected(orderedGroups()); + if (group == null || !"custom".equals(group.outputMode)) return true; + for (Map.Entry entry : weights.entrySet()) { + if (!group.outputs.containsKey(entry.getKey())) continue; + try { + double value = Double.parseDouble(entry.getValue().getValue().trim()); + if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException(); + session.setOreSourceOutput(group.key, entry.getKey(), true, value); + } catch (NumberFormatException invalid) { + error = I18n.get("error.orespawn.ore_source.weight"); + return false; + } + } + error = null; + return true; + } + + private void captureScroll() { + resetArmed = false; + if (groupList != null) groupScroll = groupList.firstIndex(); + if (outputList != null) outputScroll = outputList.firstIndex(); + } + + private List orderedGroups() { + List result = new ArrayList<>(); + for (OreSourceGroup group : session.oreSourceGroups()) { + if (showAll || showByDefault(group)) result.add(group); + } + result.sort((left, right) -> { + int leftRank = groupStatusRank(left); + int rightRank = groupStatusRank(right); + if (leftRank != rightRank) return Integer.compare(leftRank, rightRank); + int name = left.displayName.compareToIgnoreCase(right.displayName); + if (name != 0) return name; + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return result; + } + + static boolean showByDefault(OreSourceGroup group) { + return !group.isRoutineSingleSource(); + } + + static int groupRowColor(OreSourceGroup group) { + return group.needsAttention() ? 0xFF5555 + : group.isRoutineSingleSource() ? 0x55FF55 : 0xFFFF55; + } + + private static int groupStatusRank(OreSourceGroup group) { + return group.needsAttention() ? 0 : group.isRoutineSingleSource() ? 2 : 1; + } + + private OreSourceGroup selected(List groups) { + if (selectedKey == null) return null; + for (OreSourceGroup group : groups) if (selectedKey.equals(group.key)) return group; + return null; + } + + static List placementCandidates(OreSourceGroup group, String channel) { + List result = new ArrayList<>(); + for (OreSourceCandidate candidate : group.candidates) { + if (candidate.loaded && candidate.active && !candidate.external && !candidate.enrichment + && channel.equals(candidate.channel)) result.add(candidate); + } + return result; + } + + static int indexOf(List candidates, String sourceId) { + for (int index = 0; index < candidates.size(); index++) { + if (candidates.get(index).sourceId.equals(sourceId)) return index; + } + return -1; + } + + static int groupIndexOf(List groups, String key) { + for (int index = 0; index < groups.size(); index++) { + if (groups.get(index).key.equals(key)) return index; + } + return -1; + } + + static String candidateLabel(OreSourceCandidate candidate) { + String owner = candidate.ownerName.isEmpty() ? candidate.owner : candidate.ownerName; + String state = candidate.external ? I18n.get("status.orespawn.ore_source.external_short") + : !candidate.loaded ? I18n.get("status.orespawn.ore_source.missing_short") + : !candidate.active ? I18n.get("status.orespawn.ore_source.output_only") : ""; + return owner + " - " + candidate.registryId + (state.isEmpty() ? "" : " (" + state + ")"); + } + + static List candidateTooltip(OreSourceCandidate candidate) { + List result = new ArrayList<>(); + result.add(I18n.get("label.orespawn.ore_source.owner", candidate.owner, + candidate.ownerVersion.isEmpty() ? "?" : candidate.ownerVersion)); + result.add(I18n.get("label.orespawn.ore_source.registry", candidate.registryId, candidate.metadata)); + result.add(I18n.get("label.orespawn.ore_source.dictionary", + candidate.blockTag.isEmpty() ? "-" : String.join(", ", candidate.blockTag))); + result.add(I18n.get(candidate.external ? "tooltip.orespawn.ore_source.external" + : !candidate.loaded ? "tooltip.orespawn.ore_source.missing" + : !candidate.active ? "tooltip.orespawn.ore_source.output_only" + : "tooltip.orespawn.ore_source.controlled")); + return result; + } + + static List groupTooltip(OreSourceGroup group) { + List result = new ArrayList<>(); + result.add(group.material); + result.add(I18n.get("label.orespawn.ore_source.summary", group.outputCandidates().size(), + I18n.get("status.orespawn.ore_source." + group.status))); + result.add(group.blockTagEntries.isEmpty() ? "-" + : String.join(", ", group.blockTagEntries)); + result.add(I18n.get("tooltip.orespawn.ore_source.group_colours")); + return result; + } + + static String formatWeight(Double weight) { + if (weight == null) return "1"; + long whole = weight.longValue(); + return weight.doubleValue() == whole ? Long.toString(whole) : Double.toString(weight.doubleValue()); + } + + static String display(String id) { + int split = id.indexOf(':'); + String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static void drawCompactCog(GuiGraphics graphics, int x, int y, + int width, int height, boolean hovered) { + if (hovered) graphics.fill(x, y, x + width, y + height, 0x805F5F5F); + CogButton.drawCog(graphics, x + width / 2, y + height / 2, true); + } + + private void rebuild() { + clearWidgets(); + init(); + } + + static int contentWidth(int width) { + return Math.max(240, Math.min(700, width - (HORIZONTAL_MARGIN * 2))); + } + + static int leftPaneWidth(int width) { + int content = contentWidth(width); + return Math.max(104, Math.min(280, (content * 43) / 100)); + } + + static int listHeight(int height) { + return Math.max(32, (height - 32) - CONTENT_TOP); + } + + static int compactFilterWidth(int availableWidth, int labelWidth) { + return Math.min(Math.max(38, labelWidth + 10), Math.max(16, availableWidth)); + } + + @Override + public void onClose() { + captureScroll(); + if (syncWeights()) minecraft.setScreen(parent); + } + + @Override + protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, Component.translatable("label.orespawn.ore_source.groups"), + leftPaneX + 2, 8, 0xFFFFFF); + if (error != null) { + drawCenteredString(font, OreSpawnScreenLayout.fit(font, + Component.literal(error), width - 24), width / 2, height - 39, 0xFF5555); + } + if (selected(orderedGroups()) == null) { + int messageY = CONTENT_TOP + 10; + for (FormattedCharSequence line : font.split( + Component.translatable("label.orespawn.ore_source.none"), + groupList.contentRight() - leftPaneX - 12)) { + graphics.drawString(font, line, leftPaneX + 6, messageY, 0xFFA0A0A0); + messageY += 10; + } + } + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } + + @Override + protected void renderBeforeWidgets(GuiGraphics graphics) { + fill(leftPaneX - 2, CONTENT_TOP - 2, + leftPaneX + leftPaneWidth + 2, paneBottom + 2, 0x70000000); + fill(rightPaneX - 2, CONTENT_TOP - 2, + rightPaneX + rightPaneWidth + 2, paneBottom + 2, 0x70000000); + updateWeightBounds(); + } +} 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..eae57e17 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java @@ -0,0 +1,124 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import zone.moddev.mc.orespawn.OreSpawn; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo; + +import net.neoforged.fml.ModList; +import net.neoforged.neoforgespi.language.IModInfo; + +/** Builds the immutable, loaded-only model rendered by the OreSpawn Mods screen. */ +final class OreSpawnModDirectoryModel { + private OreSpawnModDirectoryModel() { + } + + static List snapshot() { + List loaded = new ArrayList<>(); + for (IModInfo mod : ModList.get().getMods()) { + loaded.add(new LoadedMod(mod.getModId(), mod.getDisplayName(), + mod.getVersion().toString())); + } + return assemble(loaded, WorldgenIntegrationManager.providerIntegrations(), + WorldSettingsExtensionRegistry.extensions()); + } + + static List assemble(List loadedMods, + List providers, List extensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedMod mod : loadedMods) { + if (mod != null && mod.modId != null) loaded.put(mod.modId, mod); + } + Map integrations = new LinkedHashMap<>(); + for (ProviderIntegrationInfo provider : providers) { + if (isVisible(provider.modId(), loaded)) { + integrations.computeIfAbsent(provider.modId(), ignored -> new MutableEntry()) + .provider = provider; + } + } + for (WorldSettingsExtension extension : extensions) { + String modId = extension.id().getNamespace(); + if (isVisible(modId, loaded)) { + integrations.computeIfAbsent(modId, ignored -> new MutableEntry()) + .extension = extension; + } + } + + List result = new ArrayList<>(); + for (Map.Entry integration : integrations.entrySet()) { + LoadedMod mod = loaded.get(integration.getKey()); + result.add(new Entry(mod.modId, nonBlank(mod.displayName, mod.modId), + nonBlank(mod.version, "?"), integration.getValue().provider, + integration.getValue().extension)); + } + result.sort(Comparator.comparing((Entry entry) -> entry.displayName.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.modId)); + return Collections.unmodifiableList(result); + } + + private static boolean isVisible(String modId, Map loaded) { + return modId != null && !OreSpawn.MODID.equals(modId) && loaded.containsKey(modId); + } + + private static String nonBlank(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value; + } + + static final class LoadedMod { + final String modId; + final String displayName; + final String version; + + LoadedMod(String modId, String displayName, String version) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + } + } + + static final class Entry { + final String modId; + final String displayName; + final String version; + final ProviderIntegrationInfo provider; + final WorldSettingsExtension extension; + + Entry(String modId, String displayName, String version, + ProviderIntegrationInfo provider, WorldSettingsExtension extension) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + this.provider = provider; + this.extension = extension; + } + + boolean configurable() { return extension != null; } + boolean nativeOs4() { return provider != null && provider.nativeOs4(); } + int schemaVersion() { return provider == null ? -1 : provider.schemaVersion(); } + int providerRevision() { return provider == null ? -1 : provider.providerRevision(); } + List legacyLineages() { + return provider == null ? Collections.emptyList() : provider.legacyLineages(); + } + String statusKey() { + if (provider == null) return "config_only"; + if (provider.rejected()) return "rejected"; + ProviderStatus status = provider.status(); + return status == null ? "inactive" : status.name().toLowerCase(Locale.ROOT); + } + } + + private static final class MutableEntry { + ProviderIntegrationInfo provider; + WorldSettingsExtension extension; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java new file mode 100644 index 00000000..4d7cd0c9 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java @@ -0,0 +1,194 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.gui.GuiGraphics; + +import net.minecraft.network.chat.Component; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.resources.language.I18n; + +/** Paginated directory of loaded mods known to OreSpawn. */ +final class OreSpawnModsScreen extends OreSpawnScreen { + private static final int ROW_HEIGHT = 42; + private final Screen parent; + private final List entries; + private int page; + private String error; + private int contentLeft; + private int contentWidth; + private int listTop; + private int pageSize; + + OreSpawnModsScreen(Screen parent) { + this(parent, OreSpawnModDirectoryModel.snapshot()); + } + + OreSpawnModsScreen(Screen parent, List entries) { + super(Component.translatable("screen.orespawn.mods")); + this.parent = parent; + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + } + + @Override + protected void init() { + contentWidth = Math.min(390, Math.max(280, width - 24)); + contentLeft = (width - contentWidth) / 2; + listTop = 38; + int controlsY = height - 52; + pageSize = pageSize(height); + int pageCount = pageCount(entries.size(), pageSize); + page = Math.max(0, Math.min(page, pageCount - 1)); + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < entries.size(); index++) { + Entry entry = entries.get(start + index); + CogButton cog = addButton(new CogButton(contentLeft + contentWidth - 26, + listTop + index * ROW_HEIGHT + 9, button -> open(entry), + (button, mouseX, mouseY) -> renderStringTooltip(tooltip(entry), mouseX, mouseY))); + cog.active = entry.configurable(); + } + + Button previous = addButton(new Button(contentLeft, controlsY, 80, 20, + Component.translatable("button.orespawn.previous"), + button -> { page--; rebuildWidgets(); })); + Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20, + Component.translatable("button.orespawn.next"), + button -> { page++; rebuildWidgets(); })); + previous.active = page > 0; + next.active = page + 1 < pageCount; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void open(Entry entry) { + if (!entry.configurable()) return; + try { + Screen screen = entry.extension.createScreen(this); + if (screen == null) { + error = I18n.get("error.orespawn.mod.screen_missing", entry.modId); + return; + } + WorldSettingsExtensionNavigation.open(this, screen); + } catch (RuntimeException failure) { + error = I18n.get("error.orespawn.mod.screen_failed", entry.modId, + failure.getClass().getSimpleName()); + } + } + + private List tooltip(Entry entry) { + List lines = new ArrayList<>(); + lines.add(I18n.get(entry.configurable() + ? "tooltip.orespawn.mod.configure" : "tooltip.orespawn.mod.unavailable", + entry.displayName)); + lines.add(I18n.get("label.orespawn.mod.id_version", entry.modId, entry.version)); + lines.add(integration(entry)); + for (Integer lineage : entry.legacyLineages()) { + if (!entry.nativeOs4() && lineage.intValue() == primaryLegacy(entry)) continue; + lines.add(I18n.get("label.orespawn.mod.additional_lineage", lineage)); + } + lines.add(I18n.get("label.orespawn.mod.status", + I18n.get("status.orespawn.mod." + entry.statusKey()))); + return lines; + } + + private String integration(Entry entry) { + if (entry.nativeOs4()) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.get("label.orespawn.mod.os4_version", + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.get("label.orespawn.mod.os4"); + } + int legacy = primaryLegacy(entry); + if (legacy > 0) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.get("label.orespawn.mod.legacy_version", legacy, + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.get("label.orespawn.mod.legacy", legacy); + } + return I18n.get("label.orespawn.mod.config_only"); + } + + private static int primaryLegacy(Entry entry) { + List lineages = entry.legacyLineages(); + return lineages.isEmpty() ? 0 : lineages.get(lineages.size() - 1); + } + + protected void rebuildWidgets() { + clearWidgets(); + init(); + } + + static int pageSize(int height) { + return Math.max(1, ((height - 52) - 38) / ROW_HEIGHT); + } + + static int pageCount(int entries, int pageSize) { + return Math.max(1, (Math.max(0, entries) + Math.max(1, pageSize) - 1) + / Math.max(1, pageSize)); + } + + @Override + public void onClose() { + minecraft.setScreen(parent); + } + + @Override + protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + drawCenteredString(font, title, width / 2, 14, 0xFFFFFF); + if (entries.isEmpty()) { + drawCenteredString(font, Component.translatable("label.orespawn.mod.none"), + width / 2, height / 2 - 6, 0xA0A0A0); + } + int pages = pageCount(entries.size(), pageSize); + drawCenteredString(font, Component.literal((page + 1) + " / " + pages), + width / 2, height - 46, 0xA0A0A0); + if (error != null) { + drawCenteredString(font, Component.literal(error), width / 2, 27, 0xFF5555); + } + } + + @Override + protected void renderBeforeWidgets(GuiGraphics graphics) { + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < entries.size(); index++) { + drawEntry(entries.get(start + index), listTop + index * ROW_HEIGHT); + } + } + + private void drawEntry(Entry entry, int y) { + fill(contentLeft, y, contentLeft + contentWidth, y + 38, 0x70000000); + int textWidth = contentWidth - 36; + drawString(font, Component.literal(trim(entry.displayName, textWidth)), + contentLeft + 6, y + 3, 0xFFFFFF); + drawString(font, Component.literal(trim( + I18n.get("label.orespawn.mod.id_version", entry.modId, entry.version), textWidth)), + contentLeft + 6, y + 14, 0xA0A0A0); + String details = I18n.get("label.orespawn.mod.integration_status", integration(entry), + I18n.get("status.orespawn.mod." + entry.statusKey())); + drawString(font, Component.literal(trim(details, textWidth)), + contentLeft + 6, y + 25, statusColor(entry.statusKey())); + } + + private String trim(String value, int maximumWidth) { + if (font.width(value) <= maximumWidth) return value; + String suffix = "..."; + int width = maximumWidth - font.width(suffix); + return font.plainSubstrByWidth(value, Math.max(0, width)) + suffix; + } + + private static int statusColor(String status) { + if ("active".equals(status)) return 0x55FF55; + if ("pending".equals(status)) return 0xFFFF55; + if ("rejected".equals(status)) return 0xFF5555; + return 0xA0A0A0; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java index 96818532..73703718 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java @@ -1,6 +1,11 @@ package zone.moddev.mc.orespawn.client; +import java.util.ArrayList; +import java.util.List; + import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; @@ -8,16 +13,61 @@ * Renders Minecraft's background and widgets before custom foreground text. */ abstract class OreSpawnScreen extends Screen { + private GuiGraphics currentGraphics; + protected OreSpawnScreen(Component title) { super(title); } @Override public final void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - super.render(graphics, mouseX, mouseY, partialTick); - renderForeground(graphics, mouseX, mouseY, partialTick); + currentGraphics = graphics; + try { + super.render(graphics, mouseX, mouseY, partialTick); + renderForeground(graphics, mouseX, mouseY, partialTick); + for (var child : children()) { + if (child instanceof CompactScrollList list) list.renderTooltip(mouseX, mouseY); + } + } finally { + currentGraphics = null; + } + } + + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, + float partialTick) { + super.renderBackground(graphics, mouseX, mouseY, partialTick); + renderBeforeWidgets(graphics); + } + + protected final T addButton(T widget) { + return addRenderableWidget(widget); + } + + protected final void fill(int x1, int y1, int x2, int y2, int color) { + currentGraphics.fill(x1, y1, x2, y2, color); + } + + protected final void drawString(Font font, Component text, int x, int y, int color) { + currentGraphics.drawString(font, text, x, y, color); + } + + protected final void drawCenteredString(Font font, Component text, int x, int y, int color) { + currentGraphics.drawCenteredString(font, text, x, y, color); + } + + protected final void renderStringTooltip(List lines, int mouseX, int mouseY) { + List text = new ArrayList<>(lines.size()); + for (String line : lines) text.add(Component.literal(line)); + currentGraphics.renderComponentTooltip(font, text, mouseX, mouseY); + } + + protected final GuiGraphics currentGraphics() { + return currentGraphics; } protected abstract void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick); + + protected void renderBeforeWidgets(GuiGraphics graphics) { } } 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 5456a80c..ccf4c95c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java @@ -2,7 +2,6 @@ import net.minecraft.client.gui.Font; import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; @@ -33,6 +32,14 @@ static int mainRowSpacing(int height) { return compact(height) ? 22 : 24; } + static int worldCreationRowSpacing(int availableHeight, int rows) { + return rows <= 1 ? 4 : Math.min(4, Math.max(0, (availableHeight - 2 - rows * 20) / (rows - 1))); + } + + static int worldCreationContentWidth(int width) { + return Math.min(385, Math.max(5, width - 20)); + } + static int mainTitleY(int height) { return compact(height) ? 7 : 16; } @@ -65,24 +72,28 @@ static Component fit(Font font, Component message, int width) { } static Button button(Screen screen, Font font, int x, int y, int width, int height, - Component message, Button.OnPress onPress) { + Component message, net.minecraft.client.gui.components.Button.OnPress onPress) { Component fitted = fit(font, message, Math.max(0, width - 8)); - Button.Builder builder = Button.builder(fitted, onPress).bounds(x, y, width, height); - if (fitted != message) { - builder.tooltip(Tooltip.create(message)); - } - return builder.build(); + Button button = new Button(x, y, width, height, fitted, onPress); + if (fitted != message) button.setTooltip(Tooltip.create(message)); + return button; } static Button plainButton(int x, int y, int width, int height, Component message, Button.OnPress onPress) { - return Button.builder(message, onPress).bounds(x, y, width, height).build(); + return new Button(x, y, width, height, message, onPress); + } + + static Button explainedButton(Screen screen, Font font, int x, int y, int width, int height, + Component message, Button.OnPress onPress, String translationKey) { + return explain(button(screen, font, x, y, width, height, message, onPress), translationKey); } static Button plainButton(int x, int y, int width, int height, Component message, Button.OnPress onPress, Component tooltip) { - return Button.builder(message, onPress).bounds(x, y, width, height) - .tooltip(Tooltip.create(tooltip)).build(); + Button button = new Button(x, y, width, height, message, onPress); + button.setTooltip(Tooltip.create(tooltip)); + return button; } static T explain(T widget, String translationKey) { @@ -90,6 +101,18 @@ static T explain(T widget, String translationKey) { return widget; } + static T explain(Screen screen, T widget, String translationKey) { + return explain(widget, translationKey); + } + + static void renderExplanations(Screen screen, int mouseX, int mouseY) { + // Minecraft renders widget tooltips itself on this target. + } + + static void beginHelp(Screen screen) { + // Tooltips are attached directly to widgets on this target. + } + static Component tooltip(java.util.List lines) { net.minecraft.network.chat.MutableComponent result = Component.empty(); for (int i = 0; i < lines.size(); i++) { diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldCreationTab.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldCreationTab.java index d05c9bcc..f24e855d 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldCreationTab.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldCreationTab.java @@ -2,9 +2,13 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; +import java.util.Set; import com.google.gson.JsonObject; import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; @@ -20,6 +24,7 @@ import net.minecraft.client.gui.components.CycleButton; import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.components.tabs.GridLayoutTab; +import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; import net.minecraft.network.chat.Component; @@ -38,6 +43,9 @@ final class OreSpawnWorldCreationTab extends GridLayoutTab { private final List terrainControls = new ArrayList<>(); private final List> vanillaOresButtons = new ArrayList<>(); private final List