diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75ad96d0..7a45e2f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: test ! -e "$GRADLE_USER_HOME" chmod +x ./gradlew gradle_args=( - verifyMavenizerCompatibilityFixture help + classes verifyLegacyFixtures verifyMavenizerCompatibilityFixture --no-daemon --no-build-cache --stacktrace --max-workers=2 "-Dorg.gradle.java.installations.paths=$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" -Dorg.gradle.java.installations.auto-detect=false @@ -85,14 +85,14 @@ jobs: | sort -z | xargs -0 -r sha256sum > "$RUNNER_TEMP/markers-before.sha256" test -s "$RUNNER_TEMP/markers-before.sha256" - - name: Verify same-cache preparation is idempotent + - name: Replay the compiled target offline shell: bash env: GRADLE_USER_HOME: ${{ runner.temp }}/orespawn-cold-gradle run: | set -euo pipefail - ./gradlew verifyMavenizerCompatibilityFixture help \ - --no-daemon --no-build-cache --stacktrace --max-workers=2 \ + ./gradlew classes verifyLegacyFixtures verifyMavenizerCompatibilityFixture \ + --offline --rerun-tasks --no-daemon --no-build-cache --stacktrace --max-workers=2 \ -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" \ -Dorg.gradle.java.installations.auto-detect=false \ -Dorg.gradle.java.installations.auto-download=false @@ -149,9 +149,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.121011.jar - build/libs/OreSpawn-4.0.16.121011-sources.jar - build/libs/OreSpawn-4.0.16.121011-javadoc.jar + build/libs/OreSpawn-4.1.0.121011.jar + build/libs/OreSpawn-4.1.0.121011-sources.jar + build/libs/OreSpawn-4.1.0.121011-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 474582a9..6875d7f6 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,15 @@ +Version 4.1.0.121011 + +* Add the mod directory, material-group ore controls, and built-in MMD conflict defaults. +* Add exact new-terrain biome replacement and dimension-wide material controls. +* Preserve old profiles and missing-mod settings when upgrading schemas to 5, 8, and 7. +* Keep Help & Guide beside Recommended Defaults so the main menus fit smaller windows. +* Reject invalid geome IDs without silently changing their spelling. +* Recognise inline native ore features and clear wrappers when a server stops. +* Make noise arithmetic explicit without changing its results. +* Add separate shared-ore test launches and stronger Eclipse, cold-cache and runtime checks. +* Split the build into smaller commented scripts and normalize tracked text to LF. + Version 4.0.16.121011 * Leave benchmark shutdown to the GameTest harness when a benchmark is run diff --git a/README.md b/README.md index 9c0cc57c..4671d6e7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ [![Discord](https://img.shields.io/badge/Discord-MMD-green.svg?style=flat&logo=Discord)](https://discord.moddev.zone) + [![CurseForge downloads](https://cf.way2muchnoise.eu/full_mmd-orespawn_downloads.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) [![Supported Minecraft versions](https://cf.way2muchnoise.eu/versions/Minecraft_mmd-orespawn_all.svg)](https://www.curseforge.com/minecraft/mc-mods/mmd-orespawn) [![Build, test, and audit](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml/badge.svg?branch=master-1.21.1)](https://github.com/MinecraftModDevelopmentMods/OreSpawn/actions/workflows/ci.yml?query=branch%3Amaster-1.21.1) # MMD OreSpawn +The Forge 1.21.1 build includes separate shared-ore test launches for trying +multiple providers without changing the ordinary client's settings. See +`gradle/README.md` for the manual profiles and automated runtime checks. + OreSpawn 4 is a provider-driven world-generation engine for Minecraft 1.21.1. It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome @@ -17,7 +22,7 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. -This branch builds target-qualified version `4.0.16.121011`: the OreSpawn 4.0.16 +This branch builds target-qualified version `4.1.0.121011`: the OreSpawn 4.1 feature set for Minecraft 1.21.1 and Forge. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. @@ -54,6 +59,15 @@ Important files: Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. +**Ore Sources** groups ores by exact block tags such as `forge:ores/sulfur`. +When two loaded ordinary MMD providers share one tag, new worlds use a +Balanced output policy and one placement budget per channel. Older worlds keep +their saved Keep Original behaviour. Imported Ore Dictionary aliases remain +visible but dormant until you assign a known exact block tag. The **Biomes** +directory can replace one loaded biome with another in new terrain; its +**Overworld Materials** control applies across the dimension, not just the +selected biome. + When an already-generated world has saved Mineralogy 1.10, 1.12, or 5.x mod metadata but no OreSpawn world profile, OreSpawn reads the matching published configuration contract and records the exact engine, numeric settings, rock @@ -119,8 +133,6 @@ and fixtures. Published jars are deterministic and retain Forge 52's official runtime names, are audited for their four access-transformer rules and contents, and accompanied by SHA-256 checksums. -Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. -Public developer and AI integration guidance lives in `docs/` and is included -in the built jar. +Developer and player guides live in `docs/` and are included in the built jar. OreSpawn is licensed under LGPL-2.1. diff --git a/build.gradle b/build.gradle index ea11db2b..da9f4dc8 100644 --- a/build.gradle +++ b/build.gradle @@ -199,45 +199,6 @@ sourceSets.main.resources { srcDir 'src/generated/resources' } -def forgeRunModClassesDirectory = file("${buildDir}/forge-run-mod-classes/main") -def prepareForgeRunModClasses = tasks.register('prepareForgeRunModClasses', Sync) { - dependsOn tasks.named('classes') - from sourceSets.main.output.classesDirs - from sourceSets.main.output.resourcesDir - into forgeRunModClassesDirectory -} - -def configureForge52Run = { JavaExec runTask, String launchTarget -> - runTask.dependsOn prepareForgeRunModClasses - // Forge 52 resolves the complete exploded mod from one merged output. - // ForgeGradle materializes its source-set environment after the run model - // is configured, so apply the target-required merged path immediately - // before launch rather than allowing the split classes/resources paths to - // replace it. - runTask.doFirst { - Set splitOutputs = sourceSets.main.output.files.collect { - it.canonicalFile.absolutePath - } as Set - Collection targetClasspath = runTask.classpath.files.findAll { - !splitOutputs.contains(it.canonicalFile.absolutePath) - } - runTask.classpath = files(forgeRunModClassesDirectory, targetClasspath) - runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}" - } -} -tasks.withType(JavaExec).configureEach { JavaExec runTask -> - Map targets = [ - runClient: 'fmluserdevclient', - runServer: 'fmluserdevserver', - runGameTestServer: 'fmluserdevgametestserver', - runData: 'fmluserdevdata', - runSurfaceIntegrationFresh: 'fmluserdevserver', - runSurfaceIntegrationReload: 'fmluserdevserver' - ] - String launchTarget = targets.get(runTask.name) - if (launchTarget != null) configureForge52Run(runTask, launchTarget) -} - repositories { minecraft.mavenizer(it) maven fg.forgeMaven @@ -256,112 +217,34 @@ 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}") - } - } +def java21Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.ADOPTIUM } -tasks.register('verifyMavenizerCompatibilityFixture') { - group = 'verification' - description = 'Verifies the sealed target-aware ForgeGradle Mavenizer compatibility tool.' - inputs.files mavenizerFixtureChecksums.keySet() - doLast { - mavenizerFixtureChecksums.each { fixture, expected -> - if (!fixture.isFile()) { - throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") - } - String actual = sha256Of(fixture) - if (actual != expected) { - throw new GradleException("Mavenizer compatibility fixture checksum mismatch for " + - "${fixture.name}: expected ${expected}, found ${actual}") - } - } - - def parsed = new JsonSlurper().parse(mavenizerRuleManifest.asFile) - String target = 'net.minecraftforge:forge:1.21.1-52.1.0' - if (parsed.schema != 1 || parsed.targets.keySet() != [target] as Set) { - throw new GradleException('Mavenizer target-rule manifest has unexpected targets or schema') - } - def forgeRules = parsed.targets[target] - if (forgeRules.expectedApplications != 0 || !forgeRules.rules.isEmpty()) { - throw new GradleException('Forge 52 Mavenizer target must be an explicit zero-rule qualification') - } - - ZipFile fixtureJar = new ZipFile(mavenizerCompatibilityJar.asFile) - try { - def embeddedEntry = fixtureJar.getEntry( - 'META-INF/orespawn/minecraft-source-compatibility.json') - if (embeddedEntry == null) { - throw new GradleException('Mavenizer fixture is missing its embedded target-rule manifest') - } - byte[] embedded = fixtureJar.getInputStream(embeddedEntry).withCloseable { it.readAllBytes() } - byte[] external = mavenizerRuleManifest.asFile.bytes - if (!Arrays.equals(embedded, external)) { - throw new GradleException('Embedded and externally audited Mavenizer manifests differ') - } - - def patcherEntry = fixtureJar.getEntry( - 'net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.class') - if (patcherEntry == null) { - throw new GradleException('Mavenizer fixture is missing SourceCompatibilityPatcher') - } - byte[] classHeader = fixtureJar.getInputStream(patcherEntry).withCloseable { - it.readNBytes(8) - } - if (classHeader.length != 8 || classHeader[0..3] != - [0xCA, 0xFE, 0xBA, 0xBE].collect { (byte) it }) { - throw new GradleException('Mavenizer compatibility class has an invalid class header') - } - int classMajor = ((classHeader[6] & 0xff) << 8) | (classHeader[7] & 0xff) - if (classMajor != 69) { - throw new GradleException("Mavenizer compatibility tool must use Java 25 bytecode, found ${classMajor}") - } - - def jarManifestEntry = fixtureJar.getEntry('META-INF/MANIFEST.MF') - Manifest jarManifest = new Manifest(fixtureJar.getInputStream(jarManifestEntry)) - if (jarManifest.mainAttributes.getValue('Main-Class') != - 'net.minecraftforge.mcmaven.cli.Main') { - throw new GradleException('Mavenizer compatibility fixture has an unexpected main class') - } - } finally { - fixtureJar.close() - } - - String readme = mavenizerReadme.asFile.getText('UTF-8') - String patchText = mavenizerSourcePatch.asFile.getText('UTF-8') - String licenseText = mavenizerLicense.asFile.getText('UTF-8') - if (!readme.contains('6968241ce7a0a902cdc1c534b976e8373a423091') - || !readme.contains('Temurin `25.0.3+9`') - || !readme.contains('1.21.1-52.1.0')) { - throw new GradleException('Mavenizer provenance, target, or Java 25 build instructions are incomplete') - } - if (!patchText.contains('SourceCompatibilityPatcher.java') - || !patchText.contains('minecraft-source-compatibility.json')) { - throw new GradleException('Mavenizer source patch is incomplete') - } - if (!licenseText.contains('GNU LESSER GENERAL PUBLIC LICENSE')) { - throw new GradleException('Mavenizer LGPL-2.1 licence fixture is incomplete') - } - } -} +// Shared values are explicit so a script can be read on its own. +ext.oreSpawnBuild = [ + sha256Of: sha256Of, + mavenizerFixtureChecksums: mavenizerFixtureChecksums, + mavenizerCompatibilityJar: mavenizerCompatibilityJar, + mavenizerRuleManifest: mavenizerRuleManifest, + mavenizerSourcePatch: mavenizerSourcePatch, + mavenizerLicense: mavenizerLicense, + mavenizerReadme: mavenizerReadme, + mineralogy5OracleJar: mineralogy5OracleJar, + mineralogy5OracleSha256: mineralogy5OracleSha256, + java21Launcher: java21Launcher, + expectedMavenGroup: expectedMavenGroup, + expectedMavenArtifact: expectedMavenArtifact, + expectedMavenCoordinate: expectedMavenCoordinate, + archiveTextSuffixes: archiveTextSuffixes, + archiveTextPatterns: archiveTextPatterns, + normalizeArchiveLineEndings: normalizeArchiveLineEndings, + bundledDocumentationDirectory: bundledDocumentationDirectory, + prepareBundledDocumentation: prepareBundledDocumentation +] +apply from: 'gradle/verification/support.gradle' dependencies { implementation minecraft.dependency( "net.minecraftforge:forge:${project.minecraft_version}-${project.forge_version}") @@ -377,36 +260,6 @@ tasks.named('test', Test) { systemProperty 'orespawn.mineralogy5Oracle', mineralogy5OracleJar.absolutePath } -tasks.register('verifyLegacyOracleIsolation') { - group = 'verification' - description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' - 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}") - } - } - } -} - -def java21Launcher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(21) - vendor = JvmVendorSpec.ADOPTIUM -} -tasks.register('verifyJava21Toolchain') { - group = 'verification' - doLast { - def metadata = java21Launcher.get().metadata - String runtimeVersion = metadata.javaRuntimeVersion - if (project.java_toolchain_version != '21.0.7+6' - || metadata.vendor.toString() != 'Eclipse Temurin' - || !(runtimeVersion == '21.0.7+6' || runtimeVersion == '21.0.7+6-LTS')) { - throw new GradleException("Expected Temurin ${project.java_toolchain_version}, found " + - "${metadata.vendor} ${runtimeVersion} at ${metadata.installationPath}") - } - } -} tasks.named('check') { dependsOn tasks.named('verifyLegacyOracleIsolation') dependsOn tasks.named('verifyJava21Toolchain') @@ -430,10 +283,6 @@ tasks.named('processResources', ProcessResources) { minecraft_version_range: project.minecraft_version_range ]) } - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } @@ -445,1300 +294,19 @@ def prepareEclipseResources = tasks.register('prepareEclipseResources') { from(layout.buildDirectory.dir('resources/main')) into(layout.projectDirectory.dir('bin/main')) } + // The older guide index is no longer shipped; remove its stale IDE copy. + delete layout.projectDirectory.file('bin/main/META-INF/orespawn/docs/AGENTS.md') } } -// Registry-focused tests initialize the real global config singleton. Keep -// that target-native coverage without leaving a generated checkout config. -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 - } - } else if (unitTestWorldgenConfig.isFile()) { - delete unitTestWorldgenConfig - } - } -} -tasks.named('test', Test).configure { - finalizedBy preserveDeveloperWorldgenConfig -} - -// A Forge process is not green merely because it returns exit code zero. The -// loader can log a worldgen/linkage failure and still shut down normally. -def acceptedForge52LogNoise = [ - ~/FML appears to be missing any signature data/, - ~/Found multiple arguments for option fml\.mcVersion/, - ~/Found multiple arguments for option fml\.forgeVersion/, - ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to verify authentication$/, - ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, - ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, - ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / -] - -def runtimeCrashSnapshot = { File runDirectory -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (!crashDirectory.isDirectory()) return [] as Set - return fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() }.collect { it.absolutePath } as Set -} - -def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (crashDirectory.isDirectory()) { - def crashes = fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } - if (!crashes.isEmpty()) { - throw new GradleException("${context} produced crash report ${crashes.first()}") - } - } - File logsDirectory = new File(runDirectory, 'logs') - if (!logsDirectory.isDirectory()) return - def failures = [] - [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] - .findAll { it.isFile() }.each { File log -> - int lineNumber = 0 - log.eachLine('UTF-8') { String line -> - lineNumber++ - boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ - boolean knownNoise = acceptedForge52LogNoise.any { line =~ it } - boolean fatalText = line.contains('Encountered an unexpected exception') || - line.contains('Exception stopping the server') || - line.contains('Migration audit failed') || - line.contains('java.lang.Error:') || - line.contains('NoSuchMethodError') || - line.contains('NoClassDefFoundError') || - line.contains('ExceptionInInitializerError') || - line.contains('Tried to assign a mutable BlockPos') || - line.contains('causing cascading worldgen lag') - if ((unexpectedSeverity && !knownNoise) || fatalText) { - failures.add("${log.name}:${lineNumber}: ${line}") - } - } - } - if (!failures.isEmpty()) { - throw new GradleException("${context} logged unexpected errors:\n" - + failures.take(20).join('\n')) - } -} - -task runtimeLogScannerTest { - group = 'verification' - description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' - doLast { - File probe = file("${buildDir}/runtime-log-scanner-test") - delete probe - File logs = new File(probe, 'logs'); logs.mkdirs() - new File(logs, 'latest.log').setText( - '[main/ERROR] [FML]: FML appears to be missing any signature data\n' - + '[Render thread/ERROR] [net.minecraft.client.Minecraft/]: Failed to verify authentication\n' - + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' - + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') - assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) - new File(logs, 'latest.log').setText( - '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') - boolean rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') - new File(logs, 'latest.log').setText( - '[Server thread/DEBUG] [FML]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') - new File(logs, 'latest.log').setText( - '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') - delete probe - } -} - -check.dependsOn runtimeLogScannerTest - -task verifyMineralogyOracleIsolation { - group = 'verification' - description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' - doLast { - configurations.each { configuration -> - if (configuration.canBeResolved && - configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { - throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") - } - } - } -} - -check.dependsOn verifyMineralogyOracleIsolation - -['runClient', 'runServer', 'runGameTestServer', 'runData'].each { String taskName -> - tasks.matching { it.name == taskName }.all { JavaExec runTask -> - if (taskName == 'runGameTestServer') { - runTask.workingDir = file("${buildDir}/gametest-benchmark-run") - } - doFirst { - new File(runTask.workingDir, 'mods').mkdirs() - if (taskName == 'runGameTestServer') { - new File(runTask.workingDir, 'server.properties').setText('''\ -level-name=gametest-benchmark-world -level-seed=-4965128775892001975 -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - } - runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) - } - doLast { - assertRuntimeLogsClean(runTask.workingDir, taskName, - runTask.ext.oreSpawnCrashSnapshot as Set) - } - } -} - -def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") -tasks.register('compileClientIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/clientIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = clientIntegrationClasses - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - options.encoding = 'UTF-8' -} -tasks.register('clientIntegrationTestModJar', Jar) { - dependsOn tasks.named('compileClientIntegrationTestMod') - archiveFileName = 'clientprobe.jar' - destinationDirectory = file("${buildDir}/client-integration-fixture") - from clientIntegrationClasses - from 'src/clientIntegrationTest/resources' -} -def packagedClientProbeJar = tasks.named('clientIntegrationTestModJar', Jar) - -def benchmarkIntegrationClasses = file("${buildDir}/benchmark-integration-fixture/classes") -tasks.register('compileBenchmarkIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/benchmarkIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = benchmarkIntegrationClasses - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - options.encoding = 'UTF-8' -} -tasks.register('benchmarkIntegrationTestModJar', Jar) { - dependsOn tasks.named('compileBenchmarkIntegrationTestMod') - archiveFileName = 'benchmarkprobe.jar' - destinationDirectory = file("${buildDir}/benchmark-integration-fixture") - from benchmarkIntegrationClasses - from 'src/benchmarkIntegrationTest/resources' -} - -def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") -task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/biomeIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = surfaceIntegrationClasses - sourceCompatibility = '21' - targetCompatibility = '21' - options.encoding = 'UTF-8' -} - -task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = file("${buildDir}/surface-integration-fixture") - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' -} - -def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") -task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { - doLast { - delete surfaceIntegrationRunDirectory - surfaceIntegrationRunDirectory.mkdirs() - copy { - from surfaceIntegrationTestModJar.archiveFile - into new File(surfaceIntegrationRunDirectory, 'mods') - } - new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -def surfaceIntegrationFreshProcess = tasks.register('surfaceIntegrationFreshProcess') { - group = 'verification' - dependsOn 'runSurfaceIntegrationFresh' -} -tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { - dependsOn prepareSurfaceIntegrationTest -} -tasks.matching { it.name == 'runSurfaceIntegrationReload' }.all { - mustRunAfter surfaceIntegrationFreshProcess -} -surfaceIntegrationFreshProcess.configure { - doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) { - throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") - } - assertRuntimeLogsClean(surfaceIntegrationRunDirectory, - 'surface integration fresh phase', [] as Set) - } -} -def surfaceIntegrationReloadProcess = tasks.register('surfaceIntegrationReloadProcess') { - group = 'verification' - dependsOn surfaceIntegrationFreshProcess - dependsOn 'runSurfaceIntegrationReload' -} -surfaceIntegrationReloadProcess.configure { - doLast { - assertRuntimeLogsClean(surfaceIntegrationRunDirectory, - 'surface integration reload phase', [] as Set) - } -} - -task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { - group = 'verification' - doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties result = new Properties() - marker.withInputStream { result.load(it) } - if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Surface integration reload was not verified: ${marker}") - } - logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) - } -} - -check.dependsOn surfaceIntegrationTest - -task syncForge52EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { - group = 'ide' - doLast { - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String ordinaryModClasses = "${mod_id}%%${mainOutput}" - String fixtureOutput = surfaceIntegrationClasses.absolutePath - String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath - String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + - "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + - "surfaceprobe%%${fixtureResources}" - ['Client', 'Server', 'Data'].each { String runName -> - File launch = file("run${runName}.launch") - if (!launch.isFile()) { - throw new GradleException("Missing generated Eclipse launch: ${launch}") - } - String text = launch.getText('UTF-8') - if (text.contains('')) { - text = text.replace( - '', - '\r\n' + - " \r\n" + - '') - } - if (!text.contains('\r?\n/, - { String environmentHeader -> - environmentHeader + - " \r\n" - }) - } - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - text = text.replace('', - ' \r\n' + - '') - } - launch.setText(text, 'UTF-8') - } - ['Fresh', 'Reload'].each { String phase -> - File launch = file("runSurfaceIntegration${phase}.launch") - File serverLaunch = file('runServer.launch') - if (!serverLaunch.isFile()) { - throw new GradleException("Missing generated Eclipse launch: ${serverLaunch}") - } - String text = serverLaunch.getText('UTF-8') - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - text = text.replaceFirst( - //, - { String match, String arguments -> - "" - }) - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - launch.setText(text, 'UTF-8') - } - } -} - -task verifyForge52EclipseLaunchIsolation { - group = 'verification' - doLast { - ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> - File launch = file(launchName) - String launchText = launch.getText('UTF-8') - if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || - !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") - } - } - } -} - -syncForge52EclipseLaunches.finalizedBy verifyForge52EclipseLaunchIsolation - -tasks.matching { it.name == 'genEclipseRuns' }.all { - finalizedBy syncForge52EclipseLaunches -} - -def packagedSurfaceProbeJar = tasks.named('surfaceIntegrationTestModJar', Jar) - -tasks.named('jar', Jar) { - archiveClassifier = '' - destinationDirectory = layout.buildDirectory.dir('libs') - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', - 'Implementation-Title' : base.archivesName.get(), - 'Implementation-Version' : project.version, - 'Implementation-Vendor' : 'SkyBlade1978', - 'OreSpawn-API-Version' : '1', - 'FMLAT' : 'accesstransformer.cfg', - 'Maven-Artifact' : expectedMavenCoordinate, - 'Built-On-Java' : '21', - 'Built-On' : "${project.minecraft_version}-${project.forge_version}" - ]) - } -} - -def releaseJar = tasks.named('jar', Jar) - -tasks.named('sourcesJar', Jar) { - dependsOn prepareBundledDocumentation - filteringCharset = 'UTF-8' - includeEmptyDirs = false - filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-sources', - 'Implementation-Version': project.version - ]) - } -} -tasks.named('javadocJar', Jar) { - filteringCharset = 'UTF-8' - filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-javadoc', - 'Implementation-Version': project.version - ]) - } -} -['apiElements', 'runtimeElements'].each { configurationName -> - configurations.named(configurationName) { artifacts.clear() } - artifacts { add(configurationName, releaseJar) } -} -tasks.named('assemble') { - dependsOn releaseJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -def expectedReleaseFiles = providers.provider { - String prefix = "${base.archivesName.get()}-${project.version}" - ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] -} -def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - doLast { - if (project.mod_version != '4.0.16.121011' - || project.mod_group != expectedMavenGroup - || project.minecraft_version != '1.21.1' - || project.forge_version != '52.1.0' - || project.mapping_channel != 'official' - || project.mapping_version != '1.21.1') { - throw new GradleException('Unexpected OreSpawn 1.21.1 release identity') - } - if (project.loader_name != 'forge' || project.loader_code != '1' - || project.java_version != '21' || project.gradle_java_version != '21' - || project.java_toolchain_version != '21.0.7+6') { - throw new GradleException('Unexpected dispatcher or Java target metadata') - } - List expectedPublicArtifacts = [ - 'OreSpawn-4.0.16.121011.jar', - 'OreSpawn-4.0.16.121011-sources.jar', - 'OreSpawn-4.0.16.121011-javadoc.jar' - ] - if (base.archivesName.get() != expectedMavenArtifact - || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { - throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') - } - String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') - expectedPublicArtifacts.each { artifactName -> - if (!ciWorkflow.contains("build/libs/${artifactName}")) { - throw new GradleException("CI does not upload expected public artifact ${artifactName}") - } - } - [ - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', - 'README.md', 'CHANGELOG.txt' - ].each { path -> - if (!file(path).getText('UTF-8').contains('4.0.16.121011')) { - throw new GradleException("Release identity missing from ${path}") - } - } - if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { - throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') - } - if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') - .getText('UTF-8').contains('API_VERSION = 1')) { - throw new GradleException('OreSpawn API major must remain 1') - } - if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') - .getText('UTF-8').contains('SCHEMA_VERSION = 6') - || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') - .getText('UTF-8').contains('SCHEMA_VERSION = 5')) { - throw new GradleException('Global/world schemas must remain 6/5') - } - def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) - if (!(schema.properties.schema_version.enum as List).contains(4)) { - throw new GradleException('Provider schema must remain version 4') - } - } -} - -def trackedDocumentationDirectory = file('docs') -def documentationFiles = { - fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { - trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') }.sort() -} -def assertDocumentationTree = { File root, List expected, String label -> - List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } - .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') } - .sort() : [] - if (actual != expected) { - throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") - } - expected.each { relative -> - byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes - byte[] candidate = new File(root, relative).bytes - if (!java.util.Arrays.equals(tracked, candidate)) { - throw new GradleException("${label}/${relative} differs from tracked documentation") - } - } -} - -def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { - group = 'verification' - dependsOn prepareBundledDocumentation - dependsOn tasks.named('processResources') - dependsOn prepareEclipseResources - dependsOn releaseJar - doLast { - List expected = documentationFiles() - if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { - throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") - } - assertDocumentationTree(bundledDocumentationDirectory.asFile, - expected, 'generated resources') - assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, - 'META-INF/orespawn/docs'), expected, 'processed resources') - assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), - expected, 'Eclipse bin/main') - new ZipFile(releaseJar.get().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') - doLast { - File libs = layout.buildDirectory.dir('libs').get().asFile - List jars = (libs.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - // Provider interpolation yields GString values; normalize them before - // comparing with real filesystem String names. - List expected = expectedReleaseFiles.get() - .collect { it.toString() }.sort() - if (jars.collect { it.name } != expected) { - throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") - } - jars.each { candidate -> - if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") - new ZipFile(candidate).withCloseable { zip -> - zip.entries().findAll { entry -> - !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } - || entry.name.endsWith('/element-list') - || entry.name.endsWith('/package-list')) - }.each { entry -> - boolean cr = zip.getInputStream(entry).withCloseable { - input -> input.bytes.any { value -> value == 13 } - } - if (cr) throw new GradleException( - "${candidate.name}!/${entry.name} is not LF-normalized") - } - [ - 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', - 'src/benchmarkIntegrationTest/', 'agent-notes/', 'surfaceprobe', - 'clientprobe', 'benchmarkprobe', 'ci-fixtures/', - 'org/junit/', 'org/mockito/', 'net/bytebuddy/', - 'Mineralogy-1.18.2-5.4.0.jar' - ].each { forbidden -> - if (zip.entries().any { it.name.contains(forbidden) }) { - throw new GradleException( - "${candidate.name} contains forbidden ${forbidden}") - } - } - } - } - - File mainJar = new File(libs, expectedReleaseFiles.get()[0]) - new ZipFile(mainJar).withCloseable { zip -> - List names = zip.entries().collect { it.name } - [ - 'META-INF/mods.toml', - 'META-INF/accesstransformer.cfg', - 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', - 'META-INF/orespawn/docs/VERSIONS.md', - 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', - 'AGENTS.md' - ].each { required -> - if (!names.contains(required)) { - throw new GradleException("Release jar is missing ${required}") - } - } - String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) - .getText(StandardCharsets.UTF_8.name()) - if (!metadata.contains('modId="orespawn"') - || !metadata.contains("version=\"${project.version}\"") - || !metadata.contains('versionRange="[1.21.1,1.22)"')) { - throw new GradleException('Packaged Forge metadata is incorrect') - } - String transformer = zip.getInputStream( - zip.getEntry('META-INF/accesstransformer.cfg')) - .getText(StandardCharsets.UTF_8.name()) - List actualRules = transformer.readLines() - .collect { it.replaceFirst(/\s*#.*/, '').trim() } - .findAll { !it.isEmpty() } - List expectedRules = [ - 'public-f net.minecraft.world.level.chunk.ChunkGenerator 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' - ] - 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('FMLAT') != 'accesstransformer.cfg' - || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate - || manifest.getValue('Implementation-Timestamp') != null) { - throw new GradleException('Release manifest is incorrect or volatile') - } - zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> - byte[] header = new byte[8] - zip.getInputStream(entry).withCloseable { input -> - if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") - } - int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 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()) - // Provider interpolation yields GString values; normalize them before - // comparing with real filesystem String names. - List expected = expectedReleaseFiles.get() - .collect { it.toString() }.sort() - List jars = (prepared.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - List actualNames = jars.collect { it.name.toString() }.sort() - if (actualNames != expected) { - throw new GradleException( - "Prepared release jars ${actualNames} do not match ${expected}") - } - if (jars.any { it.length() == 0L }) { - throw new GradleException('Prepared release contains an empty jar') - } - File checksums = new File(prepared, 'SHA256SUMS') - if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { - throw new GradleException('Prepared release is missing checksums or changelog') - } - List actual = jars.collect { candidate -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - candidate.withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" - }.sort() - if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { - throw new GradleException('Prepared release checksums do not match') - } - } -} - -def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') - .orElse('https://invalid.invalid/missing-maven-upload-url') -def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') -def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') -publishing { - publications { - mavenJava(MavenPublication) { - groupId = expectedMavenGroup - artifactId = expectedMavenArtifact - version = project.version.toString() - if (preparedReleaseDir.isPresent()) { - File prepared = file(preparedReleaseDir.get()) - artifact(new File(prepared, expectedReleaseFiles.get()[0])) - artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } - artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } - } else { - artifact(releaseJar) - artifact(tasks.named('sourcesJar')) - artifact(tasks.named('javadocJar')) - } - pom { - name = 'MMD OreSpawn' - description = project.mod_description - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - licenses { - license { - name = 'GNU Lesser General Public License, Version 2.1' - url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' - } - } - } - } - } - repositories { - maven { - name = 'release' - url = uri(mavenUploadUrl.get()) - credentials { - username = mavenUploadUsername.orNull ?: '' - password = mavenUploadPassword.orNull ?: '' - } - } - } -} -tasks.register('verifyMavenCoordinates') { - group = 'verification' - description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' - dependsOn tasks.named('generatePomFileForMavenJavaPublication') - doLast { - File pomFile = layout.buildDirectory.file( - 'publications/mavenJava/pom-default.xml').get().asFile - if (!pomFile.isFile()) { - throw new GradleException("Generated Maven POM does not exist: ${pomFile}") - } - def pom = new XmlSlurper(false, false).parse(pomFile) - def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] - def expected = [expectedMavenGroup, expectedMavenArtifact, - project.version.toString()] - if (project.group.toString() != expectedMavenGroup || actual != expected) { - throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + - "found ${actual.join(':')}") - } - } -} -tasks.register('validateMavenReleaseCredentials') { - group = 'publishing' - doLast { - if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() - || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { - throw new GradleException( - 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') - } - if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { - throw new GradleException('Release publication must use a remote repository') - } - } -} -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn preparedReleaseDir.isPresent() - ? tasks.named('verifyPreparedReleaseArtifacts') - : tasks.named('verifyReleaseArtifacts') -} - -eclipse { - classpath { - downloadSources = true - downloadJavadoc = true - file.whenMerged { classpath -> - classpath.entries.removeAll { entry -> - entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder - && ['src/main/resources', 'src/generated/resources'].contains(entry.path) - } - if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { - classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( - 'build/resources/main', 'bin/main')) - } - } - } - synchronizationTasks 'isolateEclipseProductionRuns' -} -idea { - module { - downloadSources = true - downloadJavadoc = true - } -} -tasks.register('configureEclipseBuildship') { - group = 'ide' - doLast { - File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') - Properties preferences = new Properties() - [ - 'eclipse.preferences.version' : '1', - 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', - 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'connection.project.dir' : '', - 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'override.workspace.settings' : 'true' - ].each { key, value -> preferences.setProperty(key, value) } - preferencesFile.parentFile.mkdirs() - preferencesFile.withOutputStream { - preferences.store(it, 'Generated by OreSpawn Buildship configuration.') - } - } -} -tasks.register('isolateEclipseProductionRuns') { - group = 'ide' - dependsOn tasks.named('genEclipseRuns') - dependsOn syncForge52EclipseLaunches - dependsOn tasks.named('configureEclipseBuildship') - dependsOn prepareEclipseResources - doLast { - [ - 'OreSpawn_Client.launch': 'GradleStart', - 'OreSpawn_Server.launch': 'GradleStartServer' - ].each { String name, String mainClass -> - File launch = file(name) - if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) - && !launch.delete()) { - throw new GradleException("Could not remove obsolete launch ${name}") - } - } - fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> - String contents = launch.getText('UTF-8') - contents = contents.replace( - 'key="MC_VERSION" value="${MC_VERSION}"', - "key=\"MC_VERSION\" value=\"${minecraft_version}\"") - launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') - } - } -} -tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - dependsOn tasks.named('eclipseClasspath') - dependsOn tasks.named('isolateEclipseProductionRuns') - dependsOn tasks.named('verifyLegacyOracleIsolation') - doLast { - File prefs = file('.settings/org.eclipse.buildship.core.prefs') - if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') - Properties buildshipPreferences = new Properties() - prefs.withInputStream { buildshipPreferences.load(it) } - String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath - if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome - || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome - || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { - throw new GradleException( - "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") - } - Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration - .resolvedArtifacts - .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } - .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } - .toSet() - if (!legacyLwjglArtifacts.isEmpty()) { - throw new GradleException( - "Forge 1.21.1 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") - } - File eclipseClasspath = file('.classpath') - if (!eclipseClasspath.isFile()) { - throw new GradleException('Eclipse .classpath was not generated') - } - String eclipseClasspathText = eclipseClasspath.getText('UTF-8') - if (!eclipseClasspathText.contains('path="build/resources/main"') - || eclipseClasspathText.contains('path="src/main/resources"') - || eclipseClasspathText.contains('path="src/generated/resources"')) { - throw new GradleException( - 'Eclipse must consume only Gradle-processed production resources') - } - [ - 'META-INF/mods.toml', - 'META-INF/orespawn/docs/README.md', - 'META-INF/orespawn/docs/VERSIONS.md' - ].each { relative -> - if (!new File('bin/main', relative).isFile()) { - throw new GradleException("Eclipse output is missing ${relative}") - } - } - File processedModMetadata = new File(sourceSets.main.output.resourcesDir, - 'META-INF/mods.toml') - File eclipseModMetadata = file('bin/main/META-INF/mods.toml') - if (!java.util.Arrays.equals(processedModMetadata.bytes, eclipseModMetadata.bytes) - || eclipseModMetadata.getText('UTF-8').contains('${version}') - || !eclipseModMetadata.getText('UTF-8').contains( - "version=\"${project.version}\"")) { - throw new GradleException( - 'Eclipse output contains unexpanded or stale Forge mod metadata') - } - List forbidden = [ - 'src/test', 'bin/test', 'build/classes/java/test', - 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', - 'surfaceprobe', 'clientprobe', 'benchmarkprobe', 'junit-', 'opentest4j-', - 'Mineralogy-1.18.2-5.4.0.jar', 'C:\\Users\\John' - ] - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String expectedModClasses = "${mod_id}%%${mainOutput}" - ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> - File launch = file(name) - if (!launch.isFile()) throw new GradleException("Missing ${name}") - String contents = launch.getText('UTF-8') - List leaked = forbidden.findAll { contents.contains(it) } - if (!leaked.isEmpty()) { - throw new GradleException("${name} exposes test/local content: ${leaked}") - } - if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') - || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { - throw new GradleException("${name} is not a production-only OreSpawn launch") - } - if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { - throw new GradleException("${name} lacks Forge 52 merged output discovery") - } - } - } -} - -tasks.register('verifyCommandPortability') { - group = 'verification' - description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' - doLast { - String gradleSource = file('build.gradle').getText('UTF-8') - List commandSources = [file('build.gradle')] - commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) - commandSources.each { File source -> - String text = source.getText('UTF-8') - if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { - throw new GradleException("Shell-specific command wrapper in ${source}") - } - } - if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') - || !gradleSource.contains('join(File.pathSeparator)') - || !gradleSource.contains('${File.pathSeparator}')) { - throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') - } - } -} - -tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } -tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } - -def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') -def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') -def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') - -def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> - if (!configuredPath.isPresent()) { - throw new GradleException("Pass -P${propertyName}=") - } - File runtime = file(configuredPath.get()) - if (!runtime.isDirectory()) { - throw new GradleException("${propertyName} does not name a directory: ${runtime}") - } - runtime -} - -def stagePackagedServerLibraries = { File runtime, File runDirectory -> - File sourceLibraries = new File(runtime, 'libraries') - if (!sourceLibraries.isDirectory()) { - throw new GradleException("Official server runtime has no libraries directory: ${sourceLibraries}") - } - File targetLibraries = new File(runDirectory, 'libraries') - sourceLibraries.eachFileRecurse(groovy.io.FileType.FILES) { File source -> - java.nio.file.Path relative = sourceLibraries.toPath().relativize(source.toPath()) - File target = targetLibraries.toPath().resolve(relative).toFile() - target.parentFile.mkdirs() - try { - java.nio.file.Files.createLink(target.toPath(), source.toPath()) - } catch (IOException | UnsupportedOperationException | SecurityException ignored) { - java.nio.file.Files.copy(source.toPath(), target.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING) - } - } -} - -def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") -tasks.register('preparePackagedSurfaceIntegration') { - dependsOn releaseJar - dependsOn packagedSurfaceProbeJar - doLast { - File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, - 'packagedForgeServerRuntime') - delete packagedSurfaceRunDirectory - packagedSurfaceRunDirectory.mkdirs() - stagePackagedServerLibraries(runtime, packagedSurfaceRunDirectory) - copy { - from new File(runtime, "forge-${minecraft_version}-${forge_version}-shim.jar") - into packagedSurfaceRunDirectory - } - copy { - from releaseJar - from packagedSurfaceProbeJar - into new File(packagedSurfaceRunDirectory, 'mods') - } - new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} -def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { - group = 'verification' - dependsOn tasks.named('preparePackagedSurfaceIntegration') - doFirst { - File forgeDirectory = new File(packagedSurfaceRunDirectory, - 'libraries/net/minecraftforge/forge/1.21.1-52.1.0') - File launcher = new File(forgeDirectory, 'forge-1.21.1-52.1.0-server.jar') - File sourceArguments = new File(forgeDirectory, - System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') - ? 'win_args.txt' : 'unix_args.txt') - [launcher, sourceArguments].each { - if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") - } - workingDir packagedSurfaceRunDirectory - commandLine java21Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', - "@libraries/net/minecraftforge/forge/1.21.1-52.1.0/${sourceArguments.name}", - 'nogui' - } - doLast { - File marker = new File(packagedSurfaceRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') - assertRuntimeLogsClean(packagedSurfaceRunDirectory, - 'packaged surface fresh', [] as Set) - } -} -def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { - group = 'verification' - dependsOn packagedSurfaceFresh - doFirst { - File forgeDirectory = new File(packagedSurfaceRunDirectory, - 'libraries/net/minecraftforge/forge/1.21.1-52.1.0') - File sourceArguments = new File(forgeDirectory, - System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') - ? 'win_args.txt' : 'unix_args.txt') - workingDir packagedSurfaceRunDirectory - commandLine java21Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', - "@libraries/net/minecraftforge/forge/1.21.1-52.1.0/${sourceArguments.name}", - 'nogui' - } - doLast { - assertRuntimeLogsClean(packagedSurfaceRunDirectory, - 'packaged surface reload', [] as Set) - } -} -tasks.register('packagedSurfaceIntegrationTest') { - group = 'verification' - dependsOn packagedSurfaceReload - doLast { - File marker = new File(packagedSurfaceRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties values = new Properties() - marker.withInputStream { values.load(it) } - if (values.getProperty('reload_verified') != 'true') { - throw new GradleException('Packaged surface reload was not verified') - } - } -} - -def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") -tasks.register('preparePackagedClientIntegration') { - dependsOn releaseJar - dependsOn packagedClientProbeJar - doLast { - delete packagedClientRunDirectory - packagedClientRunDirectory.mkdirs() - copy { - from releaseJar - from packagedClientProbeJar - into new File(packagedClientRunDirectory, 'mods') - } - new File(packagedClientRunDirectory, 'options.txt').setText( - 'fullscreen:false\nlang:en_us\n', 'UTF-8') - File configDirectory = new File(packagedClientRunDirectory, 'config') - configDirectory.mkdirs() - new File(configDirectory, 'orespawn-common.toml').setText('''\ -# Bootstrap fallbacks. Detailed settings live in orespawn-worldgen.json. -[worldgen] - # Master switch for configured terrain replacement. - place_terrain = true - # Allowed Values: GEOME, LEGACY - fallback_geology_mode = "GEOME" - # Range: 4 ~ 32767 - cyano_region_size = 256 - # Range: 1.0 ~ 32767.0 - cyano_layer_reach = 32.0 - # Range: 1 ~ 255 - cyano_layer_thickness = 8 -''', 'UTF-8') - } -} -def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { - group = 'verification' - dependsOn tasks.named('preparePackagedClientIntegration') - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, - 'packagedForgeClientRuntime') - String forgeVersionId = ['1.21.1-forge-52.1.0', 'forge-52.1.0'].find { candidate -> - new File(runtime, "versions/${candidate}/${candidate}.json").isFile() - } - if (forgeVersionId == null) { - throw new GradleException('Official client runtime has no Forge 52.1.0 profile') - } - File forgeJsonFile = new File(runtime, - "versions/${forgeVersionId}/${forgeVersionId}.json") - File baseJsonFile = new File(runtime, 'versions/1.21.1/1.21.1.json') - File baseJar = new File(runtime, 'versions/1.21.1/1.21.1.jar') - File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") - [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), - new File(runtime, 'assets'), nativesDirectory].each { - if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") - } - - def slurper = new groovy.json.JsonSlurper() - Map forgeJson = (Map) slurper.parse(forgeJsonFile) - Map baseJson = (Map) slurper.parse(baseJsonFile) - Map classpathByModule = new LinkedHashMap<>() - [baseJson, forgeJson].each { Map metadata -> - ((List) metadata.libraries).each { Map library -> - List rules = (List) library.rules - boolean allowed = rules == null || rules.isEmpty() - if (rules != null) { - rules.each { Map rule -> - Map os = (Map) rule.os - boolean matches = os == null - || (os.name == 'windows' - && (os.arch == null || os.arch == System.getProperty('os.arch'))) - if (matches) allowed = rule.action == 'allow' - } - } - if (!allowed) return - String relative = (String) ((Map) ((Map) library.downloads).artifact).path - File artifact = new File(runtime, "libraries/${relative}") - if (!artifact.isFile()) { - throw new GradleException("Missing official client library: ${artifact}") - } - List coordinates = ((String) library.name).split(':') as List - String module = coordinates.size() >= 2 - ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name - if (coordinates.size() >= 4) { - // Since 1.19 Mojang lists native classifiers as separate libraries. - // Keep each classifier alongside its base module instead of replacing it. - module += ":${coordinates.subList(3, coordinates.size()).join(':')}" - } - classpathByModule.put(module, artifact) - } - } - List classpathFiles = new ArrayList<>(classpathByModule.values()) - classpathFiles.add(baseJar) - - String libraryDirectory = new File(runtime, 'libraries').absolutePath - List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) - .collect { String argument -> - argument.replace('${library_directory}', libraryDirectory) - .replace('${classpath_separator}', File.pathSeparator) - .replace('${version_name}', (String) forgeJson.inheritsFrom) - } - - List gameArguments = [] - gameArguments.addAll((List) ((Map) forgeJson.arguments).game) - gameArguments.addAll([ - '--username', 'OSValidation', - '--version', (String) forgeJson.id, - '--gameDir', packagedClientRunDirectory.absolutePath, - '--assetsDir', new File(runtime, 'assets').absolutePath, - '--assetIndex', (String) ((Map) baseJson.assetIndex).id, - '--uuid', '00000000-0000-0000-0000-000000000001', - '--accessToken', 'validation-token', - '--userType', 'legacy', - '--versionType', 'release', - '--width', '854', '--height', '480' - ]) - workingDir packagedClientRunDirectory - File clientJava = packagedClientJavaExecutable.isPresent() - ? file(packagedClientJavaExecutable.get()) - : java21Launcher.get().executablePath.asFile - if (!clientJava.isFile()) { - throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") - } - List clientCommand = [ - clientJava.absolutePath, - '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', - "-Djava.library.path=${nativesDirectory.absolutePath}", - '-Dminecraft.launcher.brand=orespawn-validation', - '-Dminecraft.launcher.version=1' - ] - clientCommand.addAll(forgeJvmArguments) - clientCommand.addAll([ - '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), - (String) forgeJson.mainClass - ]) - clientCommand.addAll(gameArguments) - commandLine(clientCommand) - } -} -tasks.register('packagedClientIntegrationTest') { - group = 'verification' - dependsOn packagedClientProcess - doLast { - File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') - if (!marker.isFile()) { - throw new GradleException('Packaged client completion marker is missing') - } - Properties values = new Properties() - marker.withInputStream { values.load(it) } - ['world_settings_opened', 'long_editor_roundtrip', - 'first_world_rendered', 'reload_rendered'].each { key -> - if (values.getProperty(key) != 'true') { - throw new GradleException("Packaged client failed ${key}: ${values}") - } - } - assertDocumentationTree(new File(packagedClientRunDirectory, - 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') - assertRuntimeLogsClean(packagedClientRunDirectory, - 'packaged client', [] as Set) - } -} - -tasks.register('packagedRuntimeIntegrationTest') { - group = 'verification' - description = 'Runs the exact official-named jars in official Forge 52 server and client runtimes.' - dependsOn tasks.named('packagedSurfaceIntegrationTest') - dependsOn tasks.named('packagedClientIntegrationTest') -} +ext.oreSpawnBuild.prepareEclipseResources = prepareEclipseResources +apply from: 'gradle/verification/client-and-benchmark.gradle' +apply from: 'gradle/release/artifacts.gradle' +apply from: 'gradle/release/publishing.gradle' +apply from: 'gradle/ide/eclipse.gradle' +apply from: 'gradle/verification/packaged-forge.gradle' +apply from: 'gradle/verification/ore-sources.gradle' +apply from: 'gradle/verification/performance.gradle' +apply from: 'gradle/verification/biome-override.gradle' +apply from: 'gradle/verification/native-ores.gradle' diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index a62c49fb..00000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,17 +0,0 @@ -# OreSpawn Documentation Map - -This index is for navigating the documentation to learn how to integrate with -and use OreSpawn with a mod or modpack. Start with -[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). - -Use the focused guides for implementation details: - -- [API.md](API.md) for the supported Java API; -- [PROVIDERS.md](PROVIDERS.md) for packaged and configurable providers; -- [FEATURES.md](FEATURES.md) for rocks, ores, deposits, and geology; -- [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; -- [TEMPLATES.md](TEMPLATES.md) for selectable world styles; -- [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; -- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified - versioning, skipped functional releases, and branch-release convention; -- [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index 9e07930c..3f4cde8c 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 31681f3f..780b276d 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -164,10 +164,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 8cf34978..c8c0d1f1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,19 +4,24 @@ 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, the selected template, and Create World edits. The result is saved with the world. Restart after editing JSON by hand. +New geome IDs must already be valid lowercase registry IDs, such as +`examplemod:limestone_region`. The editor trims surrounding spaces but does not +rename an invalid ID or change the pending profile when it rejects one. + An existing generated world follows a stricter safety order. Its existing `serverconfig/orespawn-worldgen.json` always wins. If none exists, saved legacy Mineralogy mod metadata may select the matching 1.10, 1.12, or 5.x config @@ -29,7 +34,7 @@ report for the exact decision. | Field | Values | Meaning | |---|---|---| -| `schema_version` | Contract-specific integer | Global 6, world 5, provider 4 | +| `schema_version` | Contract-specific integer | Global 8, world 7, provider 5 | | `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine | | `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules | | `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features | @@ -44,6 +49,8 @@ report for the exact decision. | `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces | | `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions | | `ores` | object keyed by rule ID | Ore outputs and per-dimension placement | +| `ore_material_groups` | object keyed by material ID | Friendly names and exact block-tag aliases; imported Ore Dictionary names stay dormant | +| `ore_source_policies` | object keyed by material and dimension | Saved output mode, weights, and placement source per channel | | `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement | | `retrogen` | object | Bounded ore retrogen controls | | `flat_bedrock` | object | Opt-in flat bedrock controls | @@ -178,6 +185,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 +203,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 cafcb4ed..9470639d 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -1,5 +1,19 @@ # OreSpawn Developer Guide +## Checking a local build + +Run the offline tests, build, Javadocs, artifact audit and checksum tasks before +sharing a candidate. Also run `genEclipseRuns` and +`verifyEclipseProductionClasspath` if you use Eclipse. + +For shared-ore testing, `runOreSourcesClient.launch` and +`runTestOreSourcesClient.launch` load two simulated providers in +`run-ore-sources`. They keep their settings separate from the ordinary client. +The automatic client checks use disposable directories under `build` instead. +`nativeOreIntegrationTest` checks native Nether hosts, exact-save reloads, +new-terrain edits and wrapper cleanup. See `gradle/README.md` for task ownership +and the official-runtime properties used by the packaged checks. + ## Decide Which Integration You Need | Goal | Recommended integration | @@ -12,6 +26,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 +37,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 +49,7 @@ stone without enabling strata: ```json { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "ores": { diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 08a587f4..5f4466c8 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -25,8 +25,10 @@ player deliberately adds them. You can remove the starter rocks or add blocks from installed mods before creating the world. Mods can also offer new biomes and world materials without enabling strata. -Use **Biomes & World Materials** to inspect installed dimension palettes, -surface blocks, aquifer fluids, snow, and ice. The picker only accepts real +Use **Biomes** to inspect loaded biomes, placement palettes, and surface rules. +Replacing a biome changes only newly generated terrain. The dimension's +**Materials** button changes aquifer fluid, snow, or ice across every biome in +that dimension, not only the biome currently selected. The picker only accepts installed registry entries. Missing optional compatibility biomes are skipped safely instead of breaking world creation. @@ -48,11 +50,13 @@ safely instead of breaking world creation. installed mod. These are covered underground deposits, not exposed vanilla lakes. **Solid Cover** controls the roof thickness, while **Solid Shell** prevents a deposit from opening into a cave at its sides or underside. -- **Biomes & World Materials** controls broad biome regions and what their - surfaces, underground water, snow, and ice are made from. **Augment** mixes - new biomes into the existing source; **Replace** creates a complete provider - style. Namespace scope protects other biome mods unless a pack deliberately - opts them in. +- **Biomes** lists installed and referenced biomes and their placement rules. + **Replace in new terrain with...** is an exact, one-to-one choice and does + not rewrite existing chunks. Palette settings still offer **Augment** for a + provider mix or **Replace** for a complete provider style. +- **Ore Sources** groups interchangeable ores by exact block tags. Balanced + shares one placement budget across eligible outputs; Single picks one output; + Custom lets you choose weights. Keep Original leaves every rule independent. **World Materials** applies across an entire dimension. **Aquifer Fluid** changes the normal below-sea-level fluid, while **Deep Aquifer** can use a diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 8481f4fc..729f18e4 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -6,12 +6,20 @@ Provider mods may contribute through Forge IMC, a packaged resource at present malformed override leaves that provider inactive instead of silently falling back. -Provider schema 4 supports `profile_defaults`, `rocks`, `ores`, +Provider schema 5 supports `profile_defaults`, `rocks`, `ores`, `fluid_deposits`, `geomes`, `biome_rules`, `terrain_dimensions`, and `templates`, plus `biome_palettes` and `dimension_materials`. Each file requires a matching `provider_modid`, a positive `provider_revision`, and at least one -contribution. Legacy schemas 1-3 remain accepted; schema 3 introduced fluid -deposits and schema 4 introduces biome and world-material controls. +contribution. Legacy schemas 1-4 remain accepted; schema 3 introduced fluid +deposits, schema 4 added biome and world-material controls, and schema 5 adds +optional `material` and `placement_channel` IDs for exact ore grouping. + +Use the same exact loaded block tag, such as `forge:ores/sulfur`, for ores that +are interchangeable. OreSpawn never infers equivalence from similar block or +mod names. When two ordinary MMD providers for the same tag are loaded, a new +world starts Balanced with one placement budget per channel. An absent mod +adds no phantom output or placement rule, and old worlds keep their saved +choices. Enrichment and dimension-specific providers are kept separate. An ore-only provider does not need rocks, geomes, or terrain dimensions. Give each ore explicit host blocks or tags and OreSpawn will leave vanilla terrain, diff --git a/docs/README.md b/docs/README.md index 9fe8ad40..b3a64d9e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,16 @@ shaded or embeddable engine artifact. Choose the guide that matches what you are doing: +For a quick route through the documentation, 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. + +If you are integrating a mod, start with the developer guide; the API and +provider guides give the exact Java and JSON contracts. + +The focused guides below cover the supported integration paths. Use the +versioning guide when choosing a matching Minecraft and loader build. + - [Player and server guide](PLAYER_GUIDE.md) - [Developer quick start and complete integration map](DEVELOPER_GUIDE.md) - [Configuration field reference](CONFIGURATION.md) @@ -18,7 +28,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 cb706a02..9912a184 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -60,7 +60,7 @@ Examples: | 1.19.4 | Forge | `119041` | `4.0.10.119041` | | 1.20.1 | Forge | `120011` | `4.0.16.120011` | | 1.20.6 | Forge | `120061` | `4.0.16.120061` | -| 1.21.1 | Forge | `121011` | `4.0.16.121011` | +| 1.21.1 | Forge | `121011` | `4.1.0.121011` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | | 26.1.2 | Forge | `2601021` | `4.0.6.2601021` | | 26.2 | Forge | `2602001` | `4.0.6.2602001` | 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 40325a62..038a48c1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -44,9 +44,11 @@ mcp_version=20240808.132146 mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.16.121011 +mod_version=4.1.0.121011 mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team +# The project is maintained by Minecraft Mod Development. +mod_vendor=Minecraft Mod Development mod_description=Configurable, provider-driven terrain, ore, and deposit generation. loader_name=forge diff --git a/gradle/README.md b/gradle/README.md new file mode 100644 index 00000000..625a29a7 --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,50 @@ +# Build scripts + +The root `build.gradle` describes the Forge 1.21.1 target, Java toolchain, +dependencies, development runs and resources. Longer checks are split by +responsibility so a maintainer can find the relevant task without scanning +the entire build. + +| Script | What it owns | +| --- | --- | +| `verification/support.gradle` | Sealed fixtures, Forge run setup, Java launch helpers and runtime-log checks. | +| `verification/client-and-benchmark.gradle` | Isolated development-client and surface-generation probes. | +| `verification/packaged-forge.gradle` | Finished-jar checks in disposable official Forge installations. | +| `verification/ore-sources.gradle` | Test-only future MMD providers and missing-provider reloads. | +| `verification/performance.gradle` | Matched world-generation checks with optional sibling mods. | +| `verification/biome-override.gradle` | New-terrain biome replacement and no-retrogen probe. | +| `verification/native-ores.gradle` | Native Nether ore takeover, saved policies and wrapper cleanup. | +| `release/artifacts.gradle` | Release jars, bundled guides, artifact audits and checksums. | +| `release/publishing.gradle` | Maven coordinates, credentials and opt-in publication. | +| `ide/eclipse.gradle` | Generated launches, Buildship setup and production-classpath isolation. | + +An ordinary build does not publish anything. Before sharing a candidate, run +`check`, `build`, `javadoc`, `verifyReleaseArtifacts` and +`writeReleaseChecksums`. For Eclipse, also run `genEclipseRuns` and +`verifyEclipseProductionClasspath`. + +Packaged checks take `packagedForgeServerRuntime` and +`packagedForgeClientRuntime` properties pointing to disposable official Forge +installations. They are separate from `check`; the server command ends in +literal `nogui`. Run Gradle and the pinned compilation toolchain on Java 21. +For the development-client probe, pass `-PoreSpawnClientProbe=true` so its +world and settings stay under `build/client-userdev-run`. + +For manual shared-ore testing, use `runOreSourcesClient.launch` (also named +`runTestOreSourcesClient.launch`). Both inherit Forge 52's ordinary launcher +and add only the two simulated providers. Settings stay in `run-ore-sources`. +`verifyOreSourcesClientLaunch` checks their bootstrap arguments and classpath. +`oreSourcesClientIntegrationTest` checks the same setup automatically in a +separate, hidden, build-owned client. It does not change manual settings. + +`nativeOreIntegrationTest` covers fresh, reloaded and edited Nether worlds. +The cold-cache CI job compiles the real target online, then forces an offline +replay. Forge 52 needs no Mavenizer source repairs; its sealed fixture records +that explicit zero-rule result. + +World-hash comparisons can use `-PbenchmarkProcessorCount=2` to keep Minecraft +on one generation worker. Leave that property unset for ordinary performance +measurements, which use the machine's normal processor count. + +The client, surface, native-ore and future-provider fixtures are test-only mods. They must never appear in +an ordinary Eclipse launch, production classpath or release jar. diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..910c5ca2 --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,442 @@ +// Keep ordinary Eclipse launches on processed production resources only. +def shared = project.ext.oreSpawnBuild +def prepareEclipseResources = shared.prepareEclipseResources +def surfaceIntegrationClasses = shared.surfaceIntegrationClasses +def surfaceIntegrationRunDirectory = shared.surfaceIntegrationRunDirectory +task syncForge52EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { + group = 'ide' + dependsOn 'prepareOreSourcesClient' + doLast { + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String ordinaryModClasses = "${mod_id}%%${mainOutput}" + String fixtureOutput = surfaceIntegrationClasses.absolutePath + String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath + String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureResources}" + ['Client', 'Server', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + if (text.contains('')) { + text = text.replace( + '', + '\r\n' + + " \r\n" + + '') + } + if (!text.contains('\r?\n/, + { String environmentHeader -> + environmentHeader + + " \r\n" + }) + } + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + text = text.replace('', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') + } + ['Fresh', 'Reload'].each { String phase -> + File launch = file("runSurfaceIntegration${phase}.launch") + File serverLaunch = file('runServer.launch') + if (!serverLaunch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${serverLaunch}") + } + String text = serverLaunch.getText('UTF-8') + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + text = text.replaceFirst( + //, + { String match, String arguments -> + "" + }) + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + launch.setText(text, 'UTF-8') + } + // Inherit Forge 52's launcher, metadata, mappings, assets and JRE. + def parser = new groovy.xml.XmlParser(false, false) + parser.setTrimWhitespace(true) + def manual = parser.parse(file('runClient.launch')) + manual.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value = file('run-ore-sources').absolutePath + manual.mapAttribute.find { + it.@key == 'org.eclipse.debug.core.environmentVariables' + }.mapEntry.find { it.@key == 'MOD_CLASSES' }.@value = + shared.oreSourcesClientModClasses(file('bin/main')) + def classpath = manual.listAttribute.find { + it.@key == 'org.eclipse.jdt.launching.CLASSPATH' + } + shared.oreSourceFixtureOutputs.values().each { File output -> + def entry = new Node(null, 'runtimeClasspathEntry', [ + externalArchive: output.absolutePath, type: '2', path: '5']) + classpath.appendNode('listEntry', [value: groovy.xml.XmlUtil.serialize(entry)]) + } + String manualText = groovy.xml.XmlUtil.serialize(manual).replace('\r\n', '\n') + file('runOreSourcesClient.launch').setText(manualText, 'UTF-8') + file('runTestOreSourcesClient.launch').setText(manualText, 'UTF-8') + } +} + +task verifyForge52EclipseLaunchIsolation { + group = 'verification' + doLast { + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> + File launch = file(launchName) + String launchText = launch.getText('UTF-8') + if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || + !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") + } + } + } +} + +syncForge52EclipseLaunches.finalizedBy verifyForge52EclipseLaunchIsolation + +tasks.matching { it.name == 'genEclipseRuns' }.all { + finalizedBy syncForge52EclipseLaunches +} + +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { classpath -> + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + } + } + synchronizationTasks 'isolateEclipseProductionRuns' +} +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + [ + 'eclipse.preferences.version' : '1', + 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir' : '', + 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings' : 'true' + ].each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by OreSpawn Buildship configuration.') + } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn tasks.named('genEclipseRuns') + dependsOn syncForge52EclipseLaunches + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + doLast { + [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' + ].each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) + && !launch.delete()) { + throw new GradleException("Could not remove obsolete launch ${name}") + } + } + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + String contents = launch.getText('UTF-8') + contents = contents.replace( + 'key="MC_VERSION" value="${MC_VERSION}"', + "key=\"MC_VERSION\" value=\"${minecraft_version}\"") + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipseClasspath') + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties buildshipPreferences = new Properties() + prefs.withInputStream { buildshipPreferences.load(it) } + String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath + if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException( + "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") + } + Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration + .resolvedArtifacts + .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } + .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } + .toSet() + if (!legacyLwjglArtifacts.isEmpty()) { + throw new GradleException( + "Forge 1.21.1 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) { + throw new GradleException('Eclipse .classpath was not generated') + } + String eclipseClasspathText = eclipseClasspath.getText('UTF-8') + if (!eclipseClasspathText.contains('path="build/resources/main"') + || eclipseClasspathText.contains('path="src/main/resources"') + || eclipseClasspathText.contains('path="src/generated/resources"')) { + throw new GradleException( + 'Eclipse must consume only Gradle-processed production resources') + } + [ + 'META-INF/mods.toml', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File processedModMetadata = new File(sourceSets.main.output.resourcesDir, + 'META-INF/mods.toml') + File eclipseModMetadata = file('bin/main/META-INF/mods.toml') + if (!java.util.Arrays.equals(processedModMetadata.bytes, eclipseModMetadata.bytes) + || eclipseModMetadata.getText('UTF-8').contains('${version}') + || !eclipseModMetadata.getText('UTF-8').contains( + "version=\"${project.version}\"")) { + throw new GradleException( + 'Eclipse output contains unexpanded or stale Forge mod metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', 'benchmarkIntegrationTest', + 'surfaceprobe', 'clientprobe', 'benchmarkprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'C:\\Users\\John' + ] + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String expectedModClasses = "${mod_id}%%${mainOutput}" + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Missing ${name}") + String contents = launch.getText('UTF-8') + List leaked = forbidden.findAll { contents.contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${name} exposes test/local content: ${leaked}") + } + if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${name} is not a production-only OreSpawn launch") + } + if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { + throw new GradleException("${name} lacks Forge 52 merged output discovery") + } + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' + doLast { + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('gradle') { include '**/*.gradle' }.files) + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + String gradleSource = commandSources.findAll { it.name.endsWith('.gradle') } + .collect { it.getText('UTF-8') }.join('\n') + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') + || !gradleSource.contains('join(File.pathSeparator)') + || !gradleSource.contains('${File.pathSeparator}')) { + throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') + } + } +} + +tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } +tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } +// Eclipse copies resources again during a Java rebuild; test that copy too. +tasks.register('eclipseResourceRebuildTest') { + group = 'verification' + description = 'Checks an Eclipse rebuild retains expanded Forge metadata and bundled guides.' + dependsOn tasks.named('eclipseClasspath'), tasks.named('processResources') + doLast { + def xml = new groovy.xml.XmlParser(false, false).parse(file('.classpath')) + List resourceFolders = xml.classpathentry.findAll { entry -> + entry.@kind == 'src' && entry.@output == 'bin/main' + && entry.@path != 'src/main/java' + }.collect { it.@path.toString() } + if (resourceFolders != ['build/resources/main']) { + throw new GradleException("Eclipse must rebuild from processed resources only: ${resourceFolders}") + } + File rebuilt = file("${buildDir}/eclipse-resource-rebuild-test") + project.sync { resourceFolders.each { from file(it) }; into rebuilt } + File metadata = new File(rebuilt, 'META-INF/mods.toml') + File processed = file("${buildDir}/resources/main/META-INF/mods.toml") + if (!metadata.isFile() || metadata.getText('UTF-8').contains('${version}') + || !java.util.Arrays.equals(metadata.bytes, processed.bytes)) { + throw new GradleException('Eclipse rebuild copied unexpanded or stale Forge metadata') + } + ['README.md', 'VERSIONS.md'].each { name -> + if (!new File(rebuilt, "META-INF/orespawn/docs/${name}").isFile()) { + throw new GradleException("Eclipse rebuild lost the bundled guide ${name}") + } + } + } +} + +tasks.named('check') { + dependsOn 'eclipseResourceRebuildTest', 'oreSourcesClientLaunchContractTest' +} + +// Shared-ore runs reuse the ordinary launcher instead of guessing Forge's arguments. +shared.oreSourcesClientLaunch = { + def xml = new groovy.xml.XmlParser(false, false).parse(file('runOreSourcesClient.launch')) + def attribute = { String key -> xml.stringAttribute.find { it.@key == key }.@value.toString() } + Map environment = [:] + xml.mapAttribute.find { + it.@key == 'org.eclipse.debug.core.environmentVariables' + }.mapEntry.each { environment[it.@key.toString()] = it.@value.toString() } + List archives = [] + xml.listAttribute.find { + it.@key == 'org.eclipse.jdt.launching.CLASSPATH' + }.listEntry.each { entry -> + def runtime = new groovy.xml.XmlParser(false, false).parseText(entry.@value.toString()) + if (runtime.@externalArchive) archives.add(file(runtime.@externalArchive.toString())) + } + [mainClass: attribute('org.eclipse.jdt.launching.MAIN_TYPE'), + jvmArguments: org.apache.tools.ant.types.Commandline.translateCommandline( + attribute('org.eclipse.jdt.launching.VM_ARGUMENTS')) as List, + arguments: org.apache.tools.ant.types.Commandline.translateCommandline( + attribute('org.eclipse.jdt.launching.PROGRAM_ARGUMENTS')) as List, + environment: environment, archives: archives] +} + +def requiredClientArguments = [ + '--main': 'net.minecraftforge.bootstrap.ForgeBootstrap', + '--launchTarget': 'forge_userdev_client' +] +def verifyClientBootstrap = { Map launch -> + if (launch.mainClass != 'net.minecraftforge.launcher.Main' + || launch.environment.MCP_MAPPINGS != "${mapping_channel}_${mapping_version}".toString()) { + throw new GradleException('The client needs Forge 52 launcher and mapping settings') + } + requiredClientArguments.each { key, expected -> + int index = launch.arguments.indexOf(key) + if (index < 0 || index + 1 >= launch.arguments.size() + || launch.arguments[index + 1] != expected) { + throw new GradleException("The client needs ${key} ${expected}") + } + } + ['--metadata', '--cache', '--to-srg', '--to-obf'].each { key -> + int index = launch.arguments.indexOf(key) + if (index < 0 || index + 1 >= launch.arguments.size()) { + throw new GradleException("The client is missing ${key}") + } + File input = file(launch.arguments[index + 1]) + if (key == '--cache') { + if (input.name != "${minecraft_version}-${forge_version}".toString()) { + throw new GradleException('Shared client selected a different Forge cache') + } + } else if (!input.isFile()) { + throw new GradleException("Client input is missing: ${key} ${input}") + } + if (key == '--metadata' && input.name != "forge-${minecraft_version}-${forge_version}-metadata.zip".toString()) { + throw new GradleException('Shared client selected different target metadata') + } + } +} + +tasks.register('oreSourcesClientLaunchContractTest') { + group = 'verification' + description = 'Rejects missing or incorrect Forge 52 client bootstrap arguments.' + dependsOn tasks.named('isolateEclipseProductionRuns') + doLast { + Map valid = shared.oreSourcesClientLaunch() + verifyClientBootstrap(valid) + (requiredClientArguments.keySet() + ['--metadata', '--cache', '--to-srg', '--to-obf']).each { key -> + ['missing', 'incorrect'].each { scenario -> + Map invalid = new LinkedHashMap(valid) + invalid.arguments = new ArrayList(valid.arguments) + int index = invalid.arguments.indexOf(key) + if (scenario == 'missing') { + invalid.arguments.remove(index + 1) + invalid.arguments.remove(index) + } else invalid.arguments[index + 1] = 'incorrect' + boolean rejected = false + try { verifyClientBootstrap(invalid) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException("Client accepted ${scenario} ${key}") + } + } + } +} + +tasks.register('verifyOreSourcesClientLaunch') { + group = 'verification' + description = 'Checks the shared-ore client stays separate from production and automatic probes.' + dependsOn tasks.named('isolateEclipseProductionRuns') + doLast { + Map launch = shared.oreSourcesClientLaunch() + verifyClientBootstrap(launch) + ['runOreSourcesClient.launch', 'runTestOreSourcesClient.launch'].each { name -> + File source = file(name) + def xml = new groovy.xml.XmlParser(false, false).parse(source) + if (xml.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value.toString() != file('run-ore-sources').absolutePath + || launch.environment.MOD_CLASSES != shared.oreSourcesClientModClasses(file('bin/main'))) { + throw new GradleException('Shared client must use its own directory and merged fixture folders') + } + ['bin/test', 'src/test', 'junit-', 'clientprobe', 'surfaceprobe', + 'oresourceprobe.phase', 'clientprobe.enabled'].each { forbidden -> + if (source.getText('UTF-8').contains(forbidden)) { + throw new GradleException("Manual launch leaked ${forbidden}") + } + } + } + shared.oreSourceFixtureOutputs.values().each { File output -> + if (!new File(output, 'META-INF/mods.toml').isFile() || !launch.archives.contains(output)) { + throw new GradleException("Missing shared-ore fixture ${output}") + } + } + if (!java.util.Arrays.equals(file('runOreSourcesClient.launch').bytes, + file('runTestOreSourcesClient.launch').bytes)) { + throw new GradleException('Shared-ore launch aliases differ') + } + } +} diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..d94d48a5 --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,383 @@ +import groovy.json.JsonSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +// Audit exactly what a release jar would contain. +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 prepareEclipseResources = shared.prepareEclipseResources +tasks.named('jar', Jar) { + archiveClassifier = '' + destinationDirectory = layout.buildDirectory.dir('libs') + manifest { + attributes([ + 'Specification-Title' : 'OreSpawn', + 'Specification-Vendor' : project.mod_vendor, + 'Specification-Version' : '1', + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, + 'Implementation-Vendor' : project.mod_vendor, + 'OreSpawn-API-Version' : '1', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : expectedMavenCoordinate, + 'Built-On-Java' : '21', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}" + ]) + } +} + +def releaseJar = tasks.named('jar', Jar) + +tasks.named('sourcesJar', Jar) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-sources', + 'Implementation-Version': project.version + ]) + } +} +tasks.named('javadocJar', Jar) { + filteringCharset = 'UTF-8' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-javadoc', + 'Implementation-Version': project.version + ]) + } +} +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { artifacts.clear() } + artifacts { add(configurationName, releaseJar) } +} +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.1.0.121011' + || project.mod_group != expectedMavenGroup + || project.minecraft_version != '1.21.1' + || project.forge_version != '52.1.0' + || project.mapping_channel != 'official' + || project.mapping_version != '1.21.1') { + throw new GradleException('Unexpected OreSpawn 1.21.1 release identity') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '21' || project.gradle_java_version != '21' + || project.java_toolchain_version != '21.0.7+6') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.1.0.121011.jar', + 'OreSpawn-4.1.0.121011-sources.jar', + 'OreSpawn-4.1.0.121011-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.1.0.121011')) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') + .getText('UTF-8').contains('API_VERSION = 1')) { + throw new GradleException('OreSpawn API major must remain 1') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 8') + || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 7')) { + throw new GradleException('Global/world schemas must be 8/7') + } + def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(schema.properties.schema_version.enum as List).contains(5)) { + throw new GradleException('Provider schema must be version 5') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') } + .sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes + byte[] candidate = new File(root, relative).bytes + if (!java.util.Arrays.equals(tracked, candidate)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 20 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 20 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, + expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), + expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().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') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException( + "${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'src/benchmarkIntegrationTest/', 'src/nativeOreIntegrationTest/', + 'src/oreSourceMineralsTest/', 'src/oreSourceAdvantageTest/', + 'src/worldHashTest/', 'nativeoreprobe', 'oresourceprobe', + 'worldhashprobe', '.launch', 'agent-notes/', 'surfaceprobe', + 'clientprobe', 'benchmarkprobe', 'ci-fixtures/', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[1.21.1,1.22)"')) { + throw new GradleException('Packaged Forge metadata is incorrect') + } + String transformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator 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' + ] + 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('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") + } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 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') + inputs.files(expectedReleaseFiles.map { names -> + names.collect { layout.buildDirectory.file("libs/${it}").get().asFile } + }) + outputs.file(outputFile) + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required') + } + File prepared = file(preparedReleaseDir.get()) + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List actualNames = jars.collect { it.name.toString() }.sort() + if (actualNames != expected) { + throw new GradleException( + "Prepared release jars ${actualNames} do not match ${expected}") + } + if (jars.any { it.length() == 0L }) { + throw new GradleException('Prepared release contains an empty jar') + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + List actual = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" + }.sort() + if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { + throw new GradleException('Prepared release checksums do not match') + } + } +} + + +shared.releaseJar = releaseJar +shared.expectedReleaseFiles = expectedReleaseFiles +shared.preparedReleaseDir = preparedReleaseDir +shared.documentationFiles = documentationFiles +shared.assertDocumentationTree = assertDocumentationTree diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..7494d5cf --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,93 @@ +import groovy.xml.XmlSlurper + +// Publishing is opt-in; normal builds still check the public 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 { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} +tasks.register('verifyMavenCoordinates') { + group = 'verification' + description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + if (!pomFile.isFile()) { + throw new GradleException("Generated Maven POM does not exist: ${pomFile}") + } + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, + project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + + "found ${actual.join(':')}") + } + } +} +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} diff --git a/gradle/verification/biome-override.gradle b/gradle/verification/biome-override.gradle new file mode 100644 index 00000000..769accc2 --- /dev/null +++ b/gradle/verification/biome-override.gradle @@ -0,0 +1,110 @@ +// A disposable world proves that a saved replacement affects new chunks only. +// The probe mod is shared with the benchmark and never joins a release jar. +def biomeOverrideShared = project.ext.oreSpawnBuild +def biomeOverrideRuntime = providers.gradleProperty('packagedForgeServerRuntime') +File biomeOverrideRun = file("${buildDir}/biome-override-run") +File biomeOverrideWorld = new File(biomeOverrideRun, 'biome-override-world') +File biomeOverrideMarker = new File(biomeOverrideWorld, 'biomeoverrideprobe.properties') + +def prepareBiomeOverride = tasks.register('prepareBiomeOverrideProbe') { + dependsOn biomeOverrideShared.releaseJar, tasks.named('worldHashProbeJar') + doLast { + delete biomeOverrideRun + biomeOverrideRun.mkdirs() + if (!biomeOverrideRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + biomeOverrideShared.stageForge52Runtime(file(biomeOverrideRuntime.get()), biomeOverrideRun) + biomeOverrideShared.prepareTestForgeConfig(biomeOverrideRun) + copy { + from biomeOverrideShared.releaseJar + from tasks.named('worldHashProbeJar') + into new File(biomeOverrideRun, 'mods') + } + new File(biomeOverrideRun, 'server.properties').setText('''\ +level-name=biome-override-world +level-seed=49110 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(biomeOverrideRun, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def biomeOverrideFresh = tasks.register('biomeOverrideFresh', Exec) { + group = 'verification' + dependsOn prepareBiomeOverride + doFirst { + if (!biomeOverrideRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir biomeOverrideRun + commandLine biomeOverrideShared.forge52ServerCommand(file(biomeOverrideRuntime.get()), + biomeOverrideRun, ['-Dorespawn.biomeOverridePhase=fresh']) + } + doLast { + if (!biomeOverrideMarker.isFile()) { + throw new GradleException('Fresh biome override marker is missing') + } + biomeOverrideShared.assertRuntimeLogsClean(biomeOverrideRun, 'biome override fresh', [] as Set) + } +} + +def writeBiomeOverride = tasks.register('writeBiomeOverride') { + dependsOn biomeOverrideFresh + doLast { + Properties marker = new Properties() + biomeOverrideMarker.withInputStream { marker.load(it) } + String source = marker.getProperty('source') + if (source == null || !source.contains(':')) { + throw new GradleException("Missing source biome in ${biomeOverrideMarker}") + } + String target = source == 'minecraft:desert' ? 'minecraft:plains' : 'minecraft:desert' + File profileFile = new File(biomeOverrideWorld, 'serverconfig/orespawn-worldgen.json') + if (!profileFile.isFile()) throw new GradleException("Missing world profile ${profileFile}") + Map profile = (Map) new groovy.json.JsonSlurper().parse(profileFile) + Map palettes = profile.biome_palettes instanceof Map ? (Map) profile.biome_palettes + : new LinkedHashMap() + profile.biome_palettes = palettes + palettes['orespawn:ui/biome_overrides/minecraft_overworld'] = [ + dimension: 'minecraft:overworld', enabled: true, mode: 'replace', + scope: 'all', region_size: 'tiny', coverage: 1.0D, + fallback_weight: 0.0D, + biomes: [(target): [enabled: true, weight: 1.0D, + similar_biomes: [source]]] + ] + profileFile.setText(groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson(profile)) + '\n', 'UTF-8') + marker.setProperty('target', target) + biomeOverrideMarker.withOutputStream { marker.store(it, 'Disposable biome override probe') } + } +} + +def biomeOverrideReload = tasks.register('biomeOverrideReload', Exec) { + group = 'verification' + dependsOn writeBiomeOverride + doFirst { + workingDir biomeOverrideRun + commandLine biomeOverrideShared.forge52ServerCommand(file(biomeOverrideRuntime.get()), + biomeOverrideRun, ['-Dorespawn.biomeOverridePhase=reload']) + } + doLast { + Properties marker = new Properties() + biomeOverrideMarker.withInputStream { marker.load(it) } + if (marker.getProperty('reload_verified') != 'true') { + throw new GradleException('Biome override did not pass new-terrain and no-retrogen checks') + } + biomeOverrideShared.assertRuntimeLogsClean(biomeOverrideRun, 'biome override reload', [] as Set) + } +} + +tasks.register('biomeOverrideIntegrationTest') { + group = 'verification' + description = 'Proves an exact replacement changes new chunks without rewriting saved chunks.' + dependsOn biomeOverrideReload +} diff --git a/gradle/verification/client-and-benchmark.gradle b/gradle/verification/client-and-benchmark.gradle new file mode 100644 index 00000000..14922833 --- /dev/null +++ b/gradle/verification/client-and-benchmark.gradle @@ -0,0 +1,198 @@ +// Test-only clients and surface worlds stay outside the production source set. +def shared = project.ext.oreSpawnBuild +def assertRuntimeLogsClean = shared.assertRuntimeLogsClean +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + options.encoding = 'UTF-8' +} +tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn tasks.named('compileClientIntegrationTestMod') + archiveFileName = 'clientprobe.jar' + destinationDirectory = file("${buildDir}/client-integration-fixture") + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} +def packagedClientProbeJar = tasks.named('clientIntegrationTestModJar', Jar) + +// The opt-in client probe gets a disposable game directory. Ordinary runClient +// keeps the user's development directory when this property is absent. +if (providers.gradleProperty('oreSpawnClientProbe').isPresent()) { + File userdevClientRun = file("${buildDir}/client-userdev-run") + minecraft.runs.named('client') { + workingDir.set(layout.buildDirectory.dir('client-userdev-run')) + } + def prepareUserdevClient = tasks.register('prepareClientIntegrationRun') { + dependsOn tasks.named('clientIntegrationTestModJar') + doLast { + delete userdevClientRun + userdevClientRun.mkdirs() + shared.prepareTestForgeConfig(userdevClientRun) + copy { + from tasks.named('clientIntegrationTestModJar', Jar).get().archiveFile + into new File(userdevClientRun, 'mods') + } + new File(userdevClientRun, 'options.txt').setText( + 'fullscreen:false\nlang:en_us\n', 'UTF-8') + } + } + tasks.matching { it.name == 'runClient' }.configureEach { JavaExec run -> + dependsOn prepareUserdevClient + systemProperty 'clientprobe.enabled', 'true' + systemProperty 'clientprobe.hidden', 'true' + doFirst { + workingDir = userdevClientRun + File configuredRun = minecraft.runs.named('client').get().workingDir.get().asFile + if (configuredRun.canonicalFile != userdevClientRun.canonicalFile + || workingDir.canonicalFile != userdevClientRun.canonicalFile) { + throw new GradleException('Userdev client would leave its disposable directory') + } + } + } + tasks.register('clientIntegrationProcess') { + group = 'verification' + description = 'Runs the GUI and world reload probe in an isolated Forge userdev client.' + dependsOn 'runClient' + doLast { + File marker = new File(userdevClientRun, 'client-smoke-pass.properties') + if (!marker.isFile()) throw new GradleException('Userdev client did not finish its probe') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'main_menu_layout', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Userdev client failed ${key}: ${values}") + } + } + int creationBiomes = Integer.parseInt(values.getProperty('creation_biome_count', '0')) + int loadedBiomes = Integer.parseInt(values.getProperty('loaded_biome_count', '0')) + if (creationBiomes == 0 || creationBiomes != loadedBiomes) { + throw new GradleException("Userdev biome directory missed loaded biomes: ${values}") + } + assertRuntimeLogsClean(userdevClientRun, 'userdev client', [] as Set) + } + } +} + +def benchmarkIntegrationClasses = file("${buildDir}/benchmark-integration-fixture/classes") +tasks.register('compileBenchmarkIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/benchmarkIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = benchmarkIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + options.encoding = 'UTF-8' +} +tasks.register('benchmarkIntegrationTestModJar', Jar) { + dependsOn tasks.named('compileBenchmarkIntegrationTestMod') + archiveFileName = 'benchmarkprobe.jar' + destinationDirectory = file("${buildDir}/benchmark-integration-fixture") + from benchmarkIntegrationClasses + from 'src/benchmarkIntegrationTest/resources' +} + +def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") +task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/biomeIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = surfaceIntegrationClasses + sourceCompatibility = '21' + targetCompatibility = '21' + options.encoding = 'UTF-8' +} + +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' +} + +def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") +task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { + doLast { + delete surfaceIntegrationRunDirectory + surfaceIntegrationRunDirectory.mkdirs() + shared.prepareTestForgeConfig(surfaceIntegrationRunDirectory) + copy { + from surfaceIntegrationTestModJar.archiveFile + into new File(surfaceIntegrationRunDirectory, 'mods') + } + new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def surfaceIntegrationFreshProcess = tasks.register('surfaceIntegrationFreshProcess') { + group = 'verification' + dependsOn 'runSurfaceIntegrationFresh' +} +tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { + dependsOn prepareSurfaceIntegrationTest +} +tasks.matching { it.name == 'runSurfaceIntegrationReload' }.all { + mustRunAfter surfaceIntegrationFreshProcess +} +surfaceIntegrationFreshProcess.configure { + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") + } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration fresh phase', [] as Set) + } +} +def surfaceIntegrationReloadProcess = tasks.register('surfaceIntegrationReloadProcess') { + group = 'verification' + dependsOn surfaceIntegrationFreshProcess + dependsOn 'runSurfaceIntegrationReload' +} +surfaceIntegrationReloadProcess.configure { + doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration reload phase', [] as Set) + } +} + +task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { + group = 'verification' + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("Surface integration reload was not verified: ${marker}") + } + logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +check.dependsOn surfaceIntegrationTest + +def packagedSurfaceProbeJar = tasks.named('surfaceIntegrationTestModJar', Jar) + + +shared.surfaceIntegrationClasses = surfaceIntegrationClasses +shared.surfaceIntegrationRunDirectory = surfaceIntegrationRunDirectory +shared.packagedClientProbeJar = packagedClientProbeJar +shared.packagedSurfaceProbeJar = packagedSurfaceProbeJar diff --git a/gradle/verification/native-ores.gradle b/gradle/verification/native-ores.gradle new file mode 100644 index 00000000..4e85da9f --- /dev/null +++ b/gradle/verification/native-ores.gradle @@ -0,0 +1,118 @@ +// 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('packagedForgeServerRuntime') +def comparisonJar = providers.gradleProperty('nativeOreSpawnJar') +def finalTasks = [] +['Default', 'Explicit'].each { scenario -> + File runDirectory = file("${buildDir}/native-ore-${scenario.toLowerCase()}-run") + def prepare = tasks.register("prepareNativeOre${scenario}") { + dependsOn shared.releaseJar, packagedJar + doLast { + // This directory is build-owned; no manual saves are used here. + delete runDirectory + runDirectory.mkdirs() + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + shared.stageForge52Runtime(file(serverRuntime.get()), runDirectory) + shared.prepareTestForgeConfig(runDirectory) + copy { + from comparisonJar.isPresent() ? file(comparisonJar.get()) : shared.releaseJar + from packagedJar + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText('''\ +level-name=native-ore-world +level-seed=4192643144957839458 +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + } + } + def previous = prepare + ['Fresh', 'Reload', 'Edited'].each { phase -> + def dependency = previous + previous = tasks.register("nativeOre${scenario}${phase}", Exec) { + group = 'verification' + description = "Checks managed quartz with ${scenario.toLowerCase()} hosts (${phase.toLowerCase()})." + dependsOn dependency + doFirst { + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir runDirectory + // One generation worker makes independent host-form runs comparable. + // Ordinary runtime and performance checks keep their normal worker count. + commandLine shared.forge52ServerCommand(file(serverRuntime.get()), runDirectory, + ['-XX:ActiveProcessorCount=2', + "-Dnativeoreprobe.phase=${phase.toLowerCase()}", + "-Dnativeoreprobe.host=${scenario.toLowerCase()}"]) + } + doLast { + Properties result = new Properties() + File marker = new File(runDirectory, 'native-ore-world/nativeoreprobe.properties') + if (!marker.isFile()) throw new GradleException("Quartz audit is missing: ${marker}") + marker.withInputStream { result.load(it) } + if (result.getProperty("${phase.toLowerCase()}_verified") != 'true') { + throw new GradleException("Quartz ${phase.toLowerCase()} was not verified") + } + shared.assertRuntimeLogsClean(runDirectory, "Native quartz ${scenario} ${phase}", [] as Set) + if (new File(runDirectory, 'logs/latest.log').getText('UTF-8') + .contains("Ignoring invalid placement rule for OreSpawn-managed ore 'minecraft:nether_quartz_ore'")) { + throw new GradleException('Managed quartz was rejected during startup') + } + copy { from new File(runDirectory, 'logs/latest.log'); into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.log" } } + copy { from marker; into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.properties" } } + } + } + } + finalTasks.add(previous) +} +tasks.register('nativeOreIntegrationTest') { + group = 'verification' + description = 'Checks quartz defaults, explicit hosts, exact-save reload and new-terrain-only edits.' + dependsOn finalTasks + doLast { + ['fresh', 'reload', 'edited'].each { phase -> + Properties tagged = new Properties() + Properties explicit = new Properties() + file("${buildDir}/native-ore-default-run/audit-logs/${phase}.properties") + .withInputStream { tagged.load(it) } + file("${buildDir}/native-ore-explicit-run/audit-logs/${phase}.properties") + .withInputStream { explicit.load(it) } + ['quartz', 'blocks_sha256', 'biomes_sha256', 'new_terrain_quartz', 'water', 'new_terrain_water'].each { key -> + if (tagged.getProperty(key) != explicit.getProperty(key)) { + throw new GradleException("Tag and explicit quartz hosts differ for ${phase}: ${key}") + } + } + } + logger.lifecycle('Tag and explicit quartz hosts produce identical terrain, fresh/reload/edited') + } +} diff --git a/gradle/verification/ore-sources.gradle b/gradle/verification/ore-sources.gradle new file mode 100644 index 00000000..7763a948 --- /dev/null +++ b/gradle/verification/ore-sources.gradle @@ -0,0 +1,258 @@ +// These tiny mods represent providers that have not yet been ported to this +// Minecraft version. They live outside sourceSets.main and ship in neither +// OreSpawn nor its published API jar. +def shared = project.ext.oreSpawnBuild +def fixtureClasses = [:] +def fixtureJars = [:] +def fixtureOutputs = [:] +def prepareFixtureOutputs = [] +['Minerals': 'oreSourceMineralsTest', 'Advantage': 'oreSourceAdvantageTest'].each { label, folder -> + File classes = file("${buildDir}/ore-source-fixture/${label.toLowerCase()}-classes") + def compile = tasks.register("compileOreSource${label}Fixture", JavaCompile) { + dependsOn tasks.named('classes') + source fileTree("src/${folder}/java") + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = classes + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + options.encoding = 'UTF-8' + } + def jar = tasks.register("oreSource${label}FixtureJar", Jar) { + dependsOn compile + archiveFileName = "oresource-${label.toLowerCase()}.jar" + destinationDirectory = file("${buildDir}/ore-source-fixture") + from classes + from "src/${folder}/resources" + } + fixtureClasses[label] = classes + File clientOutput = file("${buildDir}/ore-source-fixture/${label.toLowerCase()}-mod") + prepareFixtureOutputs.add(tasks.register("prepareOreSource${label}Client", Sync) { + dependsOn compile + from classes + from "src/${folder}/resources" + into clientOutput + }) + fixtureOutputs[label] = clientOutput + fixtureJars[label] = jar +} + +// Only this named profile loads the dummy providers; it keeps manual saves. +File clientDirectory = file('run-ore-sources') +def clientModClasses = { File mainOutput -> + List entries = ["${mod_id}%%${mainOutput}"] + ['Minerals': 'baseminerals', 'Advantage': 'electricadvantage'].each { label, id -> + entries.add("${id}%%${fixtureOutputs[label]}") + } + entries.join(File.pathSeparator) +} +// Forge 52 discovers a mod from one merged folder, not split compiler outputs. +def clientClasspath = { Map launch, File probe = null -> + Set splitOutputs = sourceSets.main.output.files.collect { + it.canonicalFile.absolutePath + } as Set + List libraries = sourceSets.main.runtimeClasspath.files.findAll { + !splitOutputs.contains(it.canonicalFile.absolutePath) + } as List + files(libraries, launch.archives, file('bin/main'), probe == null ? [] : [probe]) +} +shared.oreSourceFixtureOutputs = fixtureOutputs +shared.oreSourcesClientModClasses = clientModClasses + +tasks.register('prepareOreSourcesClient') { + group = 'ide' + description = 'Prepares two dummy ore providers without clearing manual settings or saves.' + dependsOn prepareFixtureOutputs, tasks.named('classes') + doLast { + new File(clientDirectory, 'mods').mkdirs() + copy { from sourceSets.main.output; into file('bin/main') } + } +} + +tasks.register('runOreSourcesClient', JavaExec) { + group = 'forgegradle runs' + description = 'Opens a separate client with simulated Base Minerals and Electric Advantage ores.' + dependsOn tasks.named('verifyOreSourcesClientLaunch') + javaLauncher = shared.java21Launcher + doFirst { + def launch = shared.oreSourcesClientLaunch() + mainClass = launch.mainClass + args = launch.arguments + jvmArgs = launch.jvmArguments + classpath = clientClasspath(launch) + environment launch.environment + workingDir clientDirectory + File crashes = new File(clientDirectory, 'crash-reports') + ext.priorCrashes = crashes.isDirectory() + ? fileTree(crashes).files.collect { it.absolutePath } as Set : [] as Set + } + doLast { + shared.assertRuntimeLogsClean(clientDirectory, 'ore sources manual client', ext.priorCrashes) + } +} +tasks.register('runTestOreSourcesClient') { + group = 'forgegradle runs' + description = 'Alias for the manual shared-ore client.' + dependsOn tasks.named('runOreSourcesClient') +} + +def clientProbeOutput = file("${buildDir}/ore-source-fixture/client-probe-mod") +def prepareClientProbe = tasks.register('prepareOreSourcesClientProbe', Sync) { + dependsOn tasks.named('compileClientIntegrationTestMod') + from file("${buildDir}/client-integration-fixture/classes") + from 'src/clientIntegrationTest/resources' + into clientProbeOutput +} +tasks.register('oreSourcesClientIntegrationTest', Exec) { + group = 'verification' + description = 'Checks the shared-ore launch and fresh/reloaded rendered worlds.' + dependsOn 'verifyOreSourcesClientLaunch', prepareClientProbe + File directory = file("${buildDir}/ore-sources-client-integration-run") + doFirst { + delete directory + directory.mkdirs() + shared.prepareTestForgeConfig(directory) + def launch = shared.oreSourcesClientLaunch() + workingDir directory + environment launch.environment + environment 'MOD_CLASSES', clientModClasses(file('bin/main')) + + "${File.pathSeparator}clientprobe%%${clientProbeOutput}" + List arguments = new ArrayList<>(launch.jvmArguments) + arguments.addAll(['-Xmx2g', '-Dclientprobe.enabled=true', '-Dclientprobe.hidden=true', + '-Dclientprobe.oreSources=true', '-cp', + clientClasspath(launch, clientProbeOutput).files + .collect { it.absolutePath }.join(File.pathSeparator), launch.mainClass]) + arguments.addAll(launch.arguments) + commandLine(([shared.java21Launcher.get().executablePath.asFile.absolutePath] + arguments) as List) + } + doLast { + Properties result = new Properties() + File marker = new File(directory, 'client-smoke-pass.properties') + if (!marker.isFile()) throw new GradleException('Shared-ore client did not complete') + marker.withInputStream { result.load(it) } + ['world_settings_opened', 'main_menu_layout', 'long_editor_roundtrip', 'first_world_rendered', 'reload_rendered'].each { + if (result.getProperty(it) != 'true') throw new GradleException("Shared-ore client failed ${it}") + } + shared.assertRuntimeLogsClean(directory, 'shared-ore development client', [] as Set) + } +} + +def serverRuntime = providers.gradleProperty('packagedForgeServerRuntime') +['One': ['Minerals'], 'Both': ['Minerals', 'Advantage']].each { scenario, labels -> + File runDirectory = file("${buildDir}/ore-source-${scenario.toLowerCase()}-run") + def prepare = tasks.register("prepareOreSource${scenario}") { + dependsOn shared.releaseJar + labels.each { dependsOn fixtureJars[it] } + doLast { + delete runDirectory + runDirectory.mkdirs() + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + shared.stageForge52Runtime(file(serverRuntime.get()), runDirectory) + shared.prepareTestForgeConfig(runDirectory) + copy { + from shared.releaseJar + labels.each { from fixtureJars[it] } + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=ore-source-world +level-seed=49110 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + def fresh = tasks.register("oreSource${scenario}Fresh", Exec) { + group = 'verification' + dependsOn prepare + doFirst { + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir runDirectory + commandLine shared.forge52ServerCommand(file(serverRuntime.get()), runDirectory, + ['-Doresourceprobe.phase=fresh']) + } + doLast { + File marker = new File(runDirectory, 'ore-source-world/oresourceprobe.properties') + if (!marker.isFile()) throw new GradleException("Fresh ore-source marker is missing: ${marker}") + shared.assertRuntimeLogsClean(runDirectory, "${scenario} ore-source fresh", [] as Set) + } + } + def reload = tasks.register("oreSource${scenario}Reload", Exec) { + group = 'verification' + dependsOn fresh + doFirst { + workingDir runDirectory + commandLine shared.forge52ServerCommand(file(serverRuntime.get()), runDirectory, + ['-Doresourceprobe.phase=reload']) + } + doLast { + File marker = new File(runDirectory, 'ore-source-world/oresourceprobe.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("${scenario} ore-source reload did not verify") + } + shared.assertRuntimeLogsClean(runDirectory, "${scenario} ore-source reload", [] as Set) + } + } + tasks.register("oreSource${scenario}IntegrationTest") { + group = 'verification' + dependsOn reload + } +} + +File bothRun = file("${buildDir}/ore-source-both-run") +File advantageJar = new File(bothRun, 'mods/oresource-advantage.jar') +File parkedJar = new File(bothRun, 'oresource-advantage.jar') +def missing = tasks.register('oreSourceBothMissing', Exec) { + group = 'verification' + dependsOn tasks.named('oreSourceBothReload') + doFirst { + java.nio.file.Files.move(advantageJar.toPath(), parkedJar.toPath()) + workingDir bothRun + commandLine shared.forge52ServerCommand(file(serverRuntime.get()), bothRun, + ['-Doresourceprobe.phase=missing']) + } + doLast { + Properties result = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { result.load(it) } + if (result.getProperty('missing_verified') != 'true') { + throw new GradleException('Missing-provider reload did not verify') + } + shared.assertRuntimeLogsClean(bothRun, 'Both ore-source provider missing', [] as Set, + ['This world was saved with mod electricadvantage which appears to be missing']) + } +} +tasks.register('oreSourceBothRestored', Exec) { + group = 'verification' + dependsOn missing + doFirst { + java.nio.file.Files.move(parkedJar.toPath(), advantageJar.toPath()) + workingDir bothRun + commandLine shared.forge52ServerCommand(file(serverRuntime.get()), bothRun, + ['-Doresourceprobe.phase=restored']) + } + doLast { + Properties result = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { result.load(it) } + if (result.getProperty('restored_verified') != 'true') { + throw new GradleException('Restored-provider reload did not verify') + } + shared.assertRuntimeLogsClean(bothRun, 'Both ore-source provider restored', [] as Set) + } +} + +tasks.register('oreSourceFixtureIntegrationTest') { + group = 'verification' + description = 'Checks one and two future MMD providers, then removes and restores one on the same save.' + dependsOn tasks.named('oreSourceOneIntegrationTest'), tasks.named('oreSourceBothRestored') +} diff --git a/gradle/verification/packaged-forge.gradle b/gradle/verification/packaged-forge.gradle new file mode 100644 index 00000000..f03dee6b --- /dev/null +++ b/gradle/verification/packaged-forge.gradle @@ -0,0 +1,309 @@ +// Launch the finished jar in separate, disposable official Forge runtimes. +def shared = project.ext.oreSpawnBuild +def java21Launcher = shared.java21Launcher +def assertRuntimeLogsClean = shared.assertRuntimeLogsClean +def releaseJar = shared.releaseJar +def packagedSurfaceProbeJar = shared.packagedSurfaceProbeJar +def packagedClientProbeJar = shared.packagedClientProbeJar +def documentationFiles = shared.documentationFiles +def assertDocumentationTree = shared.assertDocumentationTree +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') +def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') + +def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> + if (!configuredPath.isPresent()) { + throw new GradleException("Pass -P${propertyName}=") + } + File runtime = file(configuredPath.get()) + if (!runtime.isDirectory()) { + throw new GradleException("${propertyName} does not name a directory: ${runtime}") + } + runtime +} + +def stagePackagedServerLibraries = { File runtime, File runDirectory -> + File sourceLibraries = new File(runtime, 'libraries') + if (!sourceLibraries.isDirectory()) { + throw new GradleException("Official server runtime has no libraries directory: ${sourceLibraries}") + } + File targetLibraries = new File(runDirectory, 'libraries') + sourceLibraries.eachFileRecurse(groovy.io.FileType.FILES) { File source -> + java.nio.file.Path relative = sourceLibraries.toPath().relativize(source.toPath()) + File target = targetLibraries.toPath().resolve(relative).toFile() + target.parentFile.mkdirs() + try { + java.nio.file.Files.createLink(target.toPath(), source.toPath()) + } catch (IOException | UnsupportedOperationException | SecurityException ignored) { + java.nio.file.Files.copy(source.toPath(), target.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } +} + +def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") +tasks.register('preparePackagedSurfaceIntegration') { + dependsOn releaseJar + dependsOn packagedSurfaceProbeJar + doLast { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + delete packagedSurfaceRunDirectory + packagedSurfaceRunDirectory.mkdirs() + shared.prepareTestForgeConfig(packagedSurfaceRunDirectory) + stagePackagedServerLibraries(runtime, packagedSurfaceRunDirectory) + copy { + from new File(runtime, "forge-${minecraft_version}-${forge_version}-shim.jar") + into packagedSurfaceRunDirectory + } + copy { + from releaseJar + from packagedSurfaceProbeJar + into new File(packagedSurfaceRunDirectory, 'mods') + } + new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} +def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedSurfaceIntegration') + doFirst { + File forgeDirectory = new File(packagedSurfaceRunDirectory, + 'libraries/net/minecraftforge/forge/1.21.1-52.1.0') + File launcher = new File(forgeDirectory, 'forge-1.21.1-52.1.0-server.jar') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + [launcher, sourceArguments].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + workingDir packagedSurfaceRunDirectory + commandLine java21Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', + "@libraries/net/minecraftforge/forge/1.21.1-52.1.0/${sourceArguments.name}", + 'nogui' + } + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface fresh', [] as Set) + } +} +def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { + group = 'verification' + dependsOn packagedSurfaceFresh + doFirst { + File forgeDirectory = new File(packagedSurfaceRunDirectory, + 'libraries/net/minecraftforge/forge/1.21.1-52.1.0') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + workingDir packagedSurfaceRunDirectory + commandLine java21Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', + "@libraries/net/minecraftforge/forge/1.21.1-52.1.0/${sourceArguments.name}", + 'nogui' + } + doLast { + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface reload', [] as Set) + } +} +tasks.register('packagedSurfaceIntegrationTest') { + group = 'verification' + dependsOn packagedSurfaceReload + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty('reload_verified') != 'true') { + throw new GradleException('Packaged surface reload was not verified') + } + } +} + +def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") +tasks.register('preparePackagedClientIntegration') { + dependsOn releaseJar + dependsOn packagedClientProbeJar + doLast { + delete packagedClientRunDirectory + packagedClientRunDirectory.mkdirs() + shared.prepareTestForgeConfig(packagedClientRunDirectory) + copy { + from releaseJar + from packagedClientProbeJar + into new File(packagedClientRunDirectory, 'mods') + } + new File(packagedClientRunDirectory, 'options.txt').setText( + 'fullscreen:false\nlang:en_us\n', 'UTF-8') + File configDirectory = new File(packagedClientRunDirectory, 'config') + configDirectory.mkdirs() + new File(configDirectory, 'orespawn-common.toml').setText('''\ +# Bootstrap fallbacks. Detailed settings live in orespawn-worldgen.json. +[worldgen] + # Master switch for configured terrain replacement. + place_terrain = true + # Allowed Values: GEOME, LEGACY + fallback_geology_mode = "GEOME" + # Range: 4 ~ 32767 + cyano_region_size = 256 + # Range: 1.0 ~ 32767.0 + cyano_layer_reach = 32.0 + # Range: 1 ~ 255 + cyano_layer_thickness = 8 +''', 'UTF-8') + } +} +def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedClientIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, + 'packagedForgeClientRuntime') + String forgeVersionId = ['1.21.1-forge-52.1.0', 'forge-52.1.0'].find { candidate -> + new File(runtime, "versions/${candidate}/${candidate}.json").isFile() + } + if (forgeVersionId == null) { + throw new GradleException('Official client runtime has no Forge 52.1.0 profile') + } + File forgeJsonFile = new File(runtime, + "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.21.1/1.21.1.json') + File baseJar = new File(runtime, 'versions/1.21.1/1.21.1.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } + } + if (!allowed) return + String relative = (String) ((Map) ((Map) library.downloads).artifact).path + File artifact = new File(runtime, "libraries/${relative}") + if (!artifact.isFile()) { + throw new GradleException("Missing official client library: ${artifact}") + } + List coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + if (coordinates.size() >= 4) { + // Since 1.19 Mojang lists native classifiers as separate libraries. + // Keep each classifier alongside its base module instead of replacing it. + module += ":${coordinates.subList(3, coordinates.size()).join(':')}" + } + classpathByModule.put(module, artifact) + } + } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + + String libraryDirectory = new File(runtime, 'libraries').absolutePath + List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) + .collect { String argument -> + argument.replace('${library_directory}', libraryDirectory) + .replace('${classpath_separator}', File.pathSeparator) + .replace('${version_name}', (String) forgeJson.inheritsFrom) + } + + List gameArguments = [] + gameArguments.addAll((List) ((Map) forgeJson.arguments).game) + gameArguments.addAll([ + '--username', 'OSValidation', + '--version', (String) forgeJson.id, + '--gameDir', packagedClientRunDirectory.absolutePath, + '--assetsDir', new File(runtime, 'assets').absolutePath, + '--assetIndex', (String) ((Map) baseJson.assetIndex).id, + '--uuid', '00000000-0000-0000-0000-000000000001', + '--accessToken', 'validation-token', + '--userType', 'legacy', + '--versionType', 'release', + '--width', '854', '--height', '480' + ]) + workingDir packagedClientRunDirectory + File clientJava = packagedClientJavaExecutable.isPresent() + ? file(packagedClientJavaExecutable.get()) + : java21Launcher.get().executablePath.asFile + if (!clientJava.isFile()) { + throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") + } + List clientCommand = [ + clientJava.absolutePath, + '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', '-Dclientprobe.hidden=true', + "-Djava.library.path=${nativesDirectory.absolutePath}", + '-Dminecraft.launcher.brand=orespawn-validation', + '-Dminecraft.launcher.version=1' + ] + clientCommand.addAll(forgeJvmArguments) + clientCommand.addAll([ + '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass + ]) + clientCommand.addAll(gameArguments) + commandLine(clientCommand) + } +} +tasks.register('packagedClientIntegrationTest') { + group = 'verification' + dependsOn packagedClientProcess + doLast { + File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException('Packaged client completion marker is missing') + } + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'main_menu_layout', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Packaged client failed ${key}: ${values}") + } + } + int creationBiomes = Integer.parseInt(values.getProperty('creation_biome_count', '0')) + int loadedBiomes = Integer.parseInt(values.getProperty('loaded_biome_count', '0')) + if (creationBiomes == 0 || creationBiomes != loadedBiomes) { + throw new GradleException("Packaged biome directory missed loaded biomes: ${values}") + } + assertDocumentationTree(new File(packagedClientRunDirectory, + 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') + assertRuntimeLogsClean(packagedClientRunDirectory, + 'packaged client', [] as Set) + } +} + +tasks.register('packagedRuntimeIntegrationTest') { + group = 'verification' + description = 'Runs the exact official-named jars in official Forge 52 server and client runtimes.' + dependsOn tasks.named('packagedSurfaceIntegrationTest') + dependsOn tasks.named('packagedClientIntegrationTest') +} diff --git a/gradle/verification/performance.gradle b/gradle/verification/performance.gradle new file mode 100644 index 00000000..73a8f231 --- /dev/null +++ b/gradle/verification/performance.gradle @@ -0,0 +1,193 @@ +// Each mode gets a fresh world with the same seed and Java runtime. The game +// measures separate 81-chunk areas after its own short warm-up. +def benchmarkRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def baselineJar = providers.gradleProperty('benchmarkOreSpawnJar') +def repetitions = providers.gradleProperty('benchmarkRepetitions').orElse('3') +// A single worldgen worker makes exact block comparisons repeatable. +// Ordinary performance runs keep the machine's normal processor count. +def processorCount = providers.gradleProperty('benchmarkProcessorCount').map { + int count = Integer.parseInt(it) + if (count < 1) throw new GradleException('benchmarkProcessorCount must be positive') + count +} +def withWorldHash = providers.gradleProperty('benchmarkWorldHash') + .map { it.toBoolean() }.orElse(false).get() +def runLabel = baselineJar.isPresent() ? 'baseline-' : '' +def benchmarkModes = ['vanilla', 'cyano', 'sky'] +def previousRun = null +// Either sibling may be available on a target; keep the benchmark usable when +// only Mineralogy has a finished jar for this Minecraft version. + +def hashClasses = file("${buildDir}/world-hash-fixture/classes") +def compileHash = tasks.register('compileWorldHashProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/worldHashTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = hashClasses + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + options.encoding = 'UTF-8' +} +def hashJar = tasks.register('worldHashProbeJar', Jar) { + dependsOn compileHash + archiveFileName = 'worldhashprobe.jar' + destinationDirectory = file("${buildDir}/world-hash-fixture") + from hashClasses + from 'src/worldHashTest/resources' +} +def packagedHashJar = hashJar + +benchmarkModes.each { String mode -> + File runDirectory = file("${buildDir}/worldgen-benchmark-${runLabel}${mode}") + def priorRun = previousRun + def prepare = tasks.register("prepareWorldgenBenchmark${mode.capitalize()}") { + if (!baselineJar.isPresent()) dependsOn project.ext.oreSpawnBuild.releaseJar + if (withWorldHash) dependsOn packagedHashJar + doLast { + delete runDirectory + runDirectory.mkdirs() + if (!benchmarkRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + project.ext.oreSpawnBuild.stageForge52Runtime(file(benchmarkRuntime.get()), runDirectory) + project.ext.oreSpawnBuild.prepareTestForgeConfig(runDirectory) + copy { + if (baselineJar.isPresent()) { + File oreSpawn = file(baselineJar.get()) + if (!oreSpawn.isFile()) throw new GradleException("Missing baseline jar: ${oreSpawn}") + from oreSpawn + } else { + from project.ext.oreSpawnBuild.releaseJar + } + ['packagedBaseMetalsJar', 'packagedMineralogyJar'].each { property -> + if (providers.gradleProperty(property).isPresent()) { + File sibling = file(providers.gradleProperty(property).get()) + if (!sibling.isFile()) throw new GradleException("Missing ${property}: ${sibling}") + from sibling + } + } + if (withWorldHash) from packagedHashJar + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=benchmark-world +level-seed=-4965128775892001975 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + def run = tasks.register("worldgenBenchmark${mode.capitalize()}", Exec) { + group = 'verification' + dependsOn prepare + if (priorRun != null) dependsOn priorRun + doFirst { + if (!benchmarkRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir runDirectory + List options = ["-Dorespawn.worldgenBenchmarkMode=${mode}", + '-Dorespawn.worldgenBenchmarkStopServer=true', + "-Dorespawn.worldgenBenchmarkRepetitions=${repetitions.get()}"] + if (processorCount.isPresent()) { + options.add("-XX:ActiveProcessorCount=${processorCount.get()}") + } + commandLine project.ext.oreSpawnBuild.forge52ServerCommand( + file(benchmarkRuntime.get()), runDirectory, options) + } + doLast { + File log = new File(runDirectory, 'logs/latest.log') + if (!log.isFile() || !log.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { + throw new GradleException("No completed ${mode} benchmark summary") + } + if (withWorldHash && !new File(runDirectory, + 'benchmark-world/worldhashprobe.properties').isFile()) { + throw new GradleException("No completed ${mode} world hash") + } + project.ext.oreSpawnBuild.assertRuntimeLogsClean(runDirectory, + "${mode} packaged benchmark", [] as Set) + } + } + previousRun = run +} + +tasks.register('packagedWorldgenPerformanceTest') { + group = 'verification' + description = 'Compares vanilla, Cyano and Sky worldgen on matching fresh worlds.' + dependsOn previousRun + doLast { + Map medians = [:] + benchmarkModes.each { String mode -> + File log = file("${buildDir}/worldgen-benchmark-${runLabel}${mode}/logs/latest.log") + def match = log.text =~ /ORESPAWN_BENCHMARK summary mode=${mode} .*median_ms_per_chunk=([0-9.]+)/ + if (!match.find()) throw new GradleException("Missing ${mode} median") + medians[mode] = Double.parseDouble(match.group(1)) + } + ['vanilla', 'cyano'].each { String control -> + double overhead = ((medians.sky / medians[control]) - 1.0D) * 100.0D + logger.lifecycle('Sky worldgen versus {}: {}% ({} versus {} ms/chunk)', + control, String.format(java.util.Locale.ROOT, '%.2f', overhead), + medians.sky, medians[control]) + if (overhead > 25.0D) throw new GradleException( + "Sky worldgen exceeds the 25% ceiling versus ${control}") + if (overhead > 10.0D) logger.warn( + 'Sky worldgen exceeds the 10% investigation threshold versus {}', control) + } + } +} + +tasks.register('verifyWorldgenParity') { + group = 'verification' + description = 'Compares biome maps, ore inventories and geology distributions with the control jar.' + doLast { + benchmarkModes.each { String mode -> + File oldMarker = file("${buildDir}/worldgen-benchmark-baseline-${mode}/benchmark-world/worldhashprobe.properties") + File newMarker = file("${buildDir}/worldgen-benchmark-${mode}/benchmark-world/worldhashprobe.properties") + if (!oldMarker.isFile() || !newMarker.isFile()) { + throw new GradleException("Run both world-hash benchmarks before comparing ${mode}") + } + Properties oldHashes = new Properties() + Properties newHashes = new Properties() + oldMarker.withInputStream { oldHashes.load(it) } + newMarker.withInputStream { newHashes.load(it) } + if (oldHashes.getProperty('chunks') != '81' || newHashes.getProperty('chunks') != '81' + || oldHashes.getProperty('biomes_sha256') != newHashes.getProperty('biomes_sha256')) { + throw new GradleException("${mode} biome map differs from the control jar") + } + // On this target, two runs of the same control jar can finish with + // different individual block positions after decoration. The + // managed ore counts stay exact; rock and oil counts vary slightly. + long oldManaged = Long.parseLong(oldHashes.getProperty('managed_block_count')) + long newManaged = Long.parseLong(newHashes.getProperty('managed_block_count')) + if (Math.abs(newManaged - oldManaged) > Math.max(1L, Math.ceil(oldManaged * 0.0025D))) { + throw new GradleException("${mode} managed-block total moved beyond control-run variation") + } + Set oldTypes = oldHashes.stringPropertyNames().findAll { + it.startsWith('count.mineralogy:') || it.startsWith('count.orespawn:') + } as Set + Set newTypes = newHashes.stringPropertyNames().findAll { + it.startsWith('count.mineralogy:') || it.startsWith('count.orespawn:') + } as Set + if (oldTypes != newTypes) { + throw new GradleException("${mode} managed block identities differ from the control jar") + } + oldTypes.each { String type -> + long oldCount = Long.parseLong(oldHashes.getProperty(type)) + long newCount = Long.parseLong(newHashes.getProperty(type)) + if (type.endsWith('_ore')) { + if (oldCount != newCount) throw new GradleException( + "${mode} managed ore inventory differs for ${type}") + } else if (Math.abs(newCount - oldCount) > Math.max(75L, Math.ceil(oldCount * 0.01D))) { + throw new GradleException("${mode} managed geology differs for ${type}") + } + } + logger.lifecycle('{} biome map and ore inventories match; geology is within same-version variation', mode) + } + } +} diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..9fc238cf --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,453 @@ +import groovy.json.JsonSlurper +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +// Runtime checks and sealed fixtures are shared by the development and packaged gates. +def shared = project.ext.oreSpawnBuild +def sha256Of = shared.sha256Of +def mavenizerFixtureChecksums = shared.mavenizerFixtureChecksums +def mavenizerCompatibilityJar = shared.mavenizerCompatibilityJar +def mavenizerRuleManifest = shared.mavenizerRuleManifest +def mavenizerSourcePatch = shared.mavenizerSourcePatch +def mavenizerLicense = shared.mavenizerLicense +def mavenizerReadme = shared.mavenizerReadme +def mineralogy5OracleJar = shared.mineralogy5OracleJar +def mineralogy5OracleSha256 = shared.mineralogy5OracleSha256 +def java21Launcher = shared.java21Launcher +def forgeRunModClassesDirectory = file("${buildDir}/forge-run-mod-classes/main") +def prepareForgeRunModClasses = tasks.register('prepareForgeRunModClasses', Sync) { + dependsOn tasks.named('classes') + from sourceSets.main.output.classesDirs + from sourceSets.main.output.resourcesDir + into forgeRunModClassesDirectory +} + +def configureForge52Run = { JavaExec runTask, String launchTarget -> + runTask.dependsOn prepareForgeRunModClasses + // Forge 52 resolves the complete exploded mod from one merged output. + // ForgeGradle materializes its source-set environment after the run model + // is configured, so apply the target-required merged path immediately + // before launch rather than allowing the split classes/resources paths to + // replace it. + runTask.doFirst { + Set splitOutputs = sourceSets.main.output.files.collect { + it.canonicalFile.absolutePath + } as Set + Collection targetClasspath = runTask.classpath.files.findAll { + !splitOutputs.contains(it.canonicalFile.absolutePath) + } + runTask.classpath = files(forgeRunModClassesDirectory, targetClasspath) + runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}" + } +} +tasks.withType(JavaExec).configureEach { JavaExec runTask -> + Map targets = [ + runClient: 'fmluserdevclient', + runServer: 'fmluserdevserver', + runGameTestServer: 'fmluserdevgametestserver', + runData: 'fmluserdevdata', + runSurfaceIntegrationFresh: 'fmluserdevserver', + runSurfaceIntegrationReload: 'fmluserdevserver' + ] + String launchTarget = targets.get(runTask.name) + if (launchTarget != null) configureForge52Run(runTask, launchTarget) +} + +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('verifyMavenizerCompatibilityFixture') { + group = 'verification' + description = 'Verifies the sealed target-aware ForgeGradle Mavenizer compatibility tool.' + inputs.files mavenizerFixtureChecksums.keySet() + doLast { + mavenizerFixtureChecksums.each { fixture, expected -> + if (!fixture.isFile()) { + throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") + } + String actual = sha256Of(fixture) + if (actual != expected) { + throw new GradleException("Mavenizer compatibility fixture checksum mismatch for " + + "${fixture.name}: expected ${expected}, found ${actual}") + } + } + + def parsed = new JsonSlurper().parse(mavenizerRuleManifest.asFile) + String target = 'net.minecraftforge:forge:1.21.1-52.1.0' + if (parsed.schema != 1 || parsed.targets.keySet() != [target] as Set) { + throw new GradleException('Mavenizer target-rule manifest has unexpected targets or schema') + } + def forgeRules = parsed.targets[target] + if (forgeRules.expectedApplications != 0 || !forgeRules.rules.isEmpty()) { + throw new GradleException('Forge 52 Mavenizer target must be an explicit zero-rule qualification') + } + + ZipFile fixtureJar = new ZipFile(mavenizerCompatibilityJar.asFile) + try { + def embeddedEntry = fixtureJar.getEntry( + 'META-INF/orespawn/minecraft-source-compatibility.json') + if (embeddedEntry == null) { + throw new GradleException('Mavenizer fixture is missing its embedded target-rule manifest') + } + byte[] embedded = fixtureJar.getInputStream(embeddedEntry).withCloseable { it.readAllBytes() } + byte[] external = mavenizerRuleManifest.asFile.bytes + if (!Arrays.equals(embedded, external)) { + throw new GradleException('Embedded and externally audited Mavenizer manifests differ') + } + + def patcherEntry = fixtureJar.getEntry( + 'net/minecraftforge/mcmaven/impl/util/SourceCompatibilityPatcher.class') + if (patcherEntry == null) { + throw new GradleException('Mavenizer fixture is missing SourceCompatibilityPatcher') + } + byte[] classHeader = fixtureJar.getInputStream(patcherEntry).withCloseable { + it.readNBytes(8) + } + if (classHeader.length != 8 || classHeader[0..3] != + [0xCA, 0xFE, 0xBA, 0xBE].collect { (byte) it }) { + throw new GradleException('Mavenizer compatibility class has an invalid class header') + } + int classMajor = ((classHeader[6] & 0xff) << 8) | (classHeader[7] & 0xff) + if (classMajor != 69) { + throw new GradleException("Mavenizer compatibility tool must use Java 25 bytecode, found ${classMajor}") + } + + def jarManifestEntry = fixtureJar.getEntry('META-INF/MANIFEST.MF') + Manifest jarManifest = new Manifest(fixtureJar.getInputStream(jarManifestEntry)) + if (jarManifest.mainAttributes.getValue('Main-Class') != + 'net.minecraftforge.mcmaven.cli.Main') { + throw new GradleException('Mavenizer compatibility fixture has an unexpected main class') + } + } finally { + fixtureJar.close() + } + + String readme = mavenizerReadme.asFile.getText('UTF-8') + String patchText = mavenizerSourcePatch.asFile.getText('UTF-8') + String licenseText = mavenizerLicense.asFile.getText('UTF-8') + if (!readme.contains('6968241ce7a0a902cdc1c534b976e8373a423091') + || !readme.contains('Temurin `25.0.3+9`') + || !readme.contains('1.21.1-52.1.0')) { + throw new GradleException('Mavenizer provenance, target, or Java 25 build instructions are incomplete') + } + if (!patchText.contains('SourceCompatibilityPatcher.java') + || !patchText.contains('minecraft-source-compatibility.json')) { + throw new GradleException('Mavenizer source patch is incomplete') + } + if (!licenseText.contains('GNU LESSER GENERAL PUBLIC LICENSE')) { + throw new GradleException('Mavenizer LGPL-2.1 licence fixture is incomplete') + } + } +} + +tasks.register('verifyLegacyOracleIsolation') { + group = 'verification' + description = 'Keeps the sealed Mineralogy oracle test-visible but production-invisible.' + 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}") + } + } + } +} + +tasks.register('verifyJava21Toolchain') { + group = 'verification' + doLast { + def metadata = java21Launcher.get().metadata + String runtimeVersion = metadata.javaRuntimeVersion + if (project.java_toolchain_version != '21.0.7+6' + || metadata.vendor.toString() != 'Eclipse Temurin' + || !(runtimeVersion == '21.0.7+6' || runtimeVersion == '21.0.7+6-LTS')) { + throw new GradleException("Expected Temurin ${project.java_toolchain_version}, found " + + "${metadata.vendor} ${runtimeVersion} at ${metadata.installationPath}") + } + } +} +// Registry-focused tests initialize the real global config singleton. Keep +// that target-native coverage without leaving a generated checkout config. +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 + } + } else if (unitTestWorldgenConfig.isFile()) { + delete unitTestWorldgenConfig + } + } +} +tasks.named('test', Test).configure { + finalizedBy preserveDeveloperWorldgenConfig +} + +def acceptedForge52LogNoise = [ + ~/FML appears to be missing any signature data/, + ~/Found multiple arguments for option fml\.mcVersion/, + ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to verify authentication$/, + ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, + ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, + ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / +] + +def runtimeCrashSnapshot = { File runDirectory -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (!crashDirectory.isDirectory()) return [] as Set + return fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() }.collect { it.absolutePath } as Set +} + +def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes, + List expectedErrors = [] -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (crashDirectory.isDirectory()) { + def crashes = fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } + if (!crashes.isEmpty()) { + throw new GradleException("${context} produced crash report ${crashes.first()}") + } + } + + File logsDirectory = new File(runDirectory, 'logs') + if (!logsDirectory.isDirectory()) return + def failures = [] + [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] + .findAll { it.isFile() }.each { File log -> + int lineNumber = 0 + log.eachLine('UTF-8') { String line -> + lineNumber++ + boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ + boolean knownNoise = acceptedForge52LogNoise.any { line =~ it } || + expectedErrors.any { line.contains(it) } + boolean fatalText = line.contains('Encountered an unexpected exception') || + line.contains('Exception stopping the server') || + line.contains('Migration audit failed') || + line.contains('java.lang.Error:') || + line.contains('NoSuchMethodError') || + line.contains('NoClassDefFoundError') || + line.contains('ExceptionInInitializerError') || + line.contains('com.electronwill.nightconfig.core.io.ParsingException:') || + line.contains('Tried to assign a mutable BlockPos') || + line.contains('causing cascading worldgen lag') + if ((unexpectedSeverity && !knownNoise) || fatalText) { + failures.add("${log.name}:${lineNumber}: ${line}") + } + } + } + if (!failures.isEmpty()) { + throw new GradleException("${context} logged unexpected errors:\n" + + failures.take(20).join('\n')) + } +} + +// Complete first-run configs avoid a race with NightConfig's file watcher. +// Existing configs and manual runs are left alone. +def prepareTestForgeConfig = { File directory -> + String buildRoot = layout.buildDirectory.get().asFile.canonicalPath + File.separator + if (!directory.canonicalPath.startsWith(buildRoot)) { + throw new GradleException('Test configuration must stay inside the build directory') + } + File config = new File(directory, 'config/fml.toml') + if (!config.exists()) { + config.parentFile.mkdirs() + config.setText('''\ +earlyWindowControl = true +maxThreads = -1 +versionCheck = true +defaultConfigPath = "defaultconfigs" +disableOptimizedDFU = true +earlyWindowProvider = "fmlearlywindow" +earlyWindowWidth = 854 +earlyWindowHeight = 480 +earlyWindowFBScale = 1 +earlyWindowMaximized = false +earlyWindowSkipGLVersions = [] +earlyWindowSquir = false +earlyWindowShowCPU = false +earlyWindowLogHelpMessage = true +''', 'UTF-8') + } +} +project.ext.oreSpawnBuild.prepareTestForgeConfig = prepareTestForgeConfig + +task runtimeLogScannerTest { + group = 'verification' + description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' + doLast { + File probe = file("${buildDir}/runtime-log-scanner-test") + delete probe + File logs = new File(probe, 'logs'); logs.mkdirs() + new File(logs, 'latest.log').setText( + '[main/ERROR] [FML]: FML appears to be missing any signature data\n' + + '[Render thread/ERROR] [net.minecraft.client.Minecraft/]: Failed to verify authentication\n' + + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' + + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) + new File(logs, 'latest.log').setText( + '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + boolean rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') + new File(logs, 'latest.log').setText( + '[Server thread/DEBUG] [FML]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') + new File(logs, 'latest.log').setText( + '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + new File(logs, 'latest.log').setText( + '[Thread-1/INFO] [STDERR]: com.electronwill.nightconfig.core.io.ParsingException: Invalid TOML data\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-config-parse-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a configuration parsing exception') + prepareTestForgeConfig(probe) + File preparedConfig = new File(probe, 'config/fml.toml') + if (preparedConfig.readLines('UTF-8').count { it.contains(' = ') } != 14) { + throw new GradleException('Incomplete Forge 52 test defaults') + } + preparedConfig.setText('maxThreads = 3\n', 'UTF-8') + prepareTestForgeConfig(probe) + if (preparedConfig.getText('UTF-8') != 'maxThreads = 3\n') { + throw new GradleException('Test preparation overwrote existing Forge settings') + } + rejected = false + try { prepareTestForgeConfig(file('run')) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Test preparation escaped the build directory') + delete probe + } +} + +check.dependsOn runtimeLogScannerTest + +task verifyMineralogyOracleIsolation { + group = 'verification' + description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' + doLast { + configurations.each { configuration -> + if (configuration.canBeResolved && + configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { + throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") + } + } + } +} + +check.dependsOn verifyMineralogyOracleIsolation + +['runClient', 'runServer', 'runGameTestServer', 'runData'].each { String taskName -> + tasks.matching { it.name == taskName }.all { JavaExec runTask -> + if (taskName == 'runGameTestServer') { + runTask.workingDir = file("${buildDir}/gametest-benchmark-run") + } + doFirst { + new File(runTask.workingDir, 'mods').mkdirs() + if (taskName == 'runGameTestServer') { + new File(runTask.workingDir, 'server.properties').setText('''\ +level-name=gametest-benchmark-world +level-seed=-4965128775892001975 +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + } + runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + } + doLast { + assertRuntimeLogsClean(runTask.workingDir, taskName, + runTask.ext.oreSpawnCrashSnapshot as Set) + } + } +} + + +shared.assertRuntimeLogsClean = assertRuntimeLogsClean +shared.runtimeCrashSnapshot = runtimeCrashSnapshot + +// Fixture servers use copies or hard links from one official Forge installation. +// Their worlds and mods stay in disposable run directories. +shared.stageForge52Runtime = { File runtime, File runDirectory -> + File sourceLibraries = new File(runtime, 'libraries') + File shim = new File(runtime, + "forge-${project.minecraft_version}-${project.forge_version}-shim.jar") + if (!sourceLibraries.isDirectory() || !shim.isFile()) { + throw new GradleException("Incomplete official Forge 52 runtime: ${runtime}") + } + java.nio.file.Files.copy(shim.toPath(), new File(runDirectory, shim.name).toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + File targetLibraries = new File(runDirectory, 'libraries') + sourceLibraries.eachFileRecurse(groovy.io.FileType.FILES) { File source -> + java.nio.file.Path relative = sourceLibraries.toPath().relativize(source.toPath()) + File target = targetLibraries.toPath().resolve(relative).toFile() + target.parentFile.mkdirs() + try { + java.nio.file.Files.createLink(target.toPath(), source.toPath()) + } catch (IOException | UnsupportedOperationException | SecurityException ignored) { + java.nio.file.Files.copy(source.toPath(), target.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } +} + +shared.forge52ServerCommand = { File runtime, File runDirectory, List jvmOptions -> + File forgeDirectory = new File(runDirectory, + "libraries/net/minecraftforge/forge/${project.minecraft_version}-${project.forge_version}") + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + if (!sourceArguments.isFile()) { + throw new GradleException("Incomplete official Forge 52 runtime: ${sourceArguments}") + } + List command = [shared.java21Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g'] + command.addAll(jvmOptions) + command.add("@libraries/net/minecraftforge/forge/${project.minecraft_version}-${project.forge_version}/${sourceArguments.name}") + command.add('nogui') + command +} diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java index 4a4739d7..af8a86c8 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -23,11 +23,13 @@ import net.minecraft.client.gui.components.tabs.TabNavigationBar; import net.minecraft.client.gui.components.tabs.TabManager; import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen; import net.minecraft.client.gui.screens.BackupConfirmScreen; import net.minecraft.client.gui.screens.ConfirmScreen; import net.minecraft.client.gui.screens.TitleScreen; import net.minecraft.client.gui.screens.worldselection.ConfirmExperimentalFeaturesScreen; import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.minecraft.core.registries.Registries; import net.minecraftforge.client.event.RenderLevelStageEvent; import net.minecraftforge.client.event.ScreenEvent; import net.minecraftforge.fml.common.Mod; @@ -35,6 +37,7 @@ import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.event.TickEvent; import net.minecraftforge.fml.util.ObfuscationReflectionHelper; +import net.minecraftforge.registries.ForgeRegistries; import zone.moddev.mc.orespawn.client.OreSpawnWorldSettingsScreen; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; @@ -55,6 +58,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() { @@ -98,9 +106,15 @@ private void handleClientTick() { if (++stateTicks > 3600) fail(minecraft, "Timed out in client probe state " + state + " on screen " + (minecraft.screen == null ? "" : minecraft.screen.getClass().getName())); try { + if (Boolean.getBoolean("clientprobe.hidden")) { + org.lwjgl.glfw.GLFW.glfwHideWindow(minecraft.getWindow().getWindow()); + } switch (state) { case 0: - if (minecraft.screen instanceof TitleScreen) { + if (minecraft.screen instanceof AccessibilityOnboardingScreen) { + minecraft.screen.onClose(); + System.out.println("CLIENT_PROBE onboarding_continue=true"); + } else if (minecraft.screen instanceof TitleScreen) { CreateWorldScreen.openFresh(minecraft, minecraft.screen); nextState(1); } @@ -114,6 +128,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(); + staticBiomeCount = ForgeRegistries.BIOMES.getEntries().size(); + if (Boolean.getBoolean("clientprobe.oreSources")) SharedOreClientProbe.verify(); + MainMenuLayoutProbe.verify(creation); validateCaptions(minecraft.screen); validateLongEditorRoundTrip(minecraft, minecraft.screen); nextState(3); @@ -122,10 +142,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); @@ -141,6 +173,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(); @@ -150,6 +200,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); } @@ -218,16 +270,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(); @@ -401,6 +467,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()) @@ -510,8 +590,14 @@ private static void stopIntegratedServer(Minecraft minecraft) { private void writeMarker() throws IOException { Properties values = new Properties(); values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened)); + values.setProperty("main_menu_layout", Boolean.toString(MainMenuLayoutProbe.completed())); 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..704caeeb --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/MainMenuLayoutProbe.java @@ -0,0 +1,161 @@ +package zone.moddev.mc.orespawn.clientprobe; + +import java.lang.reflect.Constructor; +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 net.minecraftforge.fml.util.ObfuscationReflectionHelper; +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 = ObfuscationReflectionHelper.getPrivateValue(CreateWorldScreen.class, parent, "f_313923_"); + TabNavigationBar navigation = ObfuscationReflectionHelper.getPrivateValue(CreateWorldScreen.class, parent, "f_267490_"); + 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 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/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/SharedOreClientProbe.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/SharedOreClientProbe.java new file mode 100644 index 00000000..e605d1cf --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/SharedOreClientProbe.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.orespawn.clientprobe; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import zone.moddev.mc.orespawn.worldgen.GeomeConfig; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Keeps test access separate from OreSpawn's packages in Forge's mod class loader. */ +final class SharedOreClientProbe { + private SharedOreClientProbe() { } + + static void verify() { + try { + Class editor = Class.forName("zone.moddev.mc.orespawn.client.GeologyEditorSession"); + java.lang.reflect.Constructor constructor = editor.getDeclaredConstructor(WorldGeologyProfile.class); + constructor.setAccessible(true); + Object session = constructor.newInstance(GeomeConfig.globalProfile()); + Method groups = editor.getDeclaredMethod("oreSourceGroups"); + groups.setAccessible(true); + for (Object group : (List) groups.invoke(session)) { + if (!"orespawn:sulfur".equals(field(group, "material"))) continue; + Set owners = new HashSet<>(); + for (Object candidate : (List) field(group, "candidates")) { + owners.add((String) field(candidate, "owner")); + } + Method outputs = group.getClass().getDeclaredMethod("outputCandidates"); + outputs.setAccessible(true); + if (!owners.contains("baseminerals") || !owners.contains("electricadvantage") + || ((List) outputs.invoke(group)).size() != 3) { + throw new IllegalStateException("The profile must expose both providers and all three outputs"); + } + System.out.println("ORE_SOURCES_CLIENT_PROBE providers=2 outputs=3 verified=true"); + return; + } + throw new IllegalStateException("Shared Sulfur group is missing"); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not inspect the shared-ore editor", failure); + } + } + + private static Object field(Object object, String name) throws ReflectiveOperationException { + Field field = object.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(object); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index fafcfe6b..f81935cb 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -65,6 +65,7 @@ public OreSpawn(FMLJavaModLoadingContext context) { MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onChunkLoad); MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onWorldTick); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerAboutToStart); + MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStarting); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onWorldLoad); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStopped); MinecraftForge.EVENT_BUS.addListener(OreRetrogenManager::onChunkLoad); diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java new file mode 100644 index 00000000..88be3e79 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.orespawn.api; + +import java.util.Optional; + +import net.minecraft.resources.ResourceLocation; + +/** + * Extended placement context supplied by OreSpawn 4.1 and later. + * + *

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

+ * + *

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

+ */ +public interface OreGenerationContext extends OrePlacementContext { + /** + * Attempts placement using a stable identity shared by every slice of one + * logical deposit body. Implementations compiled before this overload retain + * the original placement behaviour. + */ + default boolean tryPlace(int x, int y, int z, long outputIdentity) { + return tryPlace(x, y, z); + } + + /** Stable seed of the world being generated. */ + long worldSeed(); + + /** Stable namespaced identity of the dimension being generated. */ + ResourceLocation dimension(); + + /** X coordinate of the current chunk, not its minimum block coordinate. */ + int chunkX(); + + /** Z coordinate of the current chunk, not its minimum block coordinate. */ + int chunkZ(); + + /** + * Returns the active allocation-light geology sampler when the dimension has + * an OreSpawn geology configuration. Pattern implementations should cache no + * world or implementation objects beyond the placement call. + */ + Optional geologySampler(); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java index e99b8c91..26880754 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 + : ResourceLocation.fromNamespaceAndPath("orespawn", "standard"); + patternSettings = JsonCopies.copy(builder.patternSettings); heightDistribution = builder.heightDistribution; discardChanceOnAirExposure = builder.discardChanceOnAirExposure; spread = builder.spread; @@ -574,7 +587,9 @@ private OreDimensionDefinition(Builder builder) { public int maxQuantity() { return maxQuantity; } public OrePattern pattern() { return pattern; } public ResourceLocation patternType() { return patternType; } - public JsonObject patternSettings() { return patternSettings.deepCopy(); } + /** Placement frequency, shape and host budget are selected per channel. */ + public ResourceLocation placementChannel() { return placementChannel; } + public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); } public OreHeightDistribution heightDistribution() { return heightDistribution; } public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; } public int spread() { return spread; } @@ -612,6 +627,7 @@ public JsonObject toJson() { configuredPattern.add("settings", patternSettings.deepCopy()); json.add("pattern", configuredPattern); } + json.addProperty("placement_channel", placementChannel.toString()); json.addProperty("height_distribution", heightDistribution.configName()); json.addProperty("discard_chance_on_air_exposure", discardChanceOnAirExposure); json.addProperty("spread", spread); @@ -640,6 +656,7 @@ public static final class Builder { private int maxQuantity = 8; private OrePattern pattern = OrePattern.VEIN; private ResourceLocation patternType; + private ResourceLocation placementChannel; private JsonObject patternSettings = new JsonObject(); private OreHeightDistribution heightDistribution = OreHeightDistribution.UNIFORM; private double discardChanceOnAirExposure; @@ -676,6 +693,10 @@ public Builder pattern(ResourceLocation type, JsonObject settings) { patternSettings = Objects.requireNonNull(settings, "settings").deepCopy(); return this; } + public Builder placementChannel(ResourceLocation value) { + placementChannel = Objects.requireNonNull(value); + return this; + } public Builder heightDistribution(OreHeightDistribution value) { heightDistribution = Objects.requireNonNull(value); return this; } public Builder discardChanceOnAirExposure(double value) { discardChanceOnAirExposure = value; return this; } public Builder spread(int horizontal, int vertical) { spread = horizontal; verticalSpread = vertical; return this; } diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java new file mode 100644 index 00000000..439c733d --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.Objects; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Immutable registered action displayed by OreSpawn's world-settings screen. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtension { + private final ResourceLocation id; + private final String buttonTranslationKey; + private final WorldSettingsScreenFactory screenFactory; + + WorldSettingsExtension(ResourceLocation id, String buttonTranslationKey, + WorldSettingsScreenFactory screenFactory) { + this.id = Objects.requireNonNull(id, "id"); + this.buttonTranslationKey = Objects.requireNonNull(buttonTranslationKey, + "buttonTranslationKey"); + this.screenFactory = Objects.requireNonNull(screenFactory, "screenFactory"); + } + + public ResourceLocation id() { return id; } + public String buttonTranslationKey() { return buttonTranslationKey; } + + /** Creates the extension screen with OreSpawn's Mods directory as its parent. */ + public Screen createScreen(Screen parent) { + return screenFactory.create(Objects.requireNonNull(parent, "parent")); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java new file mode 100644 index 00000000..5a17a24c --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java @@ -0,0 +1,79 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Client-only registry for add-on screens in OreSpawn's Mods directory. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtensionRegistry { + private static final Pattern MOD_ID = Pattern.compile("^[a-z][a-z0-9_.-]{1,63}$"); + private static final Map EXTENSIONS = + new LinkedHashMap<>(); + + private WorldSettingsExtensionRegistry() { + } + + /** + * Registers the one optional OreSpawn-linked configuration screen owned by a + * loaded mod. Registration must occur during client initialization. + * + * @throws IllegalArgumentException if the mod identifier is not canonical + * @throws IllegalStateException if the mod already registered a screen + */ + public static synchronized WorldSettingsExtension registerConfigScreen(String modId, + WorldSettingsScreenFactory screenFactory) { + validateModId(modId); + return registerOwned(modId, ResourceLocation.fromNamespaceAndPath(modId, "configuration"), + "button.orespawn.mod.configure", screenFactory); + } + + /** + * Retains the original translated world-settings registration descriptor for + * existing add-ons. The identifier namespace owns the one permitted screen. + * Registration must occur during client initialization. + * + * @throws IllegalStateException if the identifier is already registered + */ + public static synchronized WorldSettingsExtension register(ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(id, "id"); + if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) { + throw new IllegalArgumentException("buttonTranslationKey cannot be blank"); + } + String modId = id.getNamespace(); + validateModId(modId); + return registerOwned(modId, id, buttonTranslationKey, screenFactory); + } + + private static WorldSettingsExtension registerOwned(String modId, ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(screenFactory, "screenFactory"); + if (EXTENSIONS.containsKey(modId)) { + throw new IllegalStateException("Duplicate OreSpawn configuration screen for mod: " + modId); + } + WorldSettingsExtension extension = new WorldSettingsExtension(id, buttonTranslationKey, + screenFactory); + EXTENSIONS.put(modId, extension); + return extension; + } + + private static void validateModId(String modId) { + if (modId == null || !MOD_ID.matcher(modId).matches()) { + throw new IllegalArgumentException("modId must be a canonical lower-case mod identifier"); + } + } + + /** Returns an immutable snapshot in deterministic registration order. */ + public static synchronized List extensions() { + return Collections.unmodifiableList(new ArrayList<>(EXTENSIONS.values())); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java new file mode 100644 index 00000000..03d3f7d5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java @@ -0,0 +1,12 @@ +package zone.moddev.mc.orespawn.api.client; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */ +@FunctionalInterface +@OnlyIn(Dist.CLIENT) +public interface WorldSettingsScreenFactory { + Screen create(Screen parent); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java new file mode 100644 index 00000000..781318f4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -0,0 +1,379 @@ +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.minecraftforge.fml.ModList; +import net.minecraftforge.forgespi.language.IModInfo; +import net.minecraftforge.registries.ForgeRegistries; + +/** Immutable projection of loaded biomes, profile palettes, and provider defaults. */ +final class BiomeDirectoryModel { + private static final String OVERWORLD = "minecraft:overworld"; + private static final String NETHER = "minecraft:the_nether"; + private static final String END = "minecraft:the_end"; + private static final Method BIOME_NAME = findBiomeNameMethod(); + + private BiomeDirectoryModel() { } + + static 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.1")); + Iterable, Biome>> biomes = biomeRegistry == null + ? ForgeRegistries.BIOMES.getEntries() : biomeRegistry.entrySet(); + for (Entry, Biome> entry : biomes) { + 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 ? ForgeRegistries.BIOMES.getHolder(biome) + : biomeRegistry.getHolder(biome)) + .map(holder -> holder.is(BiomeTags.IS_NETHER)).orElse(false)) return NETHER; + if ((biomeRegistry == null ? ForgeRegistries.BIOMES.getHolder(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 ResourceLocation.parse(id).getNamespace(); } + catch (RuntimeException ignored) { return "unknown"; } + } + + private static String friendly(String id) { + String value; + try { value = ResourceLocation.parse(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 c56a95db..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.screens.Screen; -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,56 +31,89 @@ 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 protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - graphics.drawCenteredString(font, title, width / 2, 12, OreSpawnScreenLayout.TEXT_PRIMARY); graphics.drawCenteredString(font, Component.literal(dimension), width / 2, 28, OreSpawnScreenLayout.TEXT_SOFT); - } } 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 9d0927d4..2199ddf2 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java @@ -5,11 +5,10 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.screens.Screen; -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; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; @@ -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); @@ -187,10 +198,8 @@ private static void putDouble(JsonObject root, String key, EditBox field, @Override protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - graphics.drawCenteredString(font, title, width / 2, 10, OreSpawnScreenLayout.TEXT_PRIMARY); graphics.drawCenteredString(font, Component.literal(biomeId), width / 2, 28, OreSpawnScreenLayout.TEXT_SOFT); - } private static JsonObject object(JsonObject root, String key) { 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..5dca8bcc --- /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(ResourceLocation.parse(source.getAsString()).toString(), + ResourceLocation.parse(target.getKey()).toString()); } + catch (RuntimeException ignored) { } + } + } + return Collections.unmodifiableMap(result); + } + + static void set(JsonObject root, String dimension, String source, String target) { + String normalizedSource = ResourceLocation.parse(source).toString(); + String normalizedTarget = ResourceLocation.parse(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 101eb02c..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.screens.Screen; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.CycleButton; -import net.minecraft.network.chat.CommonComponents; +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.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,135 +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 setPalette(String key, String value) { - session.biomePalette(dimension, true).addProperty(key, value); + 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 Component dimensionName(String value) { - return Component.literal(value); + private void resetSelected(BiomeEntry selected) { + if (!resetArmed) { resetArmed = true; rebuild(); return; } + session.resetBiome(dimension, selected.id); + resetArmed = false; clearMessage(); rebuild(); } - private Component modeName(String value) { - return Component.translatable("value.orespawn.biome_mode." + value); + private void cycleDimension(Snapshot snapshot) { + dimension = nextDimension(snapshot.dimensions, dimension); + selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; clearMessage(); rebuild(); } - private Component scopeName(String value) { - return Component.translatable("value.orespawn.biome_scope." + value); + private void clearMessage() { + message = null; } - private Component regionName(String value) { - return Component.translatable("value.orespawn.preset." + 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; } - protected void rebuildWidgets() { - clearWidgets(); - init(); + 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()); } - @Override - public void onClose() { - minecraft.setScreen(parent); + private static BiomeEntry find(List entries, String id) { + if (id != null) for (BiomeEntry entry : entries) if (id.equals(entry.id)) return entry; + return null; + } + + static 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..478cc1f3 --- /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)); + 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 8253a47b..e552ff61 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,10 +26,14 @@ 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; @@ -33,6 +41,7 @@ import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.item.Items; import net.minecraftforge.registries.ForgeRegistries; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Mutable client-side copy used until the Create World settings are accepted. */ final class GeologyEditorSession { @@ -57,6 +66,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) { @@ -64,11 +76,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"); @@ -91,6 +122,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); } @@ -121,6 +190,13 @@ List availableDimensionIds() { if (validResource(id)) result.add(ResourceLocation.parse(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(ResourceLocation.parse(id).toString()); + } + } List ordered = new ArrayList<>(); for (String vanilla : Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")) { ordered.add(vanilla); @@ -345,6 +421,775 @@ void disableOrRemoveOre(String id) { } } + List oreSourceGroups() { + List result = new ArrayList<>(); + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + String material = string(policy, "material", ""); + String domain = string(policy, "domain", ""); + if (!validResource(material) || !validResource(domain)) continue; + JsonObject groupDefinitions = section("ore_material_groups"); + JsonObject groupDefinition = groupDefinitions.has(material) + && groupDefinitions.get(material).isJsonObject() + ? groupDefinitions.getAsJsonObject(material) : new JsonObject(); + String displayName = string(groupDefinition, "display_name", displayName(material)); + List groupDictionary = stringList(groupDefinition, "block_tag_entries"); + boolean curated = "orespawn:sulfur".equals(material) || "orespawn:aluminum".equals(material); + List candidates = new ArrayList<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String registryId = string(candidate, "registry_id", ""); + if (sourceId.isEmpty() || !validResource(channel) || !validResource(registryId)) continue; + List dictionary = new ArrayList<>(); + if (candidate.has("block_tags") && candidate.get("block_tags").isJsonArray()) { + for (JsonElement name : candidate.getAsJsonArray("block_tags")) { + try { dictionary.add(name.getAsString()); } + catch (RuntimeException ignored) { } + } + } + candidates.add(new OreSourceCandidate(sourceId, + string(candidate, "owner", ""), string(candidate, "owner_name", ""), + string(candidate, "owner_version", ""), registryId, + integer(candidate, "metadata", 0), channel, dictionary, + bool(candidate, "loaded", false), bool(candidate, "placement_active", true), + bool(candidate, "external", false), + bool(candidate, "enrichment", false), + bool(candidate, "review_required", false))); + } + } + Collections.sort(candidates, (left, right) -> { + int owner = left.owner.compareTo(right.owner); + return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId); + }); + JsonObject viewPolicy = JsonCopies.copy(policy); + refreshOreSourceStatus(viewPolicy); + OreSourceGroup group = new OreSourceGroup(entry.getKey(), material, domain, + displayName, groupDictionary, curated, + string(policy, "mode", "keep_separate"), outputMode(policy), + string(viewPolicy, "status", "review_required"), + candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")); + if (!bool(policy, "dormant", false)) result.add(group); + } + Collections.sort(result, (left, right) -> { + int name = left.displayName.compareToIgnoreCase(right.displayName); + if (name != 0) return name; + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return Collections.unmodifiableList(result); + } + + String addOreMaterialGroup() { + JsonObject groups = section("ore_material_groups"); + JsonObject policies = section("ore_source_policies"); + String base = "orespawn:custom/custom_group"; + String id = base; + for (int suffix = 2; groups.has(id) + || policies.has(id + "|minecraft:overworld"); suffix++) id = base + '_' + suffix; + JsonObject definition = new JsonObject(); + definition.addProperty("display_name", "Custom Group"); + definition.add("block_tag_entries", new JsonArray()); + groups.add(id, definition); + String key = id + "|minecraft:overworld"; + JsonObject policy = objectEntry(policies, key); + policy.remove("dormant"); + policy.addProperty("material", id); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + policy.addProperty("status", "separate"); + policy.addProperty("review_required", false); + policy.add("outputs", new JsonObject()); + policy.add("placement_sources", new JsonObject()); + policy.add("candidates", new JsonArray()); + return key; + } + + void renameOreMaterialGroup(String material, String displayName) { + if (!validResource(material)) return; + String value = displayName == null ? "" : displayName.trim(); + if (value.isEmpty()) return; + objectEntry(section("ore_material_groups"), material) + .addProperty("display_name", value.length() > 64 ? value.substring(0, 64) : value); + } + + String blockTagOwner(String alias) { + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + if (stringList(entry.getValue().getAsJsonObject(), "block_tag_entries").contains(alias)) { + return entry.getKey(); + } + } + return null; + } + + boolean vanillaOreManagementRequiredForAliasMove(String material, String alias) { + if (bool(root, "manage_vanilla_ores", false)) return false; + String owner = blockTagOwner(alias); + if (owner == null || owner.equals(material)) return false; + JsonObject ores = section("ores"); + for (OreSourceGroup group : oreSourceGroups()) { + if (!owner.equals(group.material)) continue; + for (OreSourceCandidate candidate : group.candidates) { + if (!candidate.loaded || candidate.external || !candidate.blockTag.contains(alias) + || !ores.has(candidate.sourceId) || !ores.get(candidate.sourceId).isJsonObject()) continue; + if (bool(ores.getAsJsonObject(candidate.sourceId), "native_generation", false)) return true; + } + } + return false; + } + + boolean addOreMaterialAlias(String material, String alias, boolean moveExisting) { + if (!validResource(material) || !validBlockTagName(alias)) return false; + String owner = blockTagOwner(alias); + if (owner != null && !owner.equals(material)) { + if (!moveExisting) return false; + if (vanillaOreManagementRequiredForAliasMove(material, alias)) return false; + removeOreMaterialAlias(owner, alias); + } + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (!names.contains(alias)) names.add(alias); + Collections.sort(names); + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + return true; + } + + void removeOreMaterialAlias(String material, String alias) { + if (!validResource(material)) return; + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (names.remove(alias)) { + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + } + } + + void resetOreMaterialGroup(String material) { + if ("orespawn:sulfur".equals(material)) { + setOreMaterialGroup(material, "Sulfur", + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")); + } else if ("orespawn:aluminum".equals(material)) { + setOreMaterialGroup(material, "Aluminum", + Arrays.asList("forge:ores/aluminum", "forge:ores/aluminium")); + } + rebuildOreSourcePolicies(); + } + + boolean deleteEmptyOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject() + || !stringList(groups.getAsJsonObject(material), "block_tag_entries").isEmpty()) { + return false; + } + groups.remove(material); + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + boolean dissolveOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject()) return false; + List aliases = stringList(groups.getAsJsonObject(material), "block_tag_entries"); + if (aliases.isEmpty()) return false; + groups.remove(material); + for (String alias : aliases) { + String restoredMaterial = inferredMaterialForAlias(alias); + JsonObject definition = objectEntry(groups, restoredMaterial); + if (!definition.has("display_name")) { + definition.addProperty("display_name", displayName(restoredMaterial)); + } + List restoredAliases = stringList(definition, "block_tag_entries"); + if (!restoredAliases.contains(alias)) restoredAliases.add(alias); + Collections.sort(restoredAliases); + definition.add("block_tag_entries", strings(restoredAliases)); + } + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + private void markMaterialPoliciesDormant(String material) { + for (Entry entry : section("ore_source_policies").entrySet()) { + if (entry.getValue().isJsonObject() + && material.equals(string(entry.getValue().getAsJsonObject(), "material", ""))) { + entry.getValue().getAsJsonObject().addProperty("dormant", true); + } + } + } + + boolean oreMaterialGroupsChanged() { + return !sectionCopy(original, "ore_material_groups").equals(sectionCopy(root, "ore_material_groups")); + } + + JsonObject oreMaterialGroupsCopy() { + return sectionCopy(root, "ore_material_groups"); + } + + void resetOreSourcesToDefaults() { + WorldGeologyProfile current = profile(); + JsonObject reset = current.rootCopy(); + reset.remove("ore_material_groups"); + reset.remove("ore_source_policies"); + WorldGeologyProfile defaults = WorldGeologyProfile.fromGlobalConfig(reset, + current.geologyMode(), current.placeFluidDeposits()); + applyProfile(defaults); + oreSourceOriginal = defaults.rootCopy(); + normalizeRegistrySections(oreSourceOriginal); + } + + private void setOreMaterialGroup(String material, String displayName, List aliases) { + JsonObject definition = objectEntry(section("ore_material_groups"), material); + definition.addProperty("display_name", displayName); + definition.add("block_tag_entries", strings(aliases)); + } + + private void rebuildOreSourcePolicies() { + JsonObject policies = section("ore_source_policies"); + Map> previousMembers = new LinkedHashMap<>(); + Map candidates = new LinkedHashMap<>(); + for (Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + Set members = new LinkedHashSet<>(); + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue; + boolean hasCandidates = policy.getAsJsonArray("candidates").size() > 0; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = JsonCopies.copy(element.getAsJsonObject()); + String identity = candidateIdentity(candidate, string(policy, "domain", "")); + members.add(identity); + candidates.put(identity, candidate); + } + previousMembers.put(entry.getKey(), members); + String material = string(policy, "material", ""); + if (!hasCandidates && section("ore_material_groups").has(material)) { + policy.remove("dormant"); + } else { + policy.addProperty("dormant", true); + } + } + Map> regrouped = new LinkedHashMap<>(); + for (JsonObject candidate : candidates.values()) { + String material = string(candidate, "material", ""); + if (!bool(candidate, "material_declared", false)) { + material = materialForAliases(stringList(candidate, "block_tags")); + if (!material.isEmpty()) candidate.addProperty("material", material); + } + String domain = string(candidate, "domain", "minecraft:overworld"); + if (!validResource(material) || !validResource(domain)) continue; + regrouped.computeIfAbsent(material + '|' + domain, ignored -> new ArrayList<>()).add(candidate); + } + for (Entry> entry : regrouped.entrySet()) { + int split = entry.getKey().indexOf('|'); + String material = entry.getKey().substring(0, split); + String domain = entry.getKey().substring(split + 1); + JsonObject policy = policies.has(entry.getKey()) && policies.get(entry.getKey()).isJsonObject() + ? policies.getAsJsonObject(entry.getKey()) : new JsonObject(); + policy.remove("dormant"); + policy.addProperty("material", material); + policy.addProperty("domain", domain); + if (!policy.has("mode")) policy.addProperty("mode", "keep_separate"); + if (!policy.has("output_mode")) policy.addProperty("output_mode", "custom"); + JsonArray array = new JsonArray(); + Set currentMembers = new LinkedHashSet<>(); + for (JsonObject candidate : entry.getValue()) { + array.add(candidate); + currentMembers.add(candidateIdentity(candidate, domain)); + } + policy.add("candidates", array); + reconcileOreSourceOutputs(policy); + reconcileOreSourcePlacements(policy); + Set previous = previousMembers.get(entry.getKey()); + boolean membershipChanged = previous == null || !previous.equals(currentMembers); + if (membershipChanged) { + policy.addProperty("review_required", outputCandidateJson(policy).size() > 1); + } + refreshOreSourceStatus(policy); + policies.add(entry.getKey(), policy); + } + } + + private static void reconcileOreSourceOutputs(JsonObject policy) { + List candidates = outputCandidateJson(policy); + Set candidateIds = allOutputCandidateIds(policy); + JsonObject outputs = objectEntry(policy, "outputs"); + for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) { + if (!candidateIds.contains(sourceId)) outputs.remove(sourceId); + } + String mode = string(policy, "mode", "keep_separate"); + String outputMode = outputMode(policy); + if ("keep_separate".equals(mode) || "balanced".equals(outputMode)) { + for (JsonObject candidate : candidates) { + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty() && bool(candidate, "loaded", false)) { + outputs.addProperty(sourceId, 1.0D); + } + } + } else if ("single".equals(outputMode)) { + String selected = ""; + for (String sourceId : JsonCopies.keys(outputs)) { + if (candidateIds.contains(sourceId)) { + selected = sourceId; + break; + } + } + if (selected.isEmpty()) { + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + } + + private static void reconcileOreSourcePlacements(JsonObject policy) { + Map> retainedChoices = new LinkedHashMap<>(); + Map> loadedChoices = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "external", false) || bool(candidate, "enrichment", false)) continue; + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) { + retainedChoices.computeIfAbsent(channel, ignored -> new LinkedHashSet<>()).add(sourceId); + } + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false)) { + loadedChoices.computeIfAbsent(channel, ignored -> new ArrayList<>()).add(candidate); + } + } + } + JsonObject placements = objectEntry(policy, "placement_sources"); + for (String channel : new ArrayList<>(JsonCopies.keys(placements))) { + String selected = string(placements, channel, ""); + if (!retainedChoices.getOrDefault(channel, Collections.emptySet()).contains(selected)) { + placements.remove(channel); + } + } + for (Entry> entry : loadedChoices.entrySet()) { + if (!placements.has(entry.getKey()) && !entry.getValue().isEmpty()) { + placements.addProperty(entry.getKey(), string(entry.getValue().get(0), "source_id", "")); + } + } + } + + private static Set allOutputCandidateIds(JsonObject policy) { + Set result = new LinkedHashSet<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) result.add(sourceId); + } + } + return result; + } + + private static String candidateIdentity(JsonObject candidate, String fallbackDomain) { + return string(candidate, "source_id", "") + '|' + + string(candidate, "placement_channel", "orespawn:standard") + '|' + + string(candidate, "domain", fallbackDomain); + } + + private String materialForAliases(List aliases) { + Set materials = new LinkedHashSet<>(); + for (String alias : aliases) { + String owner = blockTagOwner(alias); + if (owner != null) materials.add(owner); + else if (validBlockTagName(alias)) { + materials.add("orespawn:" + oreTagMaterial(alias)); + } + } + return materials.size() == 1 ? materials.iterator().next() + : materials.size() > 1 ? provisionalMaterialForAliases(aliases) : ""; + } + + private static String provisionalMaterialForAliases(List aliases) { + Set unique = new LinkedHashSet<>(); + for (String alias : aliases) if (validBlockTagName(alias)) unique.add(alias); + List exact = new ArrayList<>(unique); + Collections.sort(exact); + String token = exact.isEmpty() ? "unknown" : oreTagMaterial(exact.get(0)); + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return "orespawn:review/" + token + '-' + padded.append(value).toString(); + } + + private static boolean isCustomMaterial(String material) { + return material != null && material.startsWith("orespawn:custom/") && validResource(material); + } + + private static String inferredMaterialForAlias(String alias) { + if ("forge:ores/sulfur".equals(alias) || "forge:ores/sulphur".equals(alias)) return "orespawn:sulfur"; + if ("forge:ores/aluminum".equals(alias) || "forge:ores/aluminium".equals(alias)) return "orespawn:aluminum"; + return "orespawn:" + oreTagMaterial(alias); + } + + void setOreSourceMode(String key, boolean consolidated) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate"); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void acceptOreSourcePolicy(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void restoreOreSourceOriginalMode(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject originalPolicies = object(oreSourceOriginal, "ore_source_policies"); + JsonObject originalPolicy = originalPolicies.has(key) && originalPolicies.get(key).isJsonObject() + ? originalPolicies.getAsJsonObject(key) : null; + policy.addProperty("mode", "keep_separate"); + if (originalPolicy != null) { + policy.addProperty("output_mode", outputMode(originalPolicy)); + policy.add("outputs", sectionCopy(originalPolicy, "outputs")); + policy.add("placement_sources", sectionCopy(originalPolicy, "placement_sources")); + } else { + JsonObject outputs = new JsonObject(); + JsonObject placements = new JsonObject(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) + || !bool(candidate, "placement_active", false) + || bool(candidate, "external", false) + || bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + if (!sourceId.isEmpty() && !placements.has(channel)) { + placements.addProperty(channel, sourceId); + } + } + } + policy.addProperty("output_mode", "custom"); + policy.add("outputs", outputs); + policy.add("placement_sources", placements); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutputMode(String key, String outputMode) { + if (!"balanced".equals(outputMode) && !"single".equals(outputMode) + && !"custom".equals(outputMode)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + if (!hasManagedPlacementSource(policy)) return; + policy.addProperty("mode", "consolidated"); + policy.addProperty("output_mode", outputMode); + JsonObject outputs = objectEntry(policy, "outputs"); + if ("balanced".equals(outputMode)) { + outputs.entrySet().clear(); + for (JsonObject candidate : outputCandidateJson(policy)) { + if (bool(candidate, "external", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + } + } else if ("single".equals(outputMode)) { + String selected = preferredSingleOutput(policy, outputs); + if (!loadedOutput(policy, selected)) { + List candidates = outputCandidateJson(policy); + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + private static String preferredSingleOutput(JsonObject policy, JsonObject outputs) { + JsonObject placements = objectEntry(policy, "placement_sources"); + for (Entry entry : placements.entrySet()) { + try { + String sourceId = entry.getValue().getAsString(); + if (outputs.has(sourceId) && loadedOutput(policy, sourceId)) return sourceId; + } catch (RuntimeException ignored) { } + } + return outputs.entrySet().isEmpty() ? "" : outputs.entrySet().iterator().next().getKey(); + } + + void setOreSourcePlacement(String key, String channel, String sourceId) { + if (!validResource(channel)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + objectEntry(policy, "placement_sources").addProperty(channel, sourceId); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutput(String key, String sourceId, boolean enabled, double weight) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject outputs = objectEntry(policy, "outputs"); + String outputMode = outputMode(policy); + if (!enabled) outputs.remove(sourceId); + else if ("single".equals(outputMode)) { + outputs.entrySet().clear(); + outputs.addProperty(sourceId, 1.0D); + } else outputs.addProperty(sourceId, "balanced".equals(outputMode) ? 1.0D + : Math.max(0.001D, Math.min(1000000.0D, weight))); + refreshOreSourceStatus(policy); + } + + private static List outputCandidateJson(JsonObject policy) { + Map unique = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) || bool(candidate, "enrichment", false)) continue; + String identity = string(candidate, "registry_id", "") + '|' + + integer(candidate, "metadata", 0); + JsonObject previous = unique.get(identity); + if (previous == null || (!bool(previous, "placement_active", false) + && bool(candidate, "placement_active", false)) + || (bool(previous, "external", false) && !bool(candidate, "external", false))) { + unique.put(identity, candidate); + } + } + } + return new ArrayList<>(unique.values()); + } + + private static boolean loadedOutput(JsonObject policy, String sourceId) { + if (sourceId == null || sourceId.isEmpty()) return false; + for (JsonObject candidate : outputCandidateJson(policy)) { + if (sourceId.equals(string(candidate, "source_id", ""))) return true; + } + return false; + } + + private static boolean hasManagedPlacementSource(JsonObject policy) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false) + && !bool(candidate, "external", false) + && !bool(candidate, "enrichment", false)) return true; + } + return false; + } + + private static void refreshOreSourceStatus(JsonObject policy) { + Set missing = new HashSet<>(); + Set inactivePlacements = new HashSet<>(); + boolean external = false; + boolean review = bool(policy, "review_required", false); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + if (!bool(candidate, "loaded", false)) missing.add(sourceId); + if (!bool(candidate, "placement_active", false)) inactivePlacements.add(sourceId); + external |= bool(candidate, "external", false) && bool(candidate, "loaded", false); + } + } + Map outputs = decimalMap(policy, "outputs"); + Map placements = stringMap(policy, "placement_sources"); + if ("consolidated".equals(string(policy, "mode", "keep_separate")) + && (outputs.isEmpty() || placements.isEmpty())) { + policy.addProperty("status", "missing_source"); + return; + } + for (String id : outputs.keySet()) { + if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; } + } + for (String id : placements.values()) { + if (missing.contains(id) || inactivePlacements.contains(id)) { + policy.addProperty("status", "missing_source"); return; + } + } + policy.addProperty("status", external ? "external_generation" : review ? "review_required" + : "consolidated".equals(string(policy, "mode", "keep_separate")) + ? "consolidated" : "separate"); + } + + private static Map decimalMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static Map stringMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { result.put(entry.getKey(), entry.getValue().getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + static final class OreSourceGroup { + final String key; + final String material; + final String domain; + final String displayName; + final List blockTagEntries; + final boolean curated; + final String mode; + final String outputMode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + OreSourceGroup(String key, String material, String domain, String displayName, + List blockTagEntries, boolean curated, String mode, String outputMode, String status, + List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.curated = curated; + this.mode = mode; + this.outputMode = outputMode; + this.status = status; + this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates)); + this.outputs = outputs; + this.placements = placements; + } + + List channels() { + Set result = new TreeSet<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.external && candidate.active) result.add(candidate.channel); + } + return Collections.unmodifiableList(new ArrayList<>(result)); + } + + List outputCandidates() { + Map unique = new LinkedHashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (candidate.enrichment) continue; + String identity = candidate.registryId + '|' + candidate.metadata; + OreSourceCandidate previous = unique.get(identity); + if (previous == null || (!previous.active && candidate.active) + || (previous.external && !candidate.external)) unique.put(identity, candidate); + } + return Collections.unmodifiableList(new ArrayList<>(unique.values())); + } + + boolean needsAttention() { + return needsReview() || "missing_source".equals(status) + || outputCandidates().isEmpty(); + } + + boolean needsReview() { + return "review_required".equals(status); + } + + boolean hasManagedPlacementSource() { + for (OreSourceCandidate candidate : candidates) { + if (candidate.loaded && candidate.active && !candidate.external + && !candidate.enrichment) return true; + } + return false; + } + + boolean isRoutineSingleSource() { + return !needsAttention() && blockTagEntries.size() == 1 + && outputCandidates().size() == 1; + } + + boolean isCustom() { + return isCustomMaterial(material); + } + + boolean canDeleteEmpty() { + return isCustom() && blockTagEntries.isEmpty(); + } + + boolean canDissolve() { + return isCustom() && !blockTagEntries.isEmpty(); + } + + boolean isRedundantSeparatePolicy() { + if (!"keep_separate".equals(mode) || candidates.isEmpty()) return false; + Set outputs = new HashSet<>(); + Map> ownersByChannel = new HashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.loaded || candidate.external || candidate.enrichment + || candidate.reviewRequired) return false; + outputs.add(candidate.registryId + '|' + candidate.metadata); + ownersByChannel.computeIfAbsent(candidate.channel, ignored -> new HashSet<>()) + .add(candidate.owner); + } + if (outputs.size() > 1) return false; + for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return false; + return true; + } + } + + static final class OreSourceCandidate { + final String sourceId; + final String owner; + final String ownerName; + final String ownerVersion; + final String registryId; + final int metadata; + final String channel; + final List blockTag; + final boolean loaded; + final boolean active; + final boolean external; + final boolean enrichment; + final boolean reviewRequired; + + OreSourceCandidate(String sourceId, String owner, String ownerName, String ownerVersion, + String registryId, int metadata, String channel, List blockTag, + boolean loaded, boolean active, boolean external, boolean enrichment, boolean reviewRequired) { + this.sourceId = sourceId; + this.owner = owner; + this.ownerName = ownerName; + this.ownerVersion = ownerVersion; + this.registryId = registryId; + this.metadata = metadata; + this.channel = channel; + this.blockTag = Collections.unmodifiableList(new ArrayList<>(blockTag)); + this.loaded = loaded; + this.active = active; + this.external = external; + this.enrichment = enrichment; + this.reviewRequired = reviewRequired; + } + } + void resetEntry(String section, String id) { JsonObject originalSection = object(original, section); if (originalSection.has(id)) { @@ -392,6 +1237,11 @@ void setPlaceFluidDeposits(boolean enabled) { List installedBiomeIds() { List result = new ArrayList<>(); + if (loadedBiomes != null) { + for (ResourceLocation id : loadedBiomes.keySet()) result.add(id.toString()); + Collections.sort(result); + return result; + } for (net.minecraft.world.level.biome.Biome biome : ForgeRegistries.BIOMES.getValues()) { ResourceLocation id = ForgeRegistries.BIOMES.getKey(biome); if (id != null) result.add(id.toString()); @@ -400,6 +1250,164 @@ List installedBiomeIds() { 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 = ResourceLocation.parse(id); + return loadedBiomes == null ? ForgeRegistries.BIOMES.getValue(key) != null + : 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( @@ -615,6 +1623,10 @@ List geomeIds() { List configuredBiomeIds() { TreeSet ids = new TreeSet<>(section("biomes").keySet()); + if (loadedBiomes != null) { + for (ResourceLocation id : loadedBiomes.keySet()) ids.add(id.toString()); + return new ArrayList<>(ids); + } for (net.minecraft.world.level.biome.Biome biome : ForgeRegistries.BIOMES.getValues()) { ResourceLocation id = ForgeRegistries.BIOMES.getKey(biome); if (id != null) { @@ -636,7 +1648,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; } @@ -721,8 +1733,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) { @@ -756,14 +1770,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() @@ -813,7 +1830,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")) { @@ -826,6 +1844,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()) { @@ -835,7 +1855,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")) { @@ -852,7 +1873,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; } @@ -886,7 +1909,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")) { @@ -904,6 +1928,8 @@ List validate() { continue; } JsonObject palette = entry.getValue().getAsJsonObject(); + boolean replacementPalette = BiomeReplacementRules.isOverridePalette(entry.getKey()); + boolean orphanedProvider = orphanedProviderDefinition(palette); if (!validResource(string(palette, "dimension", ""))) { errors.add("Invalid biome palette dimension: " + entry.getKey()); } @@ -912,7 +1938,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; @@ -933,16 +1961,19 @@ > decimal(placement, "max_downfall", 1.0D)) { continue; } JsonObject materials = entry.getValue().getAsJsonObject(); + boolean orphanedProvider = orphanedProviderDefinition(materials); if (!validResource(string(materials, "dimension", ""))) { errors.add("Invalid dimension materials target: " + entry.getKey()); } for (String key : new String[] { "default_fluid", "deep_aquifer_fluid" }) { - if (materials.has(key) && !validFluidBlock(string(materials, key, ""))) { + if (materials.has(key) && !validFluidBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid fluid material in " + entry.getKey()); } } for (String key : new String[] { "snow_block", "ice_block" }) { - if (materials.has(key) && !validBlock(string(materials, key, ""))) { + if (materials.has(key) && !validBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid weather material in " + entry.getKey()); } } @@ -950,6 +1981,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); @@ -1008,6 +2095,11 @@ private static boolean validIdArray(JsonElement element) { } private static boolean validBlockArray(JsonElement element, List errors, String oreId) { + return validBlockArray(element, errors, oreId, false); + } + + private static boolean validBlockArray(JsonElement element, List errors, String oreId, + boolean allowMissingProviderBlocks) { if (element == null || !element.isJsonArray() || element.getAsJsonArray().size() == 0) return false; boolean found = false; for (JsonElement value : element.getAsJsonArray()) { @@ -1019,6 +2111,10 @@ private static boolean validBlockArray(JsonElement element, List errors, continue; } if (!validBlock(id)) { + if (allowMissingProviderBlocks && validResource(id)) { + found = true; + continue; + } errors.add("Unknown host block '" + id + "' for " + oreId); continue; } @@ -1027,6 +2123,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); @@ -1175,8 +2280,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() { @@ -1197,15 +2301,68 @@ private static ResourceLocation blockId(Block block) { return id != null ? id : ForgeRegistries.BLOCKS.getKey(block); } - private static boolean knownBiome(String id) { + private boolean knownBiome(String id) { if (!validResource(id)) return false; + if (loadedBiomes != null) return loadedBiomes.containsKey(ResourceLocation.parse(id)); // Biomes are dynamic in 1.21.1. Before the live registry is available, // retain syntactically valid provider IDs for server-side resolution. return ForgeRegistries.BIOMES.getKeys().isEmpty() || ForgeRegistries.BIOMES.containsKey(ResourceLocation.parse(id)); } + private static boolean validBlockTagName(String name) { + if (name == null || name.length() > 128 || !validResource(name)) return false; + String path = ResourceLocation.parse(name).getPath(); + return path.startsWith("ores/") && path.length() > "ores/".length(); + } + + private static String oreTagMaterial(String tag) { + return ResourceLocation.parse(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 { ResourceLocation.parse(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 1f2efa70..4de8b4a9 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java @@ -5,10 +5,10 @@ import zone.moddev.mc.orespawn.client.GeologyEditorSession.MaterialTab; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; @@ -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"); @@ -177,8 +185,6 @@ public void onClose() { @Override protected void renderForeground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - graphics.drawCenteredString(font, title, width / 2, 10, OreSpawnScreenLayout.TEXT_PRIMARY); - } } 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..d39e8868 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -0,0 +1,380 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.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); + String selected = group.placements.get(channel); + int choice = OreSourceListScreen.indexOf(choices, selected); + String source = choice < 0 + ? I18n.get("label.orespawn.ore_source.missing") + : OreSourceListScreen.candidateLabel(choices.get(choice)); + return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + + placementSourceLabel(source, choices.size()); + } + @Override protected boolean rowEnabled(int index) { + String channel = channels.get(index); + return placementSelectable(group, channel); + } + @Override protected int rowColor(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + return choices.isEmpty() ? 0x777777 + : placementSelectable(group, channel) ? 0xFFFFFF : 0xC0C0C0; + } + @Override protected void onRowPressed(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + if (choices.size() <= 1) return; + int selected = OreSourceListScreen.indexOf(choices, group.placements.get(channel)); + OreSourceCandidate next = choices.get((selected + 1 + choices.size()) % choices.size()); + syncName(); + session.setOreSourcePlacement(group.key, channel, next.sourceId); + captureScroll(); + rebuild(); + } + @Override protected List rowTooltip(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + List lines = new ArrayList<>(); + lines.add(channelLabel(channel) + " (" + channel + ")"); + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_rules")); + if ("keep_separate".equals(group.mode)) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_requires_consolidated")); + } else if (choices.isEmpty()) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_missing")); + } else if (choices.size() == 1) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_readonly")); + } else { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_select")); + } + lines.add(I18n.get("tooltip.orespawn.ore_source.external_excluded")); + return lines; + } + }); + placementList.setFirstIndex(placementScroll); + + int doneWidth = doneButtonWidth(contentWidth); + addButton(new Button(contentX + contentWidth - doneWidth, height - 28, doneWidth, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void handleGroupAction(OreSourceGroup group) { + syncName(); + pendingAliasMove = null; + pendingAliasValue = null; + if (group.curated) { + session.resetOreMaterialGroup(group.material); + pendingDissolve = false; + error = null; + rebuild(); + return; + } + if (group.canDeleteEmpty()) { + if (session.deleteEmptyOreMaterialGroup(group.material)) minecraft.setScreen(parent); + return; + } + if (!group.canDissolve()) return; + if (!pendingDissolve) { + pendingDissolve = true; + error = I18n.get("error.orespawn.ore_source.confirm_dissolve"); + rebuild(); + return; + } + if (session.dissolveOreMaterialGroup(group.material)) minecraft.setScreen(parent); + } + + private void addAlias(OreSourceGroup group) { + syncName(); + String alias = aliasField == null ? "" : aliasField.getValue().trim(); + String owner = session.blockTagOwner(alias); + String move = owner == null || owner.equals(group.material) ? null : alias + '|' + owner; + if (move != null && session.vanillaOreManagementRequiredForAliasMove(group.material, alias)) { + pendingDissolve = false; + pendingAliasMove = null; + pendingAliasValue = alias; + error = I18n.get("error.orespawn.ore_source.manage_vanilla_first"); + rebuild(); + return; + } + if (move != null && !move.equals(pendingAliasMove)) { + pendingDissolve = false; + pendingAliasMove = move; + pendingAliasValue = alias; + error = I18n.get("error.orespawn.ore_source.alias_owned", + OreSourceListScreen.display(owner)); + rebuild(); + return; + } + if (!session.addOreMaterialAlias(group.material, alias, move != null)) { + error = I18n.get("error.orespawn.ore_source.alias"); + return; + } + pendingAliasMove = null; + pendingAliasValue = null; + pendingDissolve = false; + error = null; + rebuild(); + } + + private OreSourceGroup selectedGroup() { + for (OreSourceGroup group : session.oreSourceGroups()) { + if (groupKey.equals(group.key)) return group; + } + return null; + } + + private void syncName() { + OreSourceGroup group = selectedGroup(); + if (group != null && nameField != null) { + session.renameOreMaterialGroup(group.material, nameField.getValue()); + } + } + + private void captureScroll() { + if (aliasList != null) aliasScroll = aliasList.firstIndex(); + if (placementList != null) placementScroll = placementList.firstIndex(); + } + + private void rebuild() { + clearWidgets(); + init(); + } + + static int aliasListHeight(int height) { + return height >= 260 ? 80 : 64; + } + + static int doneButtonWidth(int contentWidth) { + return Math.min(120, Math.max(80, contentWidth / 4)); + } + + static int validationMessageWidth(int contentWidth) { + return Math.max(40, contentWidth - doneButtonWidth(contentWidth) - 8); + } + + static List placementChannels(OreSourceGroup group) { + Set channels = new TreeSet<>(group.channels()); + channels.addAll(group.placements.keySet()); + return new ArrayList<>(channels); + } + + static String channelLabel(String channel) { + return "orespawn:standard".equals(channel) + ? I18n.get("label.orespawn.ore_source.standard_veins") + : OreSourceListScreen.display(channel); + } + + static String placementSourceLabel(String source, int choices) { + return choices > 1 + ? I18n.get("label.orespawn.ore_source.placement_choice_count", source, choices) + : source; + } + + static boolean placementSelectable(OreSourceGroup group, String channel) { + return !"keep_separate".equals(group.mode) + && OreSourceListScreen.placementCandidates(group, channel).size() > 1; + } + + static String placementStatus(OreSourceGroup group, List channels) { + if (channels.isEmpty()) return I18n.get("label.orespawn.ore_source.no_placement_rules"); + if ("keep_separate".equals(group.mode)) { + return I18n.get("tooltip.orespawn.ore_source.placement_requires_consolidated"); + } + boolean selectable = false; + for (String channel : channels) { + int choices = OreSourceListScreen.placementCandidates(group, channel).size(); + if (choices == 0) return I18n.get("tooltip.orespawn.ore_source.placement_missing"); + selectable |= choices > 1; + } + return I18n.get(selectable ? "tooltip.orespawn.ore_source.placement_select" + : "tooltip.orespawn.ore_source.placement_readonly"); + } + + @Override + public void onClose() { + syncName(); + captureScroll(); + minecraft.setScreen(parent); + } + + @Override + 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..153343a1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java @@ -0,0 +1,124 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import zone.moddev.mc.orespawn.OreSpawn; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo; + +import net.minecraftforge.fml.ModList; +import net.minecraftforge.forgespi.language.IModInfo; + +/** Builds the immutable, loaded-only model rendered by the OreSpawn Mods screen. */ +final class OreSpawnModDirectoryModel { + private OreSpawnModDirectoryModel() { + } + + static List snapshot() { + List loaded = new ArrayList<>(); + for (IModInfo mod : ModList.get().getMods()) { + loaded.add(new LoadedMod(mod.getModId(), mod.getDisplayName(), + mod.getVersion().toString())); + } + return assemble(loaded, WorldgenIntegrationManager.providerIntegrations(), + WorldSettingsExtensionRegistry.extensions()); + } + + static List assemble(List loadedMods, + List providers, List extensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedMod mod : loadedMods) { + if (mod != null && mod.modId != null) loaded.put(mod.modId, mod); + } + Map integrations = new LinkedHashMap<>(); + for (ProviderIntegrationInfo provider : providers) { + if (isVisible(provider.modId(), loaded)) { + integrations.computeIfAbsent(provider.modId(), ignored -> new MutableEntry()) + .provider = provider; + } + } + for (WorldSettingsExtension extension : extensions) { + String modId = extension.id().getNamespace(); + if (isVisible(modId, loaded)) { + integrations.computeIfAbsent(modId, ignored -> new MutableEntry()) + .extension = extension; + } + } + + List result = new ArrayList<>(); + for (Map.Entry integration : integrations.entrySet()) { + LoadedMod mod = loaded.get(integration.getKey()); + result.add(new Entry(mod.modId, nonBlank(mod.displayName, mod.modId), + nonBlank(mod.version, "?"), integration.getValue().provider, + integration.getValue().extension)); + } + result.sort(Comparator.comparing((Entry entry) -> entry.displayName.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.modId)); + return Collections.unmodifiableList(result); + } + + private static boolean isVisible(String modId, Map loaded) { + return modId != null && !OreSpawn.MODID.equals(modId) && loaded.containsKey(modId); + } + + private static String nonBlank(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value; + } + + static final class LoadedMod { + final String modId; + final String displayName; + final String version; + + LoadedMod(String modId, String displayName, String version) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + } + } + + static final class Entry { + final String modId; + final String displayName; + final String version; + final ProviderIntegrationInfo provider; + final WorldSettingsExtension extension; + + Entry(String modId, String displayName, String version, + ProviderIntegrationInfo provider, WorldSettingsExtension extension) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + this.provider = provider; + this.extension = extension; + } + + boolean configurable() { return extension != null; } + boolean nativeOs4() { return provider != null && provider.nativeOs4(); } + int schemaVersion() { return provider == null ? -1 : provider.schemaVersion(); } + int providerRevision() { return provider == null ? -1 : provider.providerRevision(); } + List legacyLineages() { + return provider == null ? Collections.emptyList() : provider.legacyLineages(); + } + String statusKey() { + if (provider == null) return "config_only"; + if (provider.rejected()) return "rejected"; + ProviderStatus status = provider.status(); + return status == null ? "inactive" : status.name().toLowerCase(Locale.ROOT); + } + } + + private static final class MutableEntry { + ProviderIntegrationInfo provider; + WorldSettingsExtension extension; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java new file mode 100644 index 00000000..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..136c53c8 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. + } + + 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