diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index c739387..9066817 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -588,6 +588,11 @@ jobs: with: curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} curseforge-id: ${{ needs.preflight.outputs.curseforge_project_id }} + # A timeout can follow an accepted upload. Check the file before retrying. + curseforge-retry-attempts: 1 + curseforge-dependencies: | + 1677588(required) + 1754162(required) name: Sky's Terrain Smoother ${{ needs.preflight.outputs.release_version }} for Minecraft ${{ needs.preflight.outputs.minecraft_version }} (${{ needs.preflight.outputs.loader_display }}) version: ${{ needs.preflight.outputs.release_version }} version-type: ${{ inputs.curseforge_release_level }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ace208e..c7b3b8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,20 @@ ## 0.1.0.112021 -- Added Minecraft 1.12.2 support for surface and cave smoothing. -- Kept all three levels, matching sand caps and existing-world protections. -- Retained optional Mineralogy support without adding duplicate rock pieces. +- Added ingredient-based recipe unlocks, including slab recombination. + +- Added Minecraft 1.12.2 support for new surface terrain and cave floors. +- Made level 3 the default for new configurations. Existing choices are kept. +- Used Building Pieces' built-in Mineralogy steps and corners at levels 2 and 3; + no separate Mineralogy add-on is needed. +- Kept exposed grass beneath steps and corners, including snowy transitions. +- Improved narrow ledges without recreating full-block jumps. Fitting steps and + corners can still smooth mixed uphill and downhill edges. +- Let thin snow, grass and flowers give way to valid transitions in new terrain. + Deeper snow, trees, crops and existing saved chunks stay untouched. +- Added the Mods Config screen, help text and separate Mineralogy test profiles. +- Updated the dependency pins to released Grass Slabs 1.1.2.112021 and + Building Pieces 0.3.8.112021, including the Eclipse development setup. ## 0.1.0.110021 diff --git a/README.md b/README.md index c7b2534..232a3d2 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,21 @@ sandstone, sand and hardened clay. Set `worldgen.smoothingLevel` in `config/skysterrainsmoother.cfg`: -1. **Slabs** — half-block transitions. -2. **Slabs and steps** — adds pieces covering half a slab's footprint. -3. **Slabs, steps and corners** — also uses pieces covering a quarter of a +1. **Slabs** - half-block transitions. +2. **Slabs and steps** - adds pieces covering half a slab's footprint. +3. **Slabs, steps and corners** - also uses pieces covering a quarter of a slab's footprint. Every smoothing piece is half a block tall. If a material doesn't have steps -or corners, it still uses its slab at higher levels. +or corners, it uses its slab at higher levels wherever a slab fits safely. + +Narrow mountain ledges keep their downhill edge at its original height, rather +than gaining a row of slabs that recreates a full-block step. Wider terraces +still get transitions. At levels 2 and 3, the engine keeps a fitting uphill step +or corner even when a whole slab would raise the downhill edge. + +A single layer of snow doesn't block an otherwise valid transition. Deeper +snow, trees, crops and occupied blocks stay untouched. Only new, dry terrain is smoothed. Existing chunks, underwater floors and cave ceilings stay unchanged. Cave passages need three clear blocks above the floor, @@ -29,9 +37,9 @@ blocks in a row make six slabs, and two slabs side by side make a full block. Like sand blocks, the slabs fall when unsupported. Sand uses slabs at every smoothing level. -Mineralogy is supported without another add-on. When it's installed, available -natural rock slabs are used by default. It doesn't get new steps or corners -from this mod, and disabled slab types are skipped. +Mineralogy is supported without another add-on. When it's installed, the +smoother uses its natural rock slabs and the matching steps and corners +included in Building Pieces. Disabled native slab types are skipped. Ores, gravel, wood and decorative stone aren't smoothing materials. @@ -42,21 +50,30 @@ For BOP grass and soils, add the For **Minecraft 1.12.2**, use **Forge 14.23.5.2859** and **Java 8**, with: -- Sky's Grass Slabs **1.1.0.112021**. -- Sky's Building Pieces **0.3.6.112021**. +- Sky's Grass Slabs **1.1.2.112021**. +- Sky's Building Pieces **0.3.8.112021**. - Sky's Terrain Smoother **0.1.0.112021**. Install all three main jars on both the client and server. -Smoothing starts at level 1. Stop the game or server before editing the -configuration, then restart. You can turn smoothing off or disable Mineralogy -support; see [configuration](docs/CONFIGURATION.md). +New configurations start at level 3. Existing saved choices are kept. +Select the mod in **Mods > Config** to choose a +level, turn smoothing off or disable Mineralogy support, then restart Minecraft. +For a dedicated server, edit its configuration file and restart the server. +See [configuration](docs/CONFIGURATION.md). While installed, Terrain Smoother handles grass smoothing instead of Grass Slabs. Turning its master switch off stops both passes. Grass Slabs' own `worldgen.generateGrassSlabs` setting still decides whether grass is included. Removing Terrain Smoother restores Grass Slabs' standalone generation. +## Mineralogy steps and corners + +Building Pieces 0.3.7 or later includes Mineralogy rock steps and corners. +Install Mineralogy alongside the usual Sky mods; no separate Mineralogy +add-on is needed. Level 1 uses native slabs, level 2 also uses steps and +level 3 also uses corners. Your Mineralogy support setting still applies. + ## Existing worlds Back up worlds before changing their mods. Turning smoothing off doesn't remove diff --git a/build.gradle b/build.gradle index 83c6da1..bb90f98 100644 --- a/build.gradle +++ b/build.gradle @@ -61,10 +61,20 @@ tasks.withType(JavaCompile).configureEach { def grassSlabsDirectory = file(providers.gradleProperty('grassSlabsDirectory').getOrElse('../..' + '/SkysGrassSlabs112/SkysGrassSlabs')) def buildingPiecesDirectory = file(providers.gradleProperty('buildingPiecesDirectory').getOrElse('../..' + '/SkysBuildingPieces112/SkysBuildingPieces')) -def grassSlabsDevelopmentJar = new File(grassSlabsDirectory, 'build/libs-dev/SkysGrassSlabs-1.1.0.112021-deobf.jar') def contentLock=new Properties() file('gradle/content-dependencies.properties').withInputStream { contentLock.load(it) } +def localGrassVersion = providers.gradleProperty('grassSlabsVersion') +if (localGrassVersion.isPresent() && + (System.getenv('CI') != null || !providers.gradleProperty('grassSlabsDirectory').isPresent())) { + throw new GradleException('A local grass-version override needs an explicit directory and is not allowed in CI.') +} +def grassVersion = localGrassVersion.getOrElse(contentLock.getProperty('grass.version')) + + +def grassSlabsDevelopmentJar = new File(grassSlabsDirectory, + "build/libs-dev/SkysGrassSlabs-${grassVersion}-deobf.jar") + def localPiecesVersion = providers.gradleProperty('buildingPiecesCoreVersion') if (localPiecesVersion.isPresent() && (System.getenv('CI') != null || !providers.gradleProperty('buildingPiecesDirectory').isPresent())) { @@ -78,9 +88,14 @@ def verifyContentDependencies=tasks.register('verifyContentDependencies') { [grass:grassSlabsDirectory,pieces:buildingPiecesDirectory].each { name,directory -> Properties metadata=new Properties() new File(directory,'gradle.properties').withInputStream { metadata.load(it) } - String expectedVersion = name == 'pieces' ? piecesVersion : contentLock.getProperty(name + '.version') - if(metadata.mod_version!=expectedVersion) - throw new GradleException('Content dependency version does not match its lock: '+name) + String expectedVersion = name == 'grass' ? grassVersion : name == 'pieces' ? piecesVersion : contentLock.getProperty(name + '.version') + if (metadata.mod_version != expectedVersion) { + String localHelp = name == 'pieces' && System.getenv('CI') == null ? + ' For a local candidate, supply both -PbuildingPiecesDirectory=' + + ' and -PbuildingPiecesCoreVersion=, then regenerate the Eclipse runs.' : '' + throw new GradleException("Content dependency version does not match its lock: ${name}" + + " (expected ${expectedVersion}, found ${metadata.mod_version})." + localHelp) + } if(System.getenv('CI')!=null) { Process git=new ProcessBuilder('git','-C',directory.path,'rev-parse','HEAD').start() String sha=git.inputStream.getText('UTF-8').trim() @@ -147,6 +162,7 @@ minecraft { runs { configureEach { workingDir.convention layout.projectDirectory.dir(developmentRunDirectory) + environment 'MC_VERSION', project.minecraft_version.toString() systemProperty 'forge.logging.console.level', 'debug' systemProperty 'fml.coreMods.load', 'zone.moddev.mc.skysbuildingpieces.legacy.LoaderPlugin' } @@ -588,6 +604,7 @@ ext.releaseFiles = releaseFiles ext.releaseJar = releaseJar apply from: 'gradle/release.gradle' + // Keep the old entry point disabled until publication is configured. tasks.register('publishRelease') { doLast { throw new GradleException('Publication is disabled for the local beta.') } @@ -597,8 +614,27 @@ idea { module { downloadSources = true; downloadJavadoc = true } } eclipse { - classpath { downloadSources = true; downloadJavadoc = true } - synchronizationTasks 'genEclipseRuns' + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { classpath -> + // Eclipse copies these files on every build. Use Gradle's expanded metadata, never the template. + def resources = classpath.entries.findAll { + it instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder && + it.path in ['src/main/resources', 'build/resources/main'] + } + // Keep one entry when Gradle merges an existing Eclipse classpath. + resources.drop(1).each { classpath.entries.remove(it) } + resources.take(1).each { entry -> + entry.path = 'build/resources/main' + } + } + } + synchronizationTasks 'processResources', 'genEclipseRuns' +} + +tasks.named('eclipseClasspath') { + dependsOn tasks.named('processResources'), verifyContentDependencies } def prepareEclipseResources = tasks.register('prepareEclipseResources') { @@ -626,6 +662,20 @@ def configureEclipseBuildship = tasks.register('configureEclipseBuildship') { 'gradle.user.home': gradle.gradleUserHomeDir.canonicalPath, 'override.workspace.settings': 'true' ].each { key, value -> preferences.setProperty(key, value) } + // Refreshes must use the same checkouts, without retaining an old candidate override. + List arguments = preferences.getProperty('arguments', '').tokenize(' ') + arguments.removeAll { + it.startsWith('-PgrassSlabsDirectory=') || it.startsWith('-PgrassSlabsVersion=') || + it.startsWith('-PbuildingPiecesDirectory=') || + it.startsWith('-PbuildingPiecesCoreVersion=') + } + arguments.add('-PgrassSlabsDirectory=' + relativePath(grassSlabsDirectory)) + if (localGrassVersion.isPresent()) arguments.add('-PgrassSlabsVersion=' + grassVersion) + arguments.add('-PbuildingPiecesDirectory=' + relativePath(buildingPiecesDirectory)) + if (localPiecesVersion.isPresent()) { + arguments.add('-PbuildingPiecesCoreVersion=' + piecesVersion) + } + preferences.setProperty('arguments', arguments.join(' ')) preferencesFile.parentFile.mkdirs() preferencesFile.withOutputStream { preferences.store(it, 'Generated Buildship configuration.') } } @@ -639,6 +689,7 @@ tasks.register('isolateEclipseProductionRuns') { File launch = file(name) if (!launch.isFile()) throw new GradleException("ForgeGradle did not generate ${name}") String contents = launch.getText('UTF-8') + contents = contents.replace('${MC_VERSION}', project.minecraft_version.toString()) boolean production = name in ['runClient.launch', 'runServer.launch'] if (production && !contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { contents = contents.replace('', @@ -677,7 +728,36 @@ tasks.register('verifyEclipseProductionClasspath') { if (file(prefs.getProperty('connection.gradle.user.home', '')).canonicalFile != gradle.gradleUserHomeDir.canonicalFile) { throw new GradleException('Eclipse Buildship uses the wrong Gradle home') } + List arguments = prefs.getProperty('arguments', '').tokenize(' ') + if (!arguments.contains('-PgrassSlabsDirectory=' + relativePath(grassSlabsDirectory)) || + !arguments.contains('-PbuildingPiecesDirectory=' + relativePath(buildingPiecesDirectory))) { + throw new GradleException('Eclipse Buildship lost the selected content checkouts') + } + List overrides = arguments.findAll { it.startsWith('-PbuildingPiecesCoreVersion=') } + List expectedOverrides = localPiecesVersion.isPresent() ? + ['-PbuildingPiecesCoreVersion=' + piecesVersion] : [] + if (overrides != expectedOverrides) { + throw new GradleException('Eclipse Buildship retained an incorrect Building Pieces version override') + } File metadata = file('bin/main/mcmod.info') + def eclipseClasspath = new groovy.xml.XmlSlurper().parse(file('.classpath')) + def piecesLibraries = eclipseClasspath.classpathentry.findAll { + it.@kind == 'lib' && it.@path.text().tokenize('/\\').last().startsWith('SkysBuildingPieces-') + }*.@path*.text().collect { file(it).canonicalFile } + if (piecesLibraries != [buildingPiecesDevelopmentJar.canonicalFile]) { + throw new GradleException('Eclipse must use only the selected Building Pieces development jar') + } + def grassLibraries = eclipseClasspath.classpathentry.findAll { + it.@kind == 'lib' && it.@path.text().tokenize('/\\').last().startsWith('SkysGrassSlabs-') + }*.@path*.text().collect { file(it).canonicalFile } + if (grassLibraries != [grassSlabsDevelopmentJar.canonicalFile]) { + throw new GradleException('Eclipse must use only the pinned Grass Slabs development jar') + } + def resourceFolders = eclipseClasspath.classpathentry.findAll { it.@kind == 'src' }*.@path*.text() + if (resourceFolders.contains('src/main/resources') || + resourceFolders.count { it == 'build/resources/main' } != 1) { + throw new GradleException('Eclipse must copy processed resources, not overwrite them with templates') + } def parsed = new JsonSlurper().parseText(metadata.getText('UTF-8')) if (parsed[0].version != project.mod_version || parsed[0].mcversion != project.minecraft_version) { throw new GradleException('Eclipse output contains incorrect mcmod.info') @@ -718,3 +798,10 @@ tasks.register('integrationClientReloadProcess', JavaExec) { if(!marker.isFile() || !marker.text.contains('reload_complete=true')) throw new GradleException('Integrated reload client did not complete') } } + +ext.configureMineralogyVerificationRun = configureFromForgeRun +ext.mineralogyProfileProbeId = 'skysterrainsmoother' +ext.mineralogyProfileAdditionalMods = files() +apply from: 'gradle/mineralogy-runs.gradle' + +apply from: 'gradle/recipe-advancements.gradle' diff --git a/docs/API.md b/docs/API.md index a4acd8c..f759c24 100644 --- a/docs/API.md +++ b/docs/API.md @@ -20,8 +20,9 @@ TerrainSmoothingApi.registerMaterial(new TerrainMaterial( `addShapes(materialId, steps, corners)` supplies missing forms on an existing mapping. It cannot replace that mapping's slab or existing finer forms. A -Mineralogy add-on can therefore extend a built-in `mineralogy:` mapping -without replacing its native slab. Do not assume the mapping exists when the +material add-on can therefore extend an existing mapping without replacing +its native slab. Mineralogy already gets its finer pieces from Building Pieces, +without an add-on. Do not assume a Mineralogy mapping exists when the player has disabled Mineralogy support or its slab registration. Use `hasMaterial(materialId)` to check before contributing. The Mineralogy setting also applies to mappings supplied by add-ons for Mineralogy source blocks. @@ -44,7 +45,9 @@ apply at each floor's actual position. Ores remain outside the source catalogue, but a late ore replacement beneath a generated piece is preserved. Grass-like material mappings should provide their full dirt support state in -`supportAfterPlacement`. Register matching content orientations through the +`supportAfterPlacement`. This conversion applies only when the output covers +the whole footprint. Steps and corners retain their original support so exposed +grass remains visible. Register matching content orientations through the Grass Slabs version 1 grass API if their lifecycle should participate in its shared spreading rules. Content mods still own placement, drops, rendering and migration. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 1fab42f..3a6a6e2 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -7,6 +7,9 @@ Minecraft 1.12.2 with ForgeGradle 7.0.34. Build the exact Grass Slabs and Building Pieces source commits listed in `gradle/content-dependencies.properties` with their `jar` tasks first. +The source lock records released Grass Slabs 1.1.2.112021 and Building Pieces +0.3.8.112021. + The default layout uses sibling checkouts. For another layout, pass `-PgrassSlabsDirectory=` and `-PbuildingPiecesDirectory=`. @@ -22,7 +25,55 @@ pinned commits rather than moving branches. A local-only `buildingPiecesCoreVersion` override can test another core candidate. It requires an explicit core directory and is rejected in CI. +Eclipse's ignored Buildship preferences retain the selected checkouts. A version +override is saved only when explicitly supplied; normal generation clears any +older override. Refresh the Gradle project in Eclipse after generating the runs. Use `genEclipseRuns eclipse` to prepare the Java 8 IDE setup. Ordinary launches -exclude unit tests and build-only runtime probes. Publication remains disabled -until this mod has its own CurseForge project. +exclude unit tests and build-only runtime probes. See [the release guide](RELEASING.md) +for publication. + +## Testing with Mineralogy + +Run `gradlew genEclipseRuns eclipse`, then refresh the Eclipse project. Select +`runMineralogyClient` under Java Application in Run Configurations. The matching +`runMineralogyServer` profile is available for a dedicated server. From a terminal, +use `gradlew runMineralogyClient` or `gradlew runMineralogyServer`. + +These profiles use a separate `run-mineralogy` folder and load Mineralogy plus +its OreSpawn dependency. +Ordinary client and server profiles keep their existing mod sets and worlds. +Runtime probes and unit tests are excluded from these manual profiles. + +The expected release jars and their checksums are listed in +`gradle/mineralogy-runtime.properties`. The default paths use sibling checkouts; +for a different layout, pass `-PmineralogyRuntimeJar=` and +`-PorespawnRuntimeJar=` when generating the profiles. Missing optional jars +do not prevent an ordinary build; `prepareMineralogyDevelopmentRun` explains +what is needed before the combined profile can run. The jars are copied, not +modified. Their access rules are translated for the mapped development game. + +If a sibling build has moved on, keep the qualified jars under +`.gradle/qualification-dependencies/` with their original filenames. +The profiles prefer those copies, but still require the locked checksums. +An explicit runtime-jar property takes precedence over either location. + +New configurations use smoothing level 3. An existing saved level is kept, so +choose level 3 in Mods > Config and restart if that test folder already has a +different setting. With Building Pieces 0.3.8, Mineralogy rocks use the core's +steps at level 2 and corners at level 3, without a separate add-on. + +`gradlew integrationMineralogyTest` checks fresh-world and reload behaviour in +a disposable build folder. `integrationMineralogyClientProcess` checks client +resources there. Neither task opens the manual profile's saved worlds. + +## Recipe-book resources + +The build generates unlock advancements from `src/main/recipe-book/recipes.json`. +The runtime checks compare those entries with the registered recipes and exercise +inventory discovery, crafting-table discovery and recipe-book persistence. +Optional catalogue entries load only when their recipe exists. + +Recipe unlocks have no shared advancement parent. Minecraft 1.12 walks that +visibility tree recursively when loading completed progress, which can overflow +with a large catalogue. The inventory and crafting unlock criteria are unchanged. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 907318b..b8c8e2f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,17 +1,28 @@ # Configuration -Edit `config/skysterrainsmoother.cfg` while the game or server is stopped. -All settings require a restart. +Open **Mods**, select **Sky's Terrain Smoother** and choose **Config**. You can +turn smoothing on or off, choose level 1–3 and enable or disable Mineralogy +support. Save with **Done**, then restart Minecraft to apply your choices. + +You can also edit `config/skysterrainsmoother.cfg` while the game or server is +stopped. On a dedicated server, edit the server's file and restart it; the +client's Config screen does not change a remote server. | Setting | Default | Effect | |---|---|---| | `worldgen.enabled` | `true` | Enables combined smoothing in new Overworld chunks. | -| `worldgen.smoothingLevel` | `1` | Slabs (1), slabs and steps (2), or slabs, steps and corners (3). | +| `worldgen.smoothingLevel` | `3` | Slabs (1), slabs and steps (2), or slabs, steps and corners (3). | | `compat.enableMineralogy` | `true` | Includes available natural Mineralogy rock slabs when installed. | -Levels outside 1–3 are constrained to that range. Missing finer shapes fall -back to slabs; disabled Mineralogy slabs are skipped. This mod adds no steps or -corners to Mineralogy. +Levels outside 1–3 are constrained to that range. +Existing level choices are preserved; the new default applies only when the +setting has not been saved yet. + +Missing finer shapes fall back to slabs where they fit without raising a +downhill edge. Otherwise that placement is left alone. +Disabled Mineralogy slabs are skipped. Building Pieces includes the matching +rock steps and corners when Mineralogy is installed, so levels 2 and 3 use +them automatically. No separate Mineralogy add-on is needed. The same level applies to dry surface slopes and cave floors in new chunks. There is no retrogen: saved caves are unchanged. Low passages, ceilings and diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fc47f75..5337f8b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -4,14 +4,14 @@ Pushes and pull requests run the build, tests, artifact audit, CodeQL and Gradle wrapper checks. CI uploads the main, sources and Javadoc jars with their checksums. Tags only validate a candidate; they never publish it. -Publication is currently disabled. Before the first release, create this mod's -CurseForge project, set its own `curseforge_project_id` in `gradle.properties`, -and set `release_enabled=true`. Do not reuse another mod's project ID. +This mod publishes to CurseForge project 1768953. Publication is enabled only +in MMD; a fork build never uploads a release. The MMD release workflow uses the organisation's `CURSEFORGE_TOKEN`, `MAVEN_UPLOAD_URL`, `MAVEN_UPLOAD_USERNAME` and `MAVEN_UPLOAD_PASSWORD`. It requires a passing CI build and explicit confirmation of live publication. Enter the full release version and choose the CurseForge release level. +Select this Minecraft branch when starting the workflow. The workflow resolves the Minecraft branch from the version, rebuilds the exact commit, and checks one immutable artifact bundle. It validates or creates the tag, @@ -21,3 +21,7 @@ before announcing a release. Content mods must be released before add-ons that require them. Keep dependency source pins up to date when preparing a coordinated release. + +CurseForge uploads have one attempt. If a file is processing or an upload times +out, check the project before retrying. Reuse the same verified bundle and rerun +only unfinished stages; do not submit a second copy. diff --git a/gradle.properties b/gradle.properties index 4dfd629..8f9e378 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,8 +16,8 @@ java_toolchain_version=8.0.502+7 gradle_java_version=17 mod_id=skysterrainsmoother release_repository=MinecraftModDevelopmentMods/SkysTerrainSmoother -release_enabled=false -curseforge_project_id= +release_enabled=true +curseforge_project_id=1768953 mod_name=Sky's Terrain Smoother mod_license=LGPL-2.1-only mod_version=0.1.0.112021 diff --git a/gradle/content-dependencies.properties b/gradle/content-dependencies.properties index 2eeb07d..3cac94c 100644 --- a/gradle/content-dependencies.properties +++ b/gradle/content-dependencies.properties @@ -1,6 +1,6 @@ -grass.repository=SkyBlade1978/SkysGrassSlabs -grass.commit=1b6069b5922d20285432bb3a76dbcfccd1b1d2d8 -grass.version=1.1.0.112021 -pieces.repository=SkyBlade1978/SkysBuildingPieces -pieces.commit=78360eefcf6f7637c7959f34dc45bdb9efb6ba12 -pieces.version=0.3.6.112021 +grass.repository=MinecraftModDevelopmentMods/SkysGrassSlabs +grass.commit=b2636d8a67efa3f257a8a69293ce513e979010fd +grass.version=1.1.2.112021 +pieces.repository=MinecraftModDevelopmentMods/SkysBuildingPieces +pieces.commit=af3ce9b73d215a2f9e27a11324da56a752e4edac +pieces.version=0.3.8.112021 diff --git a/gradle/development-log4j2.xml b/gradle/development-log4j2.xml new file mode 100644 index 0000000..1281087 --- /dev/null +++ b/gradle/development-log4j2.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/gradle/mineralogy-runs.gradle b/gradle/mineralogy-runs.gradle new file mode 100644 index 0000000..ede971a --- /dev/null +++ b/gradle/mineralogy-runs.gradle @@ -0,0 +1,290 @@ +import java.security.MessageDigest +import java.nio.file.Files + +// These are optional development mods, never compile or production dependencies. +def runtimeLock = new Properties() +file('gradle/mineralogy-runtime.properties').withInputStream { runtimeLock.load(it) } +def runtimeSources = ['mineralogy', 'orespawn'].collectEntries { mod -> + File siblingJar = file(runtimeLock.getProperty(mod + '.path')) + File retainedJar = file('.gradle/qualification-dependencies/' + siblingJar.name) + // Sibling builds can change during other work. A retained copy still has to + // pass the same checksum check before it can enter a development profile. + [(mod): file(providers.gradleProperty(mod + 'RuntimeJar') + .getOrElse((retainedJar.isFile() ? retainedJar : siblingJar).path))] +} +def extraMods = project.ext.mineralogyProfileAdditionalMods +def gameDirectory = layout.projectDirectory.dir( + providers.gradleProperty('mineralogyRunDirectory').getOrElse('run-mineralogy')) + +def configureVerification = project.ext.configureMineralogyVerificationRun + +// Legacy release jars declare SRG access rules. The mapped development game needs +// their MCP names before launch; the original optional jars remain untouched. +configurations { mineralogyDevelopmentMappings } +repositories { + maven { + name = 'MineralogyDevelopmentMappings' + url = 'https://maven.minecraftforge.net/' + metadataSources { artifact() } + content { includeModule('de.oceanlabs.mcp', 'mcp_stable') } + } +} +dependencies { + mineralogyDevelopmentMappings "de.oceanlabs.mcp:mcp_stable:${project.mapping_version}@zip" +} +if (runtimeSources.values().every { it.isFile() }) { + Map mappedMembers = [:] + new java.util.zip.ZipFile(configurations.mineralogyDevelopmentMappings.singleFile).withCloseable { mappings -> + ['fields.csv', 'methods.csv'].each { name -> + mappings.getInputStream(mappings.getEntry(name)).withCloseable { input -> + input.getText('UTF-8').readLines().drop(1).each { row -> + String[] columns = row.split(',', 3) + mappedMembers[columns[0]] = columns[1] + } + } + } + } + List rules = [] + runtimeSources.each { mod, source -> + new java.util.jar.JarFile(source).withCloseable { archive -> + String declared = archive.manifest?.mainAttributes?.getValue('FMLAT') + declared?.tokenize()?.each { name -> + def entry = archive.getJarEntry('META-INF/' + name) + if (entry == null) throw new GradleException("Missing ${mod} access rules: ${name}") + archive.getInputStream(entry).withCloseable { input -> + String mapped = input.getText('UTF-8').replaceAll(/\b(?:func|field)_\d+_[A-Za-z_]+\b/) { member -> + if (!mappedMembers.containsKey(member)) throw new GradleException("Unmapped optional access rule: ${member}") + mappedMembers[member] + } + rules.add(mapped) + } + } + } + } + File accessRules = file('.gradle/mineralogy-runtime/access-transformers.cfg') + accessRules.parentFile.mkdirs() + accessRules.setText(rules.join('\n') + '\n', 'UTF-8') + minecraft.accessTransformers.from(accessRules) +} +def developmentLogging = file('gradle/development-log4j2.xml').toURI().toString() + + +def installRuntimeMods = { + runtimeSources.each { mod, source -> + if (!source.isFile()) { + throw new GradleException("Missing optional ${mod} jar: ${source}. Set -P${mod}RuntimeJar=.") + } + String expected = runtimeLock.getProperty(mod + '.sha256') + String actual = MessageDigest.getInstance('SHA-256').digest(Files.readAllBytes(source.toPath())) + .encodeHex().toString() + if (!(expected ==~ /(?i)[a-f0-9]{64}/) || !actual.equalsIgnoreCase(expected)) { + throw new GradleException("The optional ${mod} jar does not match gradle/mineralogy-runtime.properties") + } + } + + File mods = new File(gameDirectory.asFile, 'mods') + Set managedNames = runtimeSources.values()*.name as Set + fileTree(mods).matching { include '*.jar' }.each { candidate -> + String name = candidate.name.toLowerCase(Locale.ROOT) + if ((name.startsWith('mineralogy-') || name.startsWith('orespawn-')) && + !managedNames.contains(candidate.name)) { + throw new GradleException("Remove the older optional mod from this profile first: ${candidate}") + } + if (name.contains('integration-test') || name.contains('-probe')) { + throw new GradleException("The ordinary Mineralogy profile must not contain test mods: ${candidate}") + } + } + // Copy only the declared jars. Never sync or clear a player's mods, config or saves. + copy { + from runtimeSources.values(), extraMods + into mods + } +} + +def prepareMineralogyDevelopmentRun = tasks.register('prepareMineralogyDevelopmentRun') { + group = 'ide' + description = 'Checks and installs the optional Mineralogy/OreSpawn jars in the separate game folder.' + doLast { installRuntimeMods() } +} + +def prepareAvailableMineralogyDevelopmentRun = tasks.register('prepareAvailableMineralogyDevelopmentRun') { + group = 'ide' + description = 'Prepares the Eclipse profile when the optional development jars are available.' + onlyIf { + boolean available = runtimeSources.values().every { it.isFile() } + if (!available) { + logger.lifecycle('Mineralogy profiles generated; supply the optional jars before using them. See docs/BUILDING.md.') + } + available + } + doLast { installRuntimeMods() } +} + +// Reuse the target's normal launcher, mappings, assets and merged production output. +tasks.register('runMineralogyClient', JavaExec) { + group = 'forgegradle runs' + description = 'Runs a separate client with Mineralogy and OreSpawn installed.' + dependsOn prepareMineralogyDevelopmentRun + workingDir gameDirectory + systemProperty 'log4j.configurationFile', developmentLogging + configureVerification(delegate, 'runClient') +} +tasks.register('runMineralogyServer', JavaExec) { + group = 'forgegradle runs' + description = 'Runs a separate server with Mineralogy and OreSpawn installed.' + dependsOn prepareMineralogyDevelopmentRun + workingDir gameDirectory + systemProperty 'log4j.configurationFile', developmentLogging + args '--nogui' + configureVerification(delegate, 'runServer') +} + +def generateMineralogyEclipseRuns = tasks.register('generateMineralogyEclipseRuns') { + group = 'ide' + dependsOn tasks.named('isolateEclipseProductionRuns') + doLast { + ['Client', 'Server'].each { side -> + File source = file("run${side}.launch") + if (!source.isFile()) throw new GradleException('Generate the ordinary Eclipse launches first') + def xml = new groovy.xml.XmlParser(false, false).parse(source) + xml.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value = gameDirectory.asFile.absolutePath + def vmArguments = xml.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.VM_ARGUMENTS' + } + vmArguments.@value = vmArguments.@value.toString() + ' -Dlog4j.configurationFile=' + developmentLogging + file("runMineralogy${side}.launch").setText(groovy.xml.XmlUtil.serialize(xml).replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.named('genEclipseRuns') { + finalizedBy prepareAvailableMineralogyDevelopmentRun, generateMineralogyEclipseRuns +} + +tasks.named('verifyEclipseProductionClasspath') { + doLast { + ['runMineralogyClient.launch', 'runMineralogyServer.launch'].each { name -> + File launch = file(name) + def xml = new groovy.xml.XmlSlurper().parse(launch) + String workingDirectory = xml.stringAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value.text() + String contents = launch.getText('UTF-8').replace('\\', '/') + if (file(workingDirectory).canonicalFile != gameDirectory.asFile.canonicalFile || + !contents.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE') || + contents.contains('bin/test') || contents.contains('src/test') || contents.contains('junit-')) { + throw new GradleException("Mineralogy launch is not isolated from ordinary runs and tests: ${name}") + } + } + } +} + +tasks.register('verifyMineralogyDevelopmentProfile') { + group = 'verification' + dependsOn prepareMineralogyDevelopmentRun, tasks.named('verifyEclipseProductionClasspath') + doLast { + File mods = new File(gameDirectory.asFile, 'mods') + Set expected = (runtimeSources.values() + extraMods.files)*.name as Set + Set actual = fileTree(mods).matching { include '*.jar' }.files*.name as Set + if (actual != expected) { + throw new GradleException("The reference Mineralogy profile has unexpected jars: ${actual - expected}") + } + } +} + +// Verification uses another directory. Probes never enter the manual Eclipse profile. +def probeId = project.ext.mineralogyProfileProbeId +def verificationDirectory = layout.buildDirectory.dir('mineralogy-integration-run') +def prepareMineralogyIntegrationTest = tasks.register('prepareMineralogyIntegrationTest') { + group = 'verification' + dependsOn prepareMineralogyDevelopmentRun, tasks.named('integrationTestModJar') + doLast { + File directory = verificationDirectory.get().asFile + File expected = new File(layout.buildDirectory.get().asFile, 'mineralogy-integration-run') + if (directory.canonicalFile != expected.canonicalFile) { + throw new GradleException('Refusing to clear a Mineralogy verification directory outside the build output') + } + project.delete(directory) + copy { + from new File(gameDirectory.asFile, 'mods') + from tasks.named('integrationTestModJar', Jar).flatMap { it.archiveFile } + into new File(directory, 'mods') + } + new File(directory, 'eula.txt').setText('eula=true\n', 'UTF-8') + ['banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'].each { name -> + new File(directory, name).setText('[]\n', 'UTF-8') + } + new File(directory, 'server.properties').setText('''\ +level-name=world +level-seed=4815162342 +level-type=default +online-mode=false +server-ip=127.0.0.1 +server-port=0 +generate-structures=false +spawn-animals=false +spawn-monsters=false +spawn-protection=0 +max-tick-time=120000 +'''.replace('level-type=default', + probeId == 'skysterrainsmootherbop' ? 'level-type=BIOMESOP' : 'level-type=default'), 'UTF-8') + } +} + +def fresh = tasks.register('integrationMineralogyFreshProcess', JavaExec) { + group = 'verification' + dependsOn prepareMineralogyIntegrationTest + workingDir verificationDirectory + systemProperty probeId + '.integrationPhase', 'fresh' + systemProperty 'log4j.configurationFile', developmentLogging + systemProperty 'skysterrainsmoother.requireMineralogy', 'true' + systemProperty 'skysterrainsmoother.testMineralogyShapes', 'false' + configureVerification(delegate, 'runServer') +} +def reload = tasks.register('integrationMineralogyReloadProcess', JavaExec) { + group = 'verification' + dependsOn fresh + workingDir verificationDirectory + systemProperty probeId + '.integrationPhase', 'reload' + systemProperty 'log4j.configurationFile', developmentLogging + systemProperty 'skysterrainsmoother.requireMineralogy', 'true' + systemProperty 'skysterrainsmoother.testMineralogyShapes', 'false' + configureVerification(delegate, 'runServer') +} + +tasks.register('integrationMineralogyTest') { + group = 'verification' + dependsOn reload + doLast { + File directory = verificationDirectory.get().asFile + File marker = new File(directory, "world/${probeId}-integration.properties") + Properties evidence = new Properties() + if (!marker.isFile()) throw new GradleException('Mineralogy integration marker is missing') + marker.withInputStream { evidence.load(it) } + if (evidence.getProperty('fresh_complete') != 'true' || evidence.getProperty('reload_complete') != 'true') { + throw new GradleException('Mineralogy fresh/reload verification did not complete') + } + if (!fileTree(new File(directory, 'crash-reports')).isEmpty()) { + throw new GradleException('Mineralogy verification produced a crash report') + } + } +} + +tasks.register('integrationMineralogyClientProcess', JavaExec) { + group = 'verification' + dependsOn prepareMineralogyIntegrationTest + workingDir verificationDirectory + systemProperty probeId + '.integrationPhase', 'client' + systemProperty 'log4j.configurationFile', developmentLogging + systemProperty 'skysterrainsmoother.requireMineralogy', 'true' + systemProperty 'skysterrainsmoother.testMineralogyShapes', 'false' + configureVerification(delegate, 'runClient') + doLast { + String marker = probeId == 'skysterrainsmoother' ? + 'terrain-smoother-client-pass.txt' : 'bop-terrain-client-pass.txt' + if (!new File(verificationDirectory.get().asFile, marker).isFile()) { + throw new GradleException('The Mineralogy client did not complete resource verification') + } + } +} diff --git a/gradle/mineralogy-runtime.properties b/gradle/mineralogy-runtime.properties new file mode 100644 index 0000000..1b673d9 --- /dev/null +++ b/gradle/mineralogy-runtime.properties @@ -0,0 +1,5 @@ +# Optional development runtime only. These jars are not bundled or required by the mod. +mineralogy.path=../../../MinecraftMineralogy/MinecraftMineralogy 112/MinecraftMineralogy/build/libs/Mineralogy-6.0.1.112021.jar +mineralogy.sha256=558AD3403486F3B5CF575494BDF0A36B9F5967EA8D58E94BDBC2C2B3B6AF5D22 +orespawn.path=../../../MinecraftMineralogy/OS112/OreSpawn/build/libs/OreSpawn-4.1.2.112021.jar +orespawn.sha256=50EE5C3453290AB5ECA6A87BD1FEC6F9A5A8DCC1D6F7632B9BFED9B991EA182D diff --git a/gradle/recipe-advancements.gradle b/gradle/recipe-advancements.gradle new file mode 100644 index 0000000..51bf68d --- /dev/null +++ b/gradle/recipe-advancements.gradle @@ -0,0 +1,46 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + +// Recipe IDs and exact ingredient variants are checked against the live registry. +def recipeBookManifest = layout.projectDirectory.file('src/main/recipe-book/recipes.json') +def recipeBookOutput = layout.buildDirectory.dir('generated/recipe-advancements') +def generateRecipeAdvancements = tasks.register('generateRecipeAdvancements') { + inputs.file(recipeBookManifest) + outputs.dir(recipeBookOutput) + doLast { + File output = recipeBookOutput.get().asFile + delete output + List entries = new JsonSlurper().parse(recipeBookManifest.asFile) as List + Set ids = new HashSet<>() + entries.each { entry -> + String recipe = entry.recipe + String owner = project.mod_id + if (entry.owner != owner || !ids.add(recipe) || entry.ingredients.isEmpty()) { + throw new GradleException("Invalid recipe-book entry: ${recipe}") + } + String path = recipe.split(':', 2)[1] + Map criteria = [:] + List requirements = [] + entry.ingredients.eachWithIndex { ingredient, index -> + String key = entry.ingredients.size() == 1 ? 'has_ingredient' : 'has_ingredient_' + index + criteria[key] = [trigger: 'minecraft:inventory_changed', conditions: [items: [ingredient]]] + requirements.add(key) + } + criteria.has_the_recipe = [trigger: 'minecraft:recipe_unlocked', conditions: [recipe: recipe]] + requirements.add('has_the_recipe') + // A shared parent makes 1.12 recurse through thousands of learned recipes on login. + // Independent hidden unlocks keep the same recipe-book behaviour without that recursion. + Map advancement = [rewards: [recipes: [recipe]], + criteria: criteria, requirements: [requirements]] + if (project.mod_id != 'skysgrassslabs') { + // Optional catalogues and recovery recipes must not leave phantom unlocks. + advancement.conditions = [[type: 'skysbuildingpieces:recipe_exists', recipe: recipe]] + } + File target = new File(output, "assets/${owner}/advancements/recipes/${path}.json") + target.parentFile.mkdirs() + target.setText(JsonOutput.prettyPrint(JsonOutput.toJson(advancement)) + '\n', 'UTF-8') + } + } +} +sourceSets.main.resources.srcDir(generateRecipeAdvancements) +tasks.named('processResources') { dependsOn generateRecipeAdvancements } diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java index 3cda16b..9656a95 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ClientProbe.java @@ -50,6 +50,9 @@ public void tick(TickEvent.ClientTickEvent event) { ran = true; try { + MineralogyProbe.run(); + FineGrassModelProbe.run(minecraft); + ConfigScreenProbe.run(minecraft); checkModel(minecraft, RuntimeProbe.probeOre.getDefaultState()); for (net.minecraft.block.state.IBlockState state : zone.moddev.mc.skysgrassslabs.init.ModBlocks.GRASS_SLAB diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ConfigScreenProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ConfigScreenProbe.java new file mode 100644 index 0000000..0cdc543 --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ConfigScreenProbe.java @@ -0,0 +1,93 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.common.config.Configuration; +import net.minecraftforge.fml.client.FMLClientHandler; +import net.minecraftforge.fml.client.IModGuiFactory; +import net.minecraftforge.fml.client.config.GuiConfig; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.Loader; +import zone.moddev.mc.skysgrassslabs.config.SkysGrassSlabsConfig; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; +import zone.moddev.mc.skysterrainsmoother.config.TerrainSmootherConfig; + +/** Opens the actual registered screens inside the disposable client, without taking desktop control. */ +final class ConfigScreenProbe { + static void run(Minecraft minecraft) { + ModMenuProbe.run(minecraft); + for (String modId : new String[] { "skysgrassslabs", "skysterrainsmoother" }) { + IModGuiFactory factory = FMLClientHandler.instance() + .getGuiFactoryFor(Loader.instance().getIndexedModList().get(modId)); + RuntimeProbe.require(factory != null, "Mods list has a config factory for " + modId); + RuntimeProbe.require(factory.hasConfigGui(), "Config button is enabled for " + modId); + GuiScreen screen = factory.createConfigGui(minecraft.currentScreen); + net.minecraft.client.gui.ScaledResolution resolution = new net.minecraft.client.gui.ScaledResolution(minecraft); + screen.setWorldAndResolution(minecraft, resolution.getScaledWidth(), resolution.getScaledHeight()); + GuiConfig configScreen = (GuiConfig) screen; + int expectedEntries = modId.equals("skysgrassslabs") ? 2 : 3; + RuntimeProbe.require(configScreen.configElements.size() == expectedEntries + && configScreen.entryList.listEntries.size() == expectedEntries + && configScreen.allRequireMcRestart, "all settings have widgets and require restart"); + for (net.minecraftforge.fml.client.config.IConfigElement element : configScreen.configElements) { + RuntimeProbe.require(!I18n.format(element.getLanguageKey()).equals(element.getLanguageKey()), + "setting has a readable translated label: " + element.getName()); + } + minecraft.getFramebuffer().bindFramebuffer(true); + minecraft.entityRenderer.setupOverlayRendering(); + screen.drawScreen(0, 0, 0); + net.minecraft.util.ScreenShotHelper.saveScreenshot(minecraft.gameDir, modId + "-config.png", + minecraft.displayWidth, minecraft.displayHeight, minecraft.getFramebuffer()); + RuntimeProbe.require(new java.io.File(minecraft.gameDir, "screenshots/" + modId + "-config.png").isFile(), + "config screen screenshot saved"); + screen.onGuiClosed(); + } + + Configuration grass = (Configuration) grassConfiguration("configuration"); + Configuration terrain = TerrainSmootherConfig.configuration(); + boolean activeGrass = SkysGrassSlabsConfig.generateGrassSlabs(); + int activeLevel = SkysTerrainSmoother.level; + + grass.get("worldgen", "generateGrassSlabs", true).set(!activeGrass); + int pendingLevel = activeLevel == 3 ? 1 : 3; + terrain.getCategory("worldgen").get("smoothingLevel").set(pendingLevel); + MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("unrelated", null, false, true)); + RuntimeProbe.require(read(grass).get("worldgen", "generateGrassSlabs", true).getBoolean() == activeGrass + && read(terrain).get("worldgen", "smoothingLevel", 1).getInt() == activeLevel, + "other mods cannot save these settings"); + MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("skysgrassslabs", null, false, true)); + MinecraftForge.EVENT_BUS.post(new ConfigChangedEvent.OnConfigChangedEvent("skysterrainsmoother", null, false, true)); + // Forge's save() does not clear every dirty flag. Read the files back instead. + RuntimeProbe.require(read(grass).get("worldgen", "generateGrassSlabs", true).getBoolean() != activeGrass + && read(terrain).get("worldgen", "smoothingLevel", 1).getInt() == pendingLevel, + "actual GUI event handlers saved both files"); + RuntimeProbe.require(SkysGrassSlabsConfig.generateGrassSlabs() == activeGrass + && SkysTerrainSmoother.level == activeLevel, "pending GUI choices leave active settings unchanged"); + + grass.get("worldgen", "generateGrassSlabs", true).set(activeGrass); + terrain.getCategory("worldgen").get("smoothingLevel").set(activeLevel); + grassConfiguration("saveChanges"); + TerrainSmootherConfig.saveChanges(); + System.out.println("SKY_CONFIG_SCREENS_PASS factories=2 widgets=5 restart_safe=true"); + } + + private static Configuration read(Configuration source) { + Configuration written = new Configuration(source.getConfigFile()); + written.load(); + return written; + } + + private static Object grassConfiguration(String method) { + try { + // This is a build-only check, not an addition to the Grass Slabs extension API. + return SkysGrassSlabsConfig.class.getMethod(method).invoke(null); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Use the matching Grass Slabs config-screen candidate", failure); + } + } + + private ConfigScreenProbe() { + } +} diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FineGrassModelProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FineGrassModelProbe.java new file mode 100644 index 0000000..b242c81 --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FineGrassModelProbe.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import net.minecraft.block.BlockGrass; +import net.minecraft.block.state.IBlockState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.block.model.BakedQuad; +import net.minecraft.client.renderer.block.model.IBakedModel; +import zone.moddev.mc.skysbuildingpieces.catalogue.Shape; +import zone.moddev.mc.skysbuildingpieces.content.Pieces; + +/** Checks the texture rows on actual baked faces, not just the source JSON. */ +final class FineGrassModelProbe { + static void run(Minecraft minecraft) { + int checked = 0; + for (Shape shape : new Shape[] { Shape.HORIZONTAL_STEP, Shape.CORNER }) { + for (int orientation = 4; orientation < 8; orientation++) { + for (boolean snowy : new boolean[] { false, true }) { + IBlockState state = Pieces.state("minecraft:grass", shape, orientation) + .withProperty(BlockGrass.SNOWY, snowy); + IBakedModel model = minecraft.getBlockRendererDispatcher().getModelForState(state); + int sides = 0; + for (BakedQuad quad : model.getQuads(state, null, 0)) { + if (quad.getFace().getAxis() == net.minecraft.util.EnumFacing.Axis.Y + || !quad.getSprite().getIconName().contains("grass_side")) { + continue; + } + int stride = quad.getFormat().getIntegerSize(); + int textureOffset = quad.getFormat().getUvOffsetById(0) / 4; + double firstRow = Double.POSITIVE_INFINITY; + double lastRow = Double.NEGATIVE_INFINITY; + + for (int vertex = 0; vertex < 4; vertex++) { + float atlasV = Float.intBitsToFloat(quad.getVertexData()[vertex * stride + textureOffset + 1]); + double row = 16 * (atlasV - quad.getSprite().getMinV()) + / (quad.getSprite().getMaxV() - quad.getSprite().getMinV()); + firstRow = Math.min(firstRow, row); + lastRow = Math.max(lastRow, row); + } + RuntimeProbe.require(Math.abs(firstRow) < .1 && Math.abs(lastRow - 8) < .1, + "baked grass/snow fringe starts at the piece's top: " + state); + RuntimeProbe.require(!snowy || !quad.hasTintIndex(), "snow edges are not biome tinted"); + sides++; + } + RuntimeProbe.require(sides > 0, "grass piece has real side faces"); + checked++; + } + } + } + System.out.println("TERRAIN_FINE_GRASS_MODELS_PASS states=" + checked); + } + + private FineGrassModelProbe() { + } +} diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FineGrassProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FineGrassProbe.java new file mode 100644 index 0000000..3fc9d2a --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FineGrassProbe.java @@ -0,0 +1,127 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import java.util.Random; +import net.minecraft.block.BlockGrass; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.EnumSkyBlock; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; +import zone.moddev.mc.skysgrassslabs.block.GrassSpread; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; +import zone.moddev.mc.skysterrainsmoother.api.TerrainMaterial; +import zone.moddev.mc.skysterrainsmoother.internal.MaterialCatalogue; + +/** Real generation and ticking must leave the visible part of a grass support green. */ +final class FineGrassProbe { + static int run(WorldServer world) { + BlockPos floor = new BlockPos(4200, 210, 4200); + Chunk owner = world.getChunk(floor); + TerrainMaterial material = MaterialCatalogue.instance().source(Blocks.GRASS.getDefaultState()); + EnumFacing[] directions = { EnumFacing.NORTH, EnumFacing.EAST, EnumFacing.SOUTH, EnumFacing.WEST }; + int checks = 0; + + for (int level = 1; level <= 3; level++) { + for (int direction = 0; direction < directions.length; direction++) { + for (boolean snow : new boolean[] { false, true }) { + prepare(world, floor); + world.setBlockState(floor.offset(directions[direction]).up(), Blocks.GRASS.getDefaultState(), 2); + SkysTerrainSmoother.engine.smooth(world, owner, level); + IBlockState expected = level == 1 ? material.slab : material.step(direction); + RuntimeProbe.require(world.getBlockState(floor.up()).equals(expected), + "grass smoothing chooses the requested shape"); + checks += checkSupport(world, floor, expected, level == 1, snow); + } + } + } + + for (int direction = 0; direction < directions.length; direction++) { + prepare(world, floor); + BlockPos higher = floor.offset(directions[direction]).offset(directions[direction].rotateYCCW()).up(); + world.setBlockState(higher, Blocks.GRASS.getDefaultState(), 2); + SkysTerrainSmoother.engine.smooth(world, owner, 3); + IBlockState corner = material.corner(direction); + RuntimeProbe.require(world.getBlockState(floor.up()).equals(corner), "diagonal grass uses a corner"); + checks += checkSupport(world, floor, corner, false, true); + } + checks += shrinkingSlab(world, material); + System.out.println("TERRAIN_FINE_GRASS_SUPPORT_PASS checks=" + checks); + return checks; + } + + private static int checkSupport(WorldServer world, BlockPos floor, IBlockState piece, + boolean fullCover, boolean snow) { + RuntimeProbe.require(world.getBlockState(floor).getBlock() == (fullCover ? Blocks.DIRT : Blocks.GRASS), + "only a full footprint may dirtify the supporting grass"); + world.setLightFor(EnumSkyBlock.SKY, floor.up(), 15); + if (snow) { + world.setBlockState(floor.up().east(), Blocks.SNOW_LAYER.getDefaultState(), 2); + RuntimeProbe.require(piece.getBlock().getActualState(piece, world, floor.up()).getValue(BlockGrass.SNOWY), + "snow keeps the same grass-support rule"); + } + GrassSlabsApi.repairSupport(world, floor.up()); + piece.getBlock().updateTick(world, floor.up(), piece, new Random(1)); + RuntimeProbe.require(world.getBlockState(floor).getBlock() == (fullCover ? Blocks.DIRT : Blocks.GRASS), + "neighbour and random-tick repair preserve exposed grass"); + world.setBlockState(floor, Blocks.DIRT.getDefaultState(), 2); + RuntimeProbe.require(GrassSpread.growTarget(world, floor) == !fullCover, + "exposed dirt can regrow, but dirt covered by a slab cannot"); + return snow ? 5 : 4; + } + + private static void prepare(WorldServer world, BlockPos floor) { + for (int offsetZ = -3; offsetZ <= 3; offsetZ++) { + for (int offsetX = -3; offsetX <= 3; offsetX++) { + BlockPos column = floor.add(offsetX, 0, offsetZ); + for (int height = 1; height <= 4; height++) { + world.setBlockToAir(column.up(height)); + } + world.setBlockState(column.down(), Blocks.STONE.getDefaultState(), 2); + world.setBlockState(column, Blocks.GRASS.getDefaultState(), 2); + } + } + } + + private static int shrinkingSlab(WorldServer world, TerrainMaterial material) { + BlockPos floor = new BlockPos(4232, 210, 4232); + Chunk owner = world.getChunk(floor); + int previousLevel = SkysTerrainSmoother.level; + int checks = 0; + try { + SkysTerrainSmoother.level = 2; + for (IBlockState decoratedSupport : new IBlockState[] { null, + Blocks.SAND.getDefaultState(), Blocks.DIAMOND_ORE.getDefaultState() }) { + prepare(world, floor); + world.setBlockState(floor.north().up(), Blocks.GRASS.getDefaultState(), 2); + world.setBlockState(floor.east().up(), Blocks.GRASS.getDefaultState(), 2); + SkysTerrainSmoother.engine.smooth(world, owner, 2); + RuntimeProbe.require(world.getBlockState(floor.up()).equals(material.slab) + && world.getBlockState(floor).getBlock() == Blocks.DIRT, "initial full-cover slab hides dirt"); + world.setBlockToAir(floor.south()); + if (decoratedSupport != null) { + world.setBlockState(floor, decoratedSupport, 2); + } + SkysTerrainSmoother.engine.afterDecoration(new net.minecraftforge.event.terraingen.DecorateBiomeEvent.Post( + world, new Random(1), new BlockPos(floor.getX() & ~15, 0, floor.getZ() & ~15))); + if (decoratedSupport == null) { + RuntimeProbe.require(world.getBlockState(floor.up()).equals(material.step(0)) + && world.getBlockState(floor).getBlock() == Blocks.GRASS, + "shrinking our slab restores its original exposed grass"); + } else { + RuntimeProbe.require(world.getBlockState(floor).equals(decoratedSupport), + "support repair never restores grass over a decorator's sand or ore"); + } + checks += 2; + } + } finally { + SkysTerrainSmoother.level = previousLevel; + } + return checks; + } + + private FineGrassProbe() { + } +} diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FoliageProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FoliageProbe.java new file mode 100644 index 0000000..f188fc7 --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/FoliageProbe.java @@ -0,0 +1,78 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import net.minecraft.block.BlockDoublePlant; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import zone.moddev.mc.skysgrassslabs.init.ModBlocks; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; + +/** Adjacent decorators can put plants in a fresh owner before its own Pre event. */ +final class FoliageProbe { + static int run(WorldServer world) { + BlockPos floor = new BlockPos(3528, 210, 3528); + Chunk owner = world.getChunk(floor); + int checks = 0; + + for (int level = 1; level <= 3; level++) { + for (IBlockState plant : new IBlockState[] { Blocks.TALLGRASS.getDefaultState(), + Blocks.YELLOW_FLOWER.getDefaultState(), Blocks.RED_FLOWER.getDefaultState(), + Blocks.DEADBUSH.getDefaultState() }) { + prepare(world, floor); + world.setBlockState(floor.up(), plant, 2); + world.setBlockState(floor.east().up(2), Blocks.TALLGRASS.getDefaultState(), 2); + SkysTerrainSmoother.engine.smooth(world, owner, level); + RuntimeProbe.require(zone.moddev.mc.skysterrainsmoother.internal.MaterialCatalogue.instance() + .piece(world.getBlockState(floor.up())) != null + && world.getBlockState(floor).getBlock() == (level == 1 ? Blocks.DIRT : Blocks.GRASS), + "foliage must not hide a valid grass transition at level " + level); + RuntimeProbe.require(world.getBlockState(floor.east().up(2)).getBlock() == Blocks.TALLGRASS, + "plants outside the placement remain unchanged"); + checks += 2; + } + } + + prepare(world, floor); + world.setBlockState(floor.up(), Blocks.DOUBLE_PLANT.getDefaultState() + .withProperty(BlockDoublePlant.HALF, BlockDoublePlant.EnumBlockHalf.LOWER), 2); + world.setBlockState(floor.up(2), Blocks.DOUBLE_PLANT.getDefaultState() + .withProperty(BlockDoublePlant.HALF, BlockDoublePlant.EnumBlockHalf.UPPER), 2); + SkysTerrainSmoother.engine.smooth(world, owner, 1); + RuntimeProbe.require(world.getBlockState(floor.up()).getBlock() == ModBlocks.GRASS_SLAB + && world.isAirBlock(floor.up(2)), "both halves of a displaced tall plant are removed together"); + checks++; + + for (IBlockState obstacle : new IBlockState[] { Blocks.LEAVES.getDefaultState(), + Blocks.LOG.getDefaultState(), Blocks.WHEAT.getDefaultState(), Blocks.SAPLING.getDefaultState(), + Blocks.VINE.getDefaultState(), Blocks.CHEST.getDefaultState(), Blocks.WATER.getDefaultState() }) { + prepare(world, floor); + world.setBlockState(floor.up(), obstacle, 2); + SkysTerrainSmoother.engine.smooth(world, owner, 1); + RuntimeProbe.require(world.getBlockState(floor.up()).equals(obstacle) + && world.getBlockState(floor).getBlock() == Blocks.GRASS, + "trees, crops, vines, block entities and fluids are not replaceable foliage"); + checks++; + } + + System.out.println("TERRAIN_SMOOTHER_FOLIAGE_PASS checks=" + checks); + return checks; + } + + private static void prepare(WorldServer world, BlockPos floor) { + for (int offsetZ = -2; offsetZ <= 2; offsetZ++) { + for (int offsetX = -2; offsetX <= 2; offsetX++) { + BlockPos support = floor.add(offsetX, 0, offsetZ); + for (int height = 1; height <= 6; height++) { + world.setBlockState(support.up(height), Blocks.AIR.getDefaultState(), 2); + } + world.setBlockState(support, Blocks.GRASS.getDefaultState(), 2); + } + } + world.setBlockState(floor.east().up(), Blocks.GRASS.getDefaultState(), 2); + } + + private FoliageProbe() { + } +} diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/LateTerrainProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/LateTerrainProbe.java index e534969..338b630 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/LateTerrainProbe.java +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/LateTerrainProbe.java @@ -89,9 +89,9 @@ static int run(WorldServer world) { "late oasis grass gets its own grass cap beside clay"); RuntimeProbe.require( world.getBlockState(floor) - .equals(GrassSlabsApi.grassGenerationAllowed() ? Blocks.DIRT.getDefaultState() + .equals(GrassSlabsApi.grassGenerationAllowed() && level == 1 ? Blocks.DIRT.getDefaultState() : Blocks.GRASS.getDefaultState()), - "late grass support dirtification respects grass switch"); + "late support conversion respects the grass switch and exposed footprint"); post(world, owner); RuntimeProbe.require( world.getBlockState(floor.up()).equals(expected) && world.isAirBlock(floor.up(2)), @@ -100,9 +100,9 @@ static int run(WorldServer world) { "late pass never loads a neighbour"); checks += 4; - // Decoration owns occupied targets. Do not replace vegetation, fluids or a placed block to obtain - // the missing transition. + // Small grass may give way in this tracked fresh owner; trees and other occupied blocks may not. for (IBlockState occupied : new IBlockState[] { Blocks.TALLGRASS.getDefaultState(), + Blocks.SAPLING.getDefaultState(), Blocks.WATER.getDefaultState(), Blocks.GLASS.getDefaultState() }) { fixture(world, floor, Blocks.GRAVEL.getDefaultState()); SkysTerrainSmoother.engine.smooth(world, owner, level); @@ -110,10 +110,13 @@ static int run(WorldServer world) { world.setBlockState(floor.add(DX[direction], 1, DZ[direction]), clay, 2); world.setBlockState(floor.up(), occupied, 2); post(world, owner); - RuntimeProbe.require(world.getBlockState(floor.up()).equals(occupied), - "late pass preserves occupied target " + occupied); - RuntimeProbe.require(world.getBlockState(floor).getBlock() == Blocks.GRASS, - "no support dirtification without successful placement"); + boolean replacePlant = occupied.getBlock() == Blocks.TALLGRASS + && GrassSlabsApi.grassGenerationAllowed(); + RuntimeProbe.require(world.getBlockState(floor.up()).equals(replacePlant ? expected : occupied), + "late pass only replaces small grass at a valid transition: " + occupied); + RuntimeProbe.require(world.getBlockState(floor).getBlock() + == (replacePlant && level == 1 ? Blocks.DIRT : Blocks.GRASS), + "support dirtification follows successful placement only"); checks += 2; } } diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/MineralogyProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/MineralogyProbe.java new file mode 100644 index 0000000..4aca4bf --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/MineralogyProbe.java @@ -0,0 +1,66 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import net.minecraft.block.Block; +import net.minecraft.block.properties.IProperty; +import net.minecraft.block.state.IBlockState; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.common.Loader; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; +import zone.moddev.mc.skysterrainsmoother.api.TerrainMaterial; +import zone.moddev.mc.skysterrainsmoother.internal.MaterialCatalogue; + +/** Confirms Mineralogy support uses native slabs and the core's rock pieces without an add-on. */ +public final class MineralogyProbe { + public static int run() { + if (!Boolean.getBoolean("skysterrainsmoother.requireMineralogy")) { + return 0; + } + require(Loader.isModLoaded("mineralogy") && Loader.isModLoaded("orespawn"), + "the optional profile loaded Mineralogy and its OreSpawn dependency"); + require(!Loader.isModLoaded("skysbuildingpiecesmineralogy"), "no separate piece add-on is installed"); + require(SkysTerrainSmoother.level == 3, "a fresh configuration uses level 3"); + int count = 0; + for (TerrainMaterial material : MaterialCatalogue.instance().materials()) { + if (!material.mineralogy) { + continue; + } + ResourceLocation slabId = new ResourceLocation("mineralogy", material.id.getPath() + "_slab"); + require(Block.REGISTRY.getObject(slabId) == material.slab.getBlock(), "native slab identity"); + boolean bottom = false; + for (IProperty property : material.slab.getPropertyKeys()) { + if (property.getName().equals("facing")) { + bottom = material.slab.getValue(property) == EnumFacing.UP; + } + } + require(bottom, "Mineralogy FACING=UP is the bottom horizontal slab"); + for (int direction = 0; direction < 4; direction++) { + require(material.step(direction) != null && material.corner(direction) != null, + "built-in steps and corners exist in every direction"); + require(material.step(direction).getBlock().getRegistryName().toString() + .startsWith("skysbuildingpieces:mineralogy_"), + "the core owns the rock pieces"); + } + require(MaterialCatalogue.instance().result(material, 1, 1).equals(material.slab), + "level 1 retains the native slab"); + require(MaterialCatalogue.instance().result(material, 1, 3).equals(material.corner(0)), + "level 3 uses the core's northwest corner"); + require(MaterialCatalogue.instance().result(material, 3, 2).equals(material.step(0)), + "level 2 uses the core's north step"); + count++; + } + // The locked legacy releases have no pumice, chert, gypsum or chalk slabs. + require(count == 24, "all 24 available natural rock slabs are registered, got " + count); + System.out.println("TERRAIN_SMOOTHER_MINERALOGY_PASS materials=" + count + " finer_shapes=24 addon=false"); + return count; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private MineralogyProbe() { + } +} diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ModMenuProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ModMenuProbe.java new file mode 100644 index 0000000..7400bbd --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/ModMenuProbe.java @@ -0,0 +1,120 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.List; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; +import net.minecraftforge.fml.client.GuiModList; +import net.minecraftforge.fml.client.config.GuiConfig; +import net.minecraftforge.fml.client.config.GuiConfigEntries; +import net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.ModContainer; + +/** Exercises the actual Mods list; a working factory alone does not make its Config button visible. */ +final class ModMenuProbe { + static void run(Minecraft minecraft) { + GuiScreen parent = minecraft.currentScreen; + int tooltips = 0; + + for (String modId : new String[] { "skysgrassslabs", "skysterrainsmoother" }) { + ModContainer mod = Loader.instance().getIndexedModList().get(modId); + RuntimeProbe.require(!mod.getMetadata().autogenerated, "metadata must load for " + modId); + RuntimeProbe.require(modId.equals(mod.getMetadata().modId) + && !mod.getMetadata().description.trim().isEmpty() + && !mod.getMetadata().authorList.isEmpty(), "the Mods page has real information for " + modId); + + ModListScreen mods = new ModListScreen(parent); + minecraft.displayGuiScreen(mods); + mods.select(modId); + draw(minecraft, mods); + screenshot(minecraft, modId + "-mod-info.png"); + mods.openConfig(); + RuntimeProbe.require(minecraft.currentScreen instanceof GuiConfig, + "the actual Config button opens the settings for " + modId); + GuiConfig config = (GuiConfig) minecraft.currentScreen; + + for (int index = 0; index < config.configElements.size(); index++) { + String key = config.configElements.get(index).getLanguageKey() + ".tooltip"; + String translated = I18n.format(key); + RuntimeProbe.require(!translated.equals(key) && !translated.trim().isEmpty(), + "translated tooltip exists for " + key); + RuntimeProbe.require(config.configElements.get(index).getComment() != null + && !config.configElements.get(index).getComment().trim().isEmpty(), + "plain help text remains available for " + key); + + @SuppressWarnings("unchecked") + List tooltip = (List) field(GuiConfigEntries.ListEntryBase.class, + config.entryList.listEntries.get(index), "toolTip"); + String text = String.join("\n", tooltip); + RuntimeProbe.require(text.contains(translated) && !text.contains("No tooltip defined."), + "Forge builds the real entry tooltip for " + key); + draw(minecraft, config); + config.drawToolTip(tooltip, 20, 70); + screenshot(minecraft, modId + "-" + config.configElements.get(index).getName() + "-tooltip.png"); + tooltips++; + } + + minecraft.displayGuiScreen(parent); + } + + RuntimeProbe.require(tooltips == 5, "all five settings have usable tooltips"); + System.out.println("SKY_MOD_MENU_PASS metadata=2 config_buttons=2 tooltips=" + tooltips); + } + + private static void draw(Minecraft minecraft, GuiScreen screen) { + minecraft.getFramebuffer().bindFramebuffer(true); + minecraft.entityRenderer.setupOverlayRendering(); + screen.drawScreen(0, 0, 0); + } + + private static void screenshot(Minecraft minecraft, String filename) { + net.minecraft.util.ScreenShotHelper.saveScreenshot(minecraft.gameDir, filename, + minecraft.displayWidth, minecraft.displayHeight, minecraft.getFramebuffer()); + RuntimeProbe.require(new java.io.File(minecraft.gameDir, "screenshots/" + filename).isFile(), + "saved screen capture " + filename); + } + + private static Object field(Class owner, Object instance, String name) { + try { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + return field.get(instance); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Cannot inspect Forge's menu field " + name, failure); + } + } + + private static final class ModListScreen extends GuiModList { + ModListScreen(GuiScreen parent) { + super(parent); + } + + void select(String modId) { + List mods = (List) field(GuiModList.class, this, "mods"); + for (int index = 0; index < mods.size(); index++) { + if (((ModContainer) mods.get(index)).getModId().equals(modId)) { + selectModIndex(index); + return; + } + } + throw new AssertionError("The Mods list is missing " + modId); + } + + void openConfig() { + GuiButton button = (GuiButton) field(GuiModList.class, this, "configModButton"); + RuntimeProbe.require(button.visible && button.enabled, "the Config button is visible and enabled"); + try { + super.actionPerformed(button); + } catch (IOException failure) { + throw new IllegalStateException("The Config button failed", failure); + } + } + } + + private ModMenuProbe() { + } +} + diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/OpportunityProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/OpportunityProbe.java new file mode 100644 index 0000000..ad697b5 --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/OpportunityProbe.java @@ -0,0 +1,175 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import net.minecraft.block.Block; +import net.minecraft.block.BlockSnow; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; +import zone.moddev.mc.skysterrainsmoother.api.TerrainMaterial; +import zone.moddev.mc.skysterrainsmoother.internal.MaterialCatalogue; + +/** Snowy terraces and mixed uphill/downhill edges that still have room for a transition. */ +final class OpportunityProbe { + static int run(WorldServer world) throws IOException { + BlockPos floor = new BlockPos(3720, 210, 3720); + Chunk owner = world.getChunk(floor); + int checks = 0; + + for (IBlockState source : new IBlockState[] { Blocks.STONE.getDefaultState(), + Blocks.GRASS.getDefaultState(), Blocks.SAND.getDefaultState() }) { + if (source.getBlock() == Blocks.GRASS && !GrassSlabsApi.grassGenerationAllowed()) { + continue; + } + TerrainMaterial material = MaterialCatalogue.instance().source(source); + for (int level = 1; level <= 3; level++) { + for (EnumFacing uphill : EnumFacing.HORIZONTALS) { + for (int cover = 1; cover <= 3; cover++) { + prepare(world, floor, source); + world.setBlockState(floor.offset(uphill).up(), source, 2); + if ((cover & 1) != 0) { + world.setBlockState(floor.up(), Blocks.SNOW_LAYER.getDefaultState(), 2); + } + if ((cover & 2) != 0) { + world.setBlockState(floor.offset(uphill).up(2), Blocks.SNOW_LAYER.getDefaultState(), 2); + } + SkysTerrainSmoother.engine.smooth(world, owner, level); + RuntimeProbe.require(MaterialCatalogue.instance().piece(world.getBlockState(floor.up())) == material, + "thin snow must not hide a valid transition: level " + level + " / " + uphill); + RuntimeProbe.require((cover & 2) == 0 + || world.getBlockState(floor.offset(uphill).up(2)).getBlock() == Blocks.SNOW_LAYER, + "snow outside the chosen placement is not cleared"); + RuntimeProbe.require(SkysTerrainSmoother.engine.smooth(world, owner, level) == 0, + "snow replacement remains idempotent"); + checks += 3; + } + + // More than one layer is not the thin cover produced by normal snowfall. + for (int layers = 2; layers <= 8; layers++) { + prepare(world, floor, source); + world.setBlockState(floor.offset(uphill).up(), source, 2); + IBlockState snow = Blocks.SNOW_LAYER.getDefaultState().withProperty(BlockSnow.LAYERS, layers); + world.setBlockState(floor.up(), snow, 2); + SkysTerrainSmoother.engine.smooth(world, owner, level); + RuntimeProbe.require(world.getBlockState(floor.up()).equals(snow), + "deep snow remains occupied terrain"); + checks++; + } + + for (int lowerEdges = 1; lowerEdges <= 2; lowerEdges++) { + prepare(world, floor, source); + EnumFacing secondRise = uphill.rotateY(); + world.setBlockState(floor.offset(uphill).up(), source, 2); + world.setBlockState(floor.offset(secondRise).up(), source, 2); + world.setBlockToAir(floor.offset(uphill.getOpposite())); + if (lowerEdges == 2) { + world.setBlockToAir(floor.offset(secondRise.getOpposite())); + } + SkysTerrainSmoother.engine.smooth(world, owner, level); + // Terrain API directions are N/E/S/W, unlike Minecraft's horizontal index. + int stepDirection = direction(uphill); + int cornerDirection = corner(uphill, secondRise); + IBlockState expected = lowerEdges == 1 && level >= 2 ? material.step(stepDirection) + : lowerEdges == 2 && level == 3 ? material.corner(cornerDirection) : null; + RuntimeProbe.require(world.getBlockState(floor.up()) + .equals(expected == null ? Blocks.AIR.getDefaultState() : expected), + "use a fitting piece instead of rejecting a mixed terrace, level " + level + + " / " + uphill + " / lower edges " + lowerEdges); + RuntimeProbe.require(SkysTerrainSmoother.engine.smooth(world, owner, level) == 0, + "a clipped footprint does not grow on a repeated pass"); + checks += 2; + } + } + } + } + checks += snowyTerrace(world); + System.out.println("TERRAIN_SMOOTHER_OPPORTUNITIES_PASS checks=" + checks); + return checks; + } + + /** A small block-only sample from the reported mountain; no player or world metadata is included. */ + private static int snowyTerrace(WorldServer world) throws IOException { + JsonObject fixture; + InputStream input = OpportunityProbe.class.getResourceAsStream("/fixtures/snowy_stone_terrace.json"); + RuntimeProbe.require(input != null, "the mountain regression fixture is packaged with the probe"); + try (Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + fixture = new JsonParser().parse(reader).getAsJsonObject(); + } + + JsonArray savedPalette = fixture.getAsJsonArray("palette"); + IBlockState[] palette = new IBlockState[savedPalette.size()]; + for (int index = 0; index < palette.length; index++) { + JsonObject entry = savedPalette.get(index).getAsJsonObject(); + Block block = Block.getBlockFromName(entry.get("block").getAsString()); + RuntimeProbe.require(block != null, "unknown block in the mountain regression fixture"); + palette[index] = block.getStateFromMeta(entry.get("meta").getAsInt()); + } + + BlockPos origin = new BlockPos(3768, 210, 3768); + Chunk owner = world.getChunk(origin); + int radius = fixture.get("radius").getAsInt(); + int minimumHeight = fixture.get("min_height").getAsInt(); + int maximumHeight = fixture.get("max_height").getAsInt(); + JsonArray blocks = fixture.getAsJsonArray("blocks"); + int checks = 0; + + for (int level = 1; level <= 3; level++) { + prepare(world, origin, Blocks.STONE.getDefaultState()); + int index = 0; + for (int height = minimumHeight; height <= maximumHeight; height++) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + world.setBlockState(origin.add(offsetX, height, offsetZ), + palette[blocks.get(index++).getAsInt()], 2); + } + } + } + SkysTerrainSmoother.engine.smooth(world, owner, level); + RuntimeProbe.require(MaterialCatalogue.instance().piece(world.getBlockState(origin.up())) + == MaterialCatalogue.instance().source(Blocks.STONE.getDefaultState()), + "the reported snowy stone terrace receives its matching transition at level " + level); + RuntimeProbe.require(SkysTerrainSmoother.engine.smooth(world, owner, level) == 0, + "the reported terrace stays stable on a repeated pass"); + checks += 2; + } + return checks; + } + + private static int direction(EnumFacing facing) { + return facing == EnumFacing.NORTH ? 0 : facing == EnumFacing.EAST ? 1 + : facing == EnumFacing.SOUTH ? 2 : 3; + } + + private static int corner(EnumFacing first, EnumFacing second) { + int firstEdge = zone.moddev.mc.skysterrainsmoother.internal.Footprint.EDGES[direction(first)]; + int secondEdge = zone.moddev.mc.skysterrainsmoother.internal.Footprint.EDGES[direction(second)]; + return Integer.numberOfTrailingZeros(firstEdge & secondEdge); + } + + private static void prepare(WorldServer world, BlockPos floor, IBlockState source) { + for (int offsetZ = -3; offsetZ <= 3; offsetZ++) { + for (int offsetX = -3; offsetX <= 3; offsetX++) { + for (int height = -2; height <= 6; height++) { + world.setBlockState(floor.add(offsetX, height, offsetZ), + height <= 0 ? source : Blocks.AIR.getDefaultState(), 2); + } + } + } + } + + private OpportunityProbe() { + } +} + diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/RuntimeProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/RuntimeProbe.java index f513734..88865a2 100644 --- a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/RuntimeProbe.java +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/RuntimeProbe.java @@ -88,18 +88,6 @@ public void init(FMLInitializationEvent event) { null, null, false, null)); TerrainSmoothingApi.addShapes(registryId, steps, corners); - // A later Mineralogy add-on can supply finer geometry without replacing its native slab. - if (Loader.isModLoaded("mineralogy") && SkysTerrainSmoother.mineralogy - && Block.REGISTRY.containsKey(new ResourceLocation("mineralogy:basalt_slab"))) { - for (int directionIndex = 0; directionIndex < 4; directionIndex++) { - steps[directionIndex] = BuildingPiecesApi.bottomPiece("minecraft:quartz_block", - BuildingPiecesApi.TerrainShape.STEP, directions[directionIndex]); - corners[directionIndex] = BuildingPiecesApi.bottomPiece("minecraft:quartz_block", - BuildingPiecesApi.TerrainShape.CORNER, directions[directionIndex]); - } - - TerrainSmoothingApi.addShapes(new ResourceLocation("mineralogy:basalt"), steps, corners); - } TerrainSmoothingApi.addAreaProtection((dimension, pos, biome) -> pos.getY() == 241); } @@ -135,7 +123,10 @@ public void tick(TickEvent.ServerTickEvent event) { require(late, "late API registration rejected"); - int gameplay = persistence(world) + LateTerrainProbe.run(world) + CaveProbe.run(world) + int gameplay = MineralogyProbe.run() + FineGrassProbe.run(world) + OpportunityProbe.run(world) + + TransitionProbe.run(world) + FoliageProbe.run(world) + + persistence(world) + + LateTerrainProbe.run(world) + CaveProbe.run(world) + decorationChanges(world) + geometry(world) + grass(world) + sand(world) + generationOrder(world) + LateTerrainProbe.generationOrder(world); int generation = generate(world); @@ -600,11 +591,18 @@ private static int grass(WorldServer world) { "fine grass snow clears"); world.setBlockState(position.down(), Blocks.GRASS.getDefaultState(), 2); grass.getBlock().neighborChanged(grass, world, position, Blocks.GRASS, position.down()); - require(world.getBlockState(position.down()).getBlock() == Blocks.DIRT, "piece support dirtification"); - require(!GrassSpread.growTarget(world, position.down()), "no grass beneath grass pieces"); + int cells = piece.mask(grass); + boolean coversSupport = ((cells | cells >> 4) & 15) == 15; + require(world.getBlockState(position.down()).getBlock() == (coversSupport ? Blocks.DIRT : Blocks.GRASS), + "only full footprints dirtify the grass support"); + world.setBlockState(position.down(), Blocks.DIRT.getDefaultState(), 2); + require(GrassSpread.growTarget(world, position.down()) == !coversSupport, + "visible soil beneath finer pieces can regain grass"); world.setBlockState(position, dirt, 2); world.setBlockState(position.up(), ModBlocks.GRASS_SLAB.getDefaultState(), 2); require(!GrassSpread.growTarget(world, position), "covered dirt piece rejected"); + world.setBlockState(position.up(), Pieces.state("minecraft:grass", Shape.HORIZONTAL_STEP, 4), 2); + require(!GrassSpread.growTarget(world, position), "covered dirt pieces still reject partial grass cover"); world.setBlockState(position, grass, 2); world.setBlockState(position.up(), Blocks.STONE.getDefaultState(), 2); world.setLightFor(EnumSkyBlock.SKY, position.up(), 0); diff --git a/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/TransitionProbe.java b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/TransitionProbe.java new file mode 100644 index 0000000..caabafa --- /dev/null +++ b/src/integrationTest/java/zone/moddev/mc/skysterrainsmoother/probe/TransitionProbe.java @@ -0,0 +1,182 @@ +package zone.moddev.mc.skysterrainsmoother.probe; + +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.AxisAlignedBB; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import zone.moddev.mc.skysgrassslabs.api.GrassSlabsApi; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; +import zone.moddev.mc.skysterrainsmoother.api.TerrainMaterial; +import zone.moddev.mc.skysterrainsmoother.internal.MaterialCatalogue; + +/** Mountain ledges must not gain another full-height step between two half-height caps. */ +final class TransitionProbe { + static int run(WorldServer world) { + BlockPos origin = new BlockPos(3592, 210, 3592); + Chunk owner = world.getChunk(origin); + int checks = 0; + + for (IBlockState source : new IBlockState[] { Blocks.STONE.getDefaultState(), + Blocks.SANDSTONE.getDefaultState(), Blocks.SAND.getDefaultState(), Blocks.GRASS.getDefaultState() }) { + if (source.getBlock() == Blocks.GRASS && !GrassSlabsApi.grassGenerationAllowed()) { + continue; + } + + TerrainMaterial material = MaterialCatalogue.instance().source(source); + + for (int level = 1; level <= 3; level++) { + for (EnumFacing uphill : EnumFacing.HORIZONTALS) { + for (boolean roofed : new boolean[] { false, true }) { + prepare(world, origin, uphill, source, false, roofed); + int loadedBefore = world.getChunkProvider().getLoadedChunkCount(); + SkysTerrainSmoother.engine.smooth(world, owner, level); + + BlockPos lowCap = origin.up(); + BlockPos upperCap = origin.offset(uphill).up(2); + RuntimeProbe.require(MaterialCatalogue.instance().piece(world.getBlockState(lowCap)) == material, + "the foot of a steep slope still receives its matching transition"); + requireOpenDownhillEdge(world, upperCap, uphill.getOpposite(), + "consecutive mountain ledges, level " + level + " / " + uphill); + RuntimeProbe.require(world.getChunkProvider().getLoadedChunkCount() == loadedBefore, + "slope checks never load neighbouring chunks"); + RuntimeProbe.require(SkysTerrainSmoother.engine.smooth(world, owner, level) == 0, + "a repeated pass does not add the rejected upper cap"); + checks += 4; + + prepare(world, origin, uphill, source, true, roofed); + SkysTerrainSmoother.engine.smooth(world, owner, level); + RuntimeProbe.require(MaterialCatalogue.instance() + .piece(world.getBlockState(origin.offset(uphill).up())) == material, + "the bottom of a wider terrace is smoothed"); + RuntimeProbe.require(MaterialCatalogue.instance() + .piece(world.getBlockState(origin.offset(uphill, 3).up(2))) == material, + "a wider upper terrace retains room for its own transition"); + checks += 2; + } + } + } + } + + // Existing caps, including those retained over an ore vein, must give the same answer as a fresh plan. + TerrainMaterial stone = MaterialCatalogue.instance().source(Blocks.STONE.getDefaultState()); + for (EnumFacing uphill : EnumFacing.HORIZONTALS) { + prepare(world, origin, uphill, Blocks.STONE.getDefaultState(), false, false); + world.setBlockState(origin, Blocks.COAL_ORE.getDefaultState(), 2); + world.setBlockState(origin.up(), stone.slab, 2); + SkysTerrainSmoother.engine.smooth(world, owner, 1); + requireOpenDownhillEdge(world, origin.offset(uphill).up(2), uphill.getOpposite(), + "a pre-existing lower slab must not receive a parallel upper slab"); + RuntimeProbe.require(world.getBlockState(origin).getBlock() == Blocks.COAL_ORE + && world.getBlockState(origin.up()).equals(stone.slab), + "lower slabs and their ore supports are not removed"); + checks += 2; + } + + checks += borders(world) + lateLowerFloor(world, origin); + System.out.println("TERRAIN_SMOOTHER_TRANSITIONS_PASS checks=" + checks); + return checks; + } + + private static int borders(WorldServer world) { + int checks = 0; + for (EnumFacing uphill : EnumFacing.HORIZONTALS) { + BlockPos low = uphill == EnumFacing.EAST ? new BlockPos(3615, 210, 3624) + : uphill == EnumFacing.WEST ? new BlockPos(3616, 210, 3624) + : uphill == EnumFacing.SOUTH ? new BlockPos(3624, 210, 3615) + : new BlockPos(3624, 210, 3616); + Chunk lowerOwner = world.getChunk(low); + Chunk upperOwner = world.getChunk(low.offset(uphill)); + + for (int level = 1; level <= 3; level++) { + IBlockState[] forward = null; + for (boolean reverse : new boolean[] { false, true }) { + prepare(world, low, uphill, Blocks.STONE.getDefaultState(), false, false); + int loadedBefore = world.getChunkProvider().getLoadedChunkCount(); + SkysTerrainSmoother.engine.smooth(world, reverse ? upperOwner : lowerOwner, level); + SkysTerrainSmoother.engine.smooth(world, reverse ? lowerOwner : upperOwner, level); + requireOpenDownhillEdge(world, low.offset(uphill).up(2), uphill.getOpposite(), + "a chunk boundary cannot recreate the upper lip, " + uphill + " / level " + level); + RuntimeProbe.require(world.getChunkProvider().getLoadedChunkCount() == loadedBefore, + "downhill boundary checks never generate chunks"); + IBlockState[] result = { world.getBlockState(low.up()), + world.getBlockState(low.offset(uphill).up(2)) }; + if (forward == null) { + forward = result; + } else { + RuntimeProbe.require(java.util.Arrays.equals(forward, result), + "mountain transitions match in both chunk orders"); + checks++; + } + checks += 2; + } + } + } + return checks; + } + + private static int lateLowerFloor(WorldServer world, BlockPos origin) { + Chunk owner = world.getChunk(origin); + int checks = 0; + for (EnumFacing uphill : EnumFacing.HORIZONTALS) { + prepare(world, origin, uphill, Blocks.STONE.getDefaultState(), false, false); + for (int distance = -2; distance <= 0; distance++) { + for (int width = -2; width <= 2; width++) { + world.setBlockState(origin.offset(uphill, distance).offset(uphill.rotateY(), width).up(), + Blocks.STONE.getDefaultState(), 2); + } + } + SkysTerrainSmoother.engine.smooth(world, owner, 1); + BlockPos upperCap = origin.offset(uphill).up(2); + RuntimeProbe.require(MaterialCatalogue.instance().piece(world.getBlockState(upperCap)) != null, + "late-terrace regression begins with a valid upper cap"); + world.setBlockToAir(origin.up()); + net.minecraftforge.common.MinecraftForge.EVENT_BUS.post( + new net.minecraftforge.event.terraingen.DecorateBiomeEvent.Post(world, new java.util.Random(1), + new BlockPos(owner.x << 4, 0, owner.z << 4))); + requireOpenDownhillEdge(world, upperCap, uphill.getOpposite(), + "late decoration cannot leave an upper cap that raises a newly exposed downhill edge"); + RuntimeProbe.require(SkysTerrainSmoother.level != 1 || world.isAirBlock(upperCap), + "slab-only mode leaves the upper lip uncapped"); + checks += 3; + } + return checks; + } + + private static void requireOpenDownhillEdge(WorldServer world, BlockPos target, EnumFacing downhill, + String message) { + IBlockState state = world.getBlockState(target); + if (state.getBlock() == Blocks.AIR) { + return; + } + + AxisAlignedBB bounds = state.getBoundingBox(world, target); + boolean touches = downhill == EnumFacing.NORTH ? bounds.minZ == 0 + : downhill == EnumFacing.SOUTH ? bounds.maxZ == 1 + : downhill == EnumFacing.WEST ? bounds.minX == 0 : bounds.maxX == 1; + RuntimeProbe.require(!touches, message); + } + + private static void prepare(WorldServer world, BlockPos origin, EnumFacing uphill, IBlockState source, + boolean wide, boolean roofed) { + EnumFacing across = uphill.rotateY(); + for (int distance = -3; distance <= 5; distance++) { + int rise = Math.max(0, Math.min(2, wide ? distance / 2 : distance)); + for (int width = -3; width <= 3; width++) { + BlockPos column = origin.offset(uphill, distance).offset(across, width); + for (int height = -2; height <= 9; height++) { + world.setBlockState(column.up(height), height <= rise ? source : Blocks.AIR.getDefaultState(), 2); + } + if (roofed) { + world.setBlockState(column.up(8), Blocks.BRICK_BLOCK.getDefaultState(), 2); + } + } + } + } + + private TransitionProbe() { + } +} + diff --git a/src/integrationTest/resources/assets/terrainsmootherprobe/blockstates/ore_support.json b/src/integrationTest/resources/assets/terrainsmootherprobe/blockstates/ore_support.json new file mode 100644 index 0000000..48826ea --- /dev/null +++ b/src/integrationTest/resources/assets/terrainsmootherprobe/blockstates/ore_support.json @@ -0,0 +1,6 @@ +{ + "variants": { + "normal": { "model": "minecraft:stone" }, + "inventory": { "model": "minecraft:stone" } + } +} diff --git a/src/integrationTest/resources/assets/terrainsmootherprobe/lang/en_us.lang b/src/integrationTest/resources/assets/terrainsmootherprobe/lang/en_us.lang new file mode 100644 index 0000000..b7d74c6 --- /dev/null +++ b/src/integrationTest/resources/assets/terrainsmootherprobe/lang/en_us.lang @@ -0,0 +1 @@ +# These runtime checks add no player-facing content. diff --git a/src/integrationTest/resources/assets/terrainsmootherprobe/models/item/ore_support.json b/src/integrationTest/resources/assets/terrainsmootherprobe/models/item/ore_support.json new file mode 100644 index 0000000..5d0d153 --- /dev/null +++ b/src/integrationTest/resources/assets/terrainsmootherprobe/models/item/ore_support.json @@ -0,0 +1,3 @@ +{ + "parent": "minecraft:block/stone" +} diff --git a/src/integrationTest/resources/fixtures/snowy_stone_terrace.json b/src/integrationTest/resources/fixtures/snowy_stone_terrace.json new file mode 100644 index 0000000..c513b2e --- /dev/null +++ b/src/integrationTest/resources/fixtures/snowy_stone_terrace.json @@ -0,0 +1,197 @@ +{ + "name": "snowy_stone_terrace", + "min_height": -2, + "max_height": 4, + "radius": 2, + "palette": [ + { + "block": "minecraft:stone", + "meta": 0 + }, + { + "block": "minecraft:snow_layer", + "meta": 0 + }, + { + "block": "minecraft:air", + "meta": 0 + } + ], + "blocks": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 2, + 2, + 2, + 1, + 1, + 2, + 2, + 2, + 1, + 1, + 2, + 2, + 2, + 1, + 1, + 2, + 2, + 2, + 1, + 1, + 2, + 2, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] +} diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/SkysTerrainSmoother.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/SkysTerrainSmoother.java index c69bcaf..d22a949 100644 --- a/src/main/java/zone/moddev/mc/skysterrainsmoother/SkysTerrainSmoother.java +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/SkysTerrainSmoother.java @@ -3,7 +3,7 @@ import java.io.File; import net.minecraft.server.MinecraftServer; import net.minecraftforge.common.MinecraftForge; -import net.minecraftforge.common.config.Configuration; +import zone.moddev.mc.skysterrainsmoother.config.TerrainSmootherConfig; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.SidedProxy; import net.minecraftforge.fml.common.event.*; @@ -17,11 +17,12 @@ name = "Sky's Terrain Smoother", version = SkysTerrainSmoother.VERSION, acceptedMinecraftVersions = "[1.12.2]", - dependencies = "required-before:skysgrassslabs@[1.1.0.112021,2);required-before:skysbuildingpieces@[0.3.0.112021,0.4)") + dependencies = "required-before:skysgrassslabs@[1.1.1.112021,2);required-before:skysbuildingpieces@[0.3.8.112021,0.4)", + guiFactory = "zone.moddev.mc.skysterrainsmoother.client.TerrainSmootherGuiFactory") public final class SkysTerrainSmoother { public static final String ID = "skysterrainsmoother", VERSION = "0.1.0.112021"; public static boolean enabled = true, mineralogy = true; - public static int level = 1; + public static int level = 3; public static Logger logger; public static SmoothingEngine engine; @SidedProxy( @@ -33,19 +34,7 @@ public final class SkysTerrainSmoother { public void preInit(FMLPreInitializationEvent event) { logger = event.getModLog(); - Configuration config = new Configuration(event.getSuggestedConfigurationFile()); - - config.load(); - enabled = config.getBoolean("enabled", "worldgen", true, - "Smooth newly generated dry Overworld slopes and cave floors. Requires restart."); - level = config.getInt("smoothingLevel", "worldgen", 1, 1, 3, - "1: slabs; 2: slabs and steps; 3: slabs, steps and corners. Missing shapes use slabs. Requires restart."); - mineralogy = config.getBoolean("enableMineralogy", "compat", true, - "Reuse available natural Mineralogy rock slabs. No additional blocks are registered. Requires restart."); - - if (config.hasChanged()) { - config.save(); - } + TerrainSmootherConfig.load(event.getSuggestedConfigurationFile()); GrassSlabsApi.claimSmoothing(ID, enabled); zone.moddev.mc.skysterrainsmoother.content.SandContent.register(); diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/client/TerrainSmootherConfigScreen.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/client/TerrainSmootherConfigScreen.java new file mode 100644 index 0000000..1227b3b --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/client/TerrainSmootherConfigScreen.java @@ -0,0 +1,23 @@ +package zone.moddev.mc.skysterrainsmoother.client; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.common.config.ConfigElement; +import net.minecraftforge.fml.client.config.GuiConfig; +import net.minecraftforge.fml.client.config.IConfigElement; +import zone.moddev.mc.skysterrainsmoother.config.TerrainSmootherConfig; + +/** These generation and migration choices take effect after restarting Minecraft. */ +public final class TerrainSmootherConfigScreen extends GuiConfig { + public TerrainSmootherConfigScreen(GuiScreen parent) { + super(parent, elements(), "skysterrainsmoother", false, true, "Sky's Terrain Smoother"); + } + + private static List elements() { + List elements = new ArrayList<>(); + elements.addAll(new ConfigElement(TerrainSmootherConfig.configuration().getCategory("worldgen")).getChildElements()); + elements.addAll(new ConfigElement(TerrainSmootherConfig.configuration().getCategory("compat")).getChildElements()); + return elements; + } +} diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/client/TerrainSmootherGuiFactory.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/client/TerrainSmootherGuiFactory.java new file mode 100644 index 0000000..4bd7464 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/client/TerrainSmootherGuiFactory.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.skysterrainsmoother.client; + +import java.util.Collections; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.client.IModGuiFactory; +import net.minecraftforge.fml.client.event.ConfigChangedEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import zone.moddev.mc.skysterrainsmoother.config.TerrainSmootherConfig; + +/** Connects the Mods list's Config button to Forge's standard settings screen. */ +public final class TerrainSmootherGuiFactory implements IModGuiFactory { + @Override + public void initialize(Minecraft minecraftInstance) { + MinecraftForge.EVENT_BUS.register(this); + } + + @Override + public boolean hasConfigGui() { + return true; + } + + @Override + public GuiScreen createConfigGui(GuiScreen parentScreen) { + return new TerrainSmootherConfigScreen(parentScreen); + } + + @Override + public Set runtimeGuiCategories() { + return Collections.emptySet(); + } + + @SubscribeEvent + public void configChanged(ConfigChangedEvent.OnConfigChangedEvent event) { + if ("skysterrainsmoother".equals(event.getModID())) { + // Save for the next restart; do not rerun generation or compatibility arbitration. + TerrainSmootherConfig.saveChanges(); + } + } +} diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/config/TerrainSmootherConfig.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/config/TerrainSmootherConfig.java new file mode 100644 index 0000000..4bf9580 --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/config/TerrainSmootherConfig.java @@ -0,0 +1,50 @@ +package zone.moddev.mc.skysterrainsmoother.config; + +import java.io.File; +import net.minecraftforge.common.config.Configuration; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; + +/** Keeps saved choices separate from the settings used by the current session. */ +public final class TerrainSmootherConfig { + private static Configuration configuration; + + public static void load(File file) { + configuration = new Configuration(file); + configuration.load(); + SkysTerrainSmoother.enabled = configuration.getBoolean("enabled", "worldgen", true, + "Smooth newly generated dry Overworld slopes and cave floors. Requires restart."); + SkysTerrainSmoother.level = configuration.getInt("smoothingLevel", "worldgen", 3, 1, 3, + "1: slabs; 2: slabs and steps; 3: slabs, steps and corners. Missing shapes use slabs. Requires restart."); + SkysTerrainSmoother.mineralogy = configuration.getBoolean("enableMineralogy", "compat", true, + "Reuse available natural Mineralogy rock slabs. Requires restart."); + + configuration.setCategoryRequiresMcRestart("worldgen", true); + configuration.setCategoryRequiresMcRestart("compat", true); + // Work with the existing properties so Forge retains their help text and bounds. + configuration.getCategory("worldgen").get("enabled").setLanguageKey("config.skysterrainsmoother.enabled"); + // The generic integer getter resets Forge's bounds, so keep the existing bounded property. + configuration.getCategory("worldgen").get("smoothingLevel") + .setLanguageKey("config.skysterrainsmoother.smoothingLevel") + .set(SkysTerrainSmoother.level); + configuration.getCategory("compat").get("enableMineralogy") + .setLanguageKey("config.skysterrainsmoother.enableMineralogy"); + saveChanges(); + } + + public static Configuration configuration() { + if (configuration == null) { + throw new IllegalStateException("Terrain Smoother configuration has not loaded"); + } + + return configuration; + } + + public static void saveChanges() { + if (configuration().hasChanged()) { + configuration.save(); + } + } + + private TerrainSmootherConfig() { + } +} diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/content/SandContent.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/content/SandContent.java index f7a6912..27843f4 100644 --- a/src/main/java/zone/moddev/mc/skysterrainsmoother/content/SandContent.java +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/content/SandContent.java @@ -42,11 +42,11 @@ public static void recipes(RegistryEvent.Register event) { Ingredient half = Ingredient.fromStacks(new ItemStack(slab, 1, variant * 2)); event.getRegistry() - .register(new ShapedRecipes("skysterrainsmoother", 3, 1, + .register(new ShapedRecipes("skysterrainsmoother/slabs", 3, 1, NonNullList.from(Ingredient.EMPTY, full, full, full), new ItemStack(slab, 6, variant * 2)) .setRegistryName("skysterrainsmoother", name + "_slab")); event.getRegistry() - .register(new ShapedRecipes("skysterrainsmoother", 2, 1, + .register(new ShapedRecipes("skysterrainsmoother/full_blocks", 2, 1, NonNullList.from(Ingredient.EMPTY, half, half), new ItemStack(Blocks.SAND, 1, variant)) .setRegistryName("skysterrainsmoother", name + "_from_slabs")); } diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/BuiltInMaterials.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/BuiltInMaterials.java index 56c012a..dd73848 100644 --- a/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/BuiltInMaterials.java +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/BuiltInMaterials.java @@ -60,7 +60,9 @@ public static void register() { IBlockState slab = bottom(Block.REGISTRY.getObject(slabId).getDefaultState()); TerrainSmoothingApi.registerMaterial(new TerrainMaterial(fullId, - Collections.singleton(Block.REGISTRY.getObject(fullId).getDefaultState()), slab, null, null, false, + Collections.singleton(Block.REGISTRY.getObject(fullId).getDefaultState()), slab, + mineralogyShapes(fullId.toString(), BuildingPiecesApi.TerrainShape.STEP), + mineralogyShapes(fullId.toString(), BuildingPiecesApi.TerrainShape.CORNER), false, null)); ++count; } @@ -93,6 +95,19 @@ private static void vanilla(String name, IBlockState source, boolean grass, IBlo slab, steps, corners, grass, grass ? Blocks.DIRT.getDefaultState() : null)); } + private static IBlockState[] mineralogyShapes(String material, BuildingPiecesApi.TerrainShape shape) { + EnumFacing[] directions = { EnumFacing.NORTH, EnumFacing.EAST, EnumFacing.SOUTH, EnumFacing.WEST }; + IBlockState[] pieces = new IBlockState[directions.length]; + for (int direction = 0; direction < directions.length; direction++) { + pieces[direction] = BuildingPiecesApi.bottomPiece(material, shape, directions[direction]); + if (pieces[direction] == null) { + // A missing optional form still falls back to the native slab. + return null; + } + } + return pieces; + } + @SuppressWarnings({ "rawtypes", "unchecked" }) private static IBlockState bottom(IBlockState state) { for (IProperty property : state.getPropertyKeys()) { diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/Footprint.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/Footprint.java index 6653cf3..76f102b 100644 --- a/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/Footprint.java +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/Footprint.java @@ -48,6 +48,21 @@ public static int shape(int mask, int level) { return 1; } + /** Keep the uphill part of a footprint without raising any exposed downhill edge. */ + public static int trimDownhillEdges(int mask, int lowerEdges) { + if ((mask & ~15) != 0 || (lowerEdges & ~15) != 0) { + throw new IllegalArgumentException("Invalid footprint or downhill edges"); + } + + for (int direction = 0; direction < 4; direction++) { + if ((lowerEdges & 1 << direction) != 0) { + mask &= ~EDGES[direction]; + } + } + + return mask; + } + public static int direction(int mask, int shape) { if (shape == 3) { return Integer.numberOfTrailingZeros(mask); diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/SmoothingEngine.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/SmoothingEngine.java index a5552c5..5de699e 100644 --- a/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/SmoothingEngine.java +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/SmoothingEngine.java @@ -9,6 +9,7 @@ import net.minecraft.init.Blocks; import net.minecraft.item.ItemStack; import net.minecraft.util.EnumFacing; +import net.minecraft.util.math.AxisAlignedBB; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; import net.minecraft.world.chunk.Chunk; @@ -35,6 +36,7 @@ public final class SmoothingEngine { private final Set populated = new HashSet<>(), dirtyOwners = new HashSet<>(); private final Map> pending = new HashMap<>(); private final Map oreSupports = new HashMap<>(); + private final Map pieceEdges = new HashMap<>(); private final ThreadLocal decisions = ThreadLocal.withInitial(Decisions::new); public long passes, placed, elapsedNanos, skippedExisting; @@ -54,6 +56,7 @@ public void start(File worldDirectory) { dirtyOwners.clear(); pending.clear(); oreSupports.clear(); + pieceEdges.clear(); passes = placed = elapsedNanos = skippedExisting = 0; SkysTerrainSmoother.logger.info("Indexed {} saved Overworld chunks; no smoothing retrogen will run", existing.size()); @@ -66,6 +69,7 @@ public void stop() { dirtyOwners.clear(); pending.clear(); oreSupports.clear(); + pieceEdges.clear(); decisions.remove(); } @@ -214,11 +218,16 @@ private void validate(World world, int chunkX, int chunkZ, List place IBlockState support = owner.getBlockState(placement.pos.down()); TerrainMaterial material = placement.material; boolean blocked = placement.cave && !caveHeadroom(world, owner, placement.pos.down()); + boolean raisesLip = raisesLowerEdge(world, placement.pos.down(), placement.state); + boolean unsupported = !material.sources.contains(support) && !support.equals(material.supportAfterPlacement) + && !oreSupport(support); // Keep ore veins hidden beneath existing caps, but never use ore as a new smoothing source. - if ((blocked || !material.sources.contains(support) && !support.equals(material.supportAfterPlacement) - && !oreSupport(support)) && owner.getBlockState(placement.pos).equals(placement.state)) { - TerrainMaterial replacement = catalogue.source(support); + if ((blocked || raisesLip || unsupported) && owner.getBlockState(placement.pos).equals(placement.state)) { + boolean ourDirt = placement.dirtifiedSupport != null + && support.equals(placement.dirtifiedSupport) + && material.sources.contains(placement.originalSupport); + TerrainMaterial replacement = ourDirt ? material : catalogue.source(support); IBlockState result = null; if (!blocked && replacement != null && (!replacement.grass || GrassSlabsApi.grassGenerationAllowed()) @@ -250,8 +259,8 @@ private void validate(World world, int chunkX, int chunkZ, List place } } - result = catalogue.result(replacement, Footprint.mask(cardinal, diagonal, placement.level), - placement.level); + result = transitionResult(world, placement.pos.down(), replacement, + Footprint.mask(cardinal, diagonal, placement.level), placement.level); } repairs.put(placement, result == null ? Blocks.AIR.getDefaultState() : result); @@ -269,14 +278,31 @@ private void validate(World world, int chunkX, int chunkZ, List place } IBlockState state = repair.getValue(); + IBlockState supportBefore = owner.getBlockState(placement.pos.down()); + TerrainMaterial previousMaterial = placement.material; + boolean ourDirt = placement.dirtifiedSupport != null + && supportBefore.equals(placement.dirtifiedSupport) + && previousMaterial.sources.contains(placement.originalSupport); changed |= setGenerationState(owner, placement.pos, state); placement.state = state; placement.material = materials.get(placement); - if (placement.material != null && state.getBlock() != Blocks.AIR - && placement.material.supportAfterPlacement != null) { - changed |= setGenerationState(owner, placement.pos.down(), placement.material.supportAfterPlacement); + IBlockState supportAfter = placement.material != null && state.getBlock() != Blocks.AIR + ? supportAfterPlacement(placement.material, state) : null; + if (supportAfter != null) { + if (!ourDirt || previousMaterial != placement.material) { + placement.originalSupport = supportBefore; + } + changed |= setGenerationState(owner, placement.pos.down(), supportAfter); + placement.dirtifiedSupport = placement.material.grass + && !supportAfter.equals(placement.originalSupport) ? supportAfter : null; + } else { + // Shrinking our own slab exposes its old grass. Never undo a decorator's replacement terrain. + if (ourDirt && previousMaterial == placement.material && state.getBlock() != Blocks.AIR) { + changed |= setGenerationState(owner, placement.pos.down(), placement.originalSupport); + } + placement.dirtifiedSupport = null; } } if (changed) { @@ -317,7 +343,7 @@ private int smooth(World world, Chunk owner, int level, boolean afterDecoration) for (int localBlockZ = 0; localBlockZ < 16; ++localBlockZ) { for (int localBlockX = 0; localBlockX < 16; ++localBlockX) { - int surfaceY = owner.getHeightValue(localBlockX, localBlockZ) - 1; + int surfaceY = SmoothingFoliage.surfaceHeight(owner, localBlockX, localBlockZ, startX, startZ); // Visit upward-facing floors at every depth. Finish every decision before the first world write. if (surfaceY < 0) { @@ -334,8 +360,9 @@ private int smooth(World world, Chunk owner, int level, boolean afterDecoration) previous = lower; - // Only floor/air boundaries need a material lookup, not solid rock or the inside of an air pocket. - if (above.getMaterial() != Material.AIR || lower.getMaterial() == Material.AIR) { + // Ignore thin natural cover when finding a floor, but never tunnel through occupied terrain. + if (above.getMaterial() != Material.AIR && !SmoothingFoliage.isCover(above) + || lower.getMaterial() == Material.AIR) { continue; } @@ -353,7 +380,8 @@ private int smooth(World world, Chunk owner, int level, boolean afterDecoration) if (cave && !caveHeadroom(world, owner, support) || !lower.isSideSolid(world, support, EnumFacing.UP) - || !above.getBlock().isAir(above, world, target) || lower.getBlock().hasTileEntity(lower) + || !SmoothingFoliage.canReplace(world, owner, target) + || lower.getBlock().hasTileEntity(lower) || owner.getTileEntity(target, Chunk.EnumCreateEntityType.CHECK) != null || StructureProtection.contains(structures, target) || StructureProtection.contains(structures, support) @@ -378,10 +406,11 @@ private int smooth(World world, Chunk owner, int level, boolean afterDecoration) } } - IBlockState result = catalogue.result(material, Footprint.mask(cardinal, diagonal, level), level); + IBlockState result = transitionResult(world, support, material, + Footprint.mask(cardinal, diagonal, level), level); if (result != null) { - buffer.add(localBlockX | localBlockZ << 4 | (floorY + 1) << 8, result, material, cave); + buffer.add(localBlockX | localBlockZ << 4 | (floorY + 1) << 8, result, material, cave, above); } } } @@ -395,16 +424,20 @@ private int smooth(World world, Chunk owner, int level, boolean afterDecoration) BlockPos target = new BlockPos(startX + (position & 15), position >> 8, startZ + ((position >> 4) & 15)); IBlockState above = owner.getBlockState(target); - if (!above.getBlock().isAir(above, world, target)) { + if (!above.equals(buffer.originalTargets[index]) || !SmoothingFoliage.canReplace(world, owner, target)) { continue; } + IBlockState originalSupport = owner.getBlockState(target.down()); if (setGenerationState(owner, target, buffer.results[index])) { + if (above.getBlock() == Blocks.DOUBLE_PLANT) { + setGenerationState(owner, target.up(), Blocks.AIR.getDefaultState()); + } if (buffer.supports[index] != null) { setGenerationState(owner, target.down(), buffer.supports[index]); } placements.add(new Placement(target, buffer.results[index], buffer.materials[index], level, - buffer.caves[index])); + buffer.caves[index], originalSupport)); ++count; } } @@ -446,6 +479,74 @@ private static boolean setGenerationState(Chunk owner, BlockPos pos, IBlockState return true; } + private IBlockState transitionResult(World world, BlockPos support, TerrainMaterial material, int mask, int level) { + int lowerEdges = lowerEdges(world, support); + int safeMask = Footprint.trimDownhillEdges(mask, lowerEdges); + IBlockState result = catalogue.result(material, safeMask, level); + + // Missing finer shapes fall back to slabs. Check the real result, not just the requested footprint. + return result != null && (touchingEdges(result) & lowerEdges) == 0 ? result : null; + } + + private static IBlockState supportAfterPlacement(TerrainMaterial material, IBlockState piece) { + // A slab hides the whole surface. Finer grass pieces must leave the visible ground grassy. + return material.grass && !GrassSlabsApi.coversSupport(piece, EmptyAccess.INSTANCE, BlockPos.ORIGIN) + ? null : material.supportAfterPlacement; + } + + private boolean raisesLowerEdge(World world, BlockPos support, IBlockState result) { + return (touchingEdges(result) & lowerEdges(world, support)) != 0; + } + + private int touchingEdges(IBlockState result) { + Integer edges = pieceEdges.get(result); + + if (edges == null) { + AxisAlignedBB bounds = result.getBoundingBox(EmptyAccess.INSTANCE, BlockPos.ORIGIN); + edges = (bounds.minZ == 0 ? 1 : 0) | (bounds.maxX == 1 ? 2 : 0) + | (bounds.maxZ == 1 ? 4 : 0) | (bounds.minX == 0 ? 8 : 0); + pieceEdges.put(result, edges); + } + + return edges; + } + + /** + * Two caps on consecutive one-block rises recreate a full-block step. Leave the downhill lip alone, but + * allow the remaining uphill half or quarter where the chosen smoothing level has a matching piece. + */ + private int lowerEdges(World world, BlockPos support) { + int edges = 0; + + for (int direction = 0; direction < 4; direction++) { + BlockPos beside = support.add(DX[direction], 0, DZ[direction]); + Chunk neighbour = loaded(world, beside.getX() >> 4, beside.getZ() >> 4); + + // This is a downhill veto, not another source of higher terrain. Read only the loaded halo. + if (neighbour == null) { + continue; + } + + IBlockState atLip = neighbour.getBlockState(beside); + if (catalogue.piece(atLip) != null) { + edges |= 1 << direction; + continue; + } + if (atLip.getMaterial() == Material.AIR || SmoothingFoliage.isPlant(atLip) + || atLip.getBlock() == Blocks.SNOW_LAYER) { + BlockPos lowerFloor = beside.down(); + IBlockState below = neighbour.getBlockState(lowerFloor); + + if (below.isFullCube() && below.isSideSolid(world, lowerFloor, EnumFacing.UP) + && !below.getBlock().hasTileEntity(below)) { + edges |= 1 << direction; + } + } + } + + return edges; + } + private boolean caveHeadroom(World world, Chunk owner, BlockPos support) { // Three clear blocks leave 2.5 blocks above a new cap. A two-block tunnel must stay walkable. if (support.getY() > 252) { @@ -522,8 +623,7 @@ private boolean higher(World world, Chunk owner, int localX, int localZ, int low || oreSupport(state); } - return source != null && above.getMaterial() == Material.AIR - && above.getBlock().isAir(above, world, floor.up()); + return source != null && SmoothingFoliage.canReplace(world, neighbour, floor.up()); } /** A missing neighbour is unavailable, never a reason to request or generate another chunk. */ @@ -537,6 +637,7 @@ private static final class Decisions { int size; int[] positions = new int[256]; IBlockState[] results = new IBlockState[256], supports = new IBlockState[256]; + IBlockState[] originalTargets = new IBlockState[256]; TerrainMaterial[] materials = new TerrainMaterial[256]; boolean[] caves = new boolean[256]; @@ -544,24 +645,27 @@ void clear() { Arrays.fill(results, 0, size, null); Arrays.fill(supports, 0, size, null); Arrays.fill(materials, 0, size, null); + Arrays.fill(originalTargets, 0, size, null); size = 0; } - void add(int position, IBlockState result, TerrainMaterial material, boolean cave) { + void add(int position, IBlockState result, TerrainMaterial material, boolean cave, IBlockState originalTarget) { if (size == positions.length) { int capacity = positions.length * 2; positions = Arrays.copyOf(positions, capacity); results = Arrays.copyOf(results, capacity); supports = Arrays.copyOf(supports, capacity); + originalTargets = Arrays.copyOf(originalTargets, capacity); materials = Arrays.copyOf(materials, capacity); caves = Arrays.copyOf(caves, capacity); } positions[size] = position; results[size] = result; - supports[size] = material.supportAfterPlacement; + supports[size] = supportAfterPlacement(material, result); materials[size] = material; + originalTargets[size] = originalTarget; caves[size++] = cave; } } @@ -570,15 +674,21 @@ private static final class Placement { final BlockPos pos; IBlockState state; TerrainMaterial material; + IBlockState originalSupport; + IBlockState dirtifiedSupport; final int level; final boolean cave; - Placement(BlockPos pos, IBlockState state, TerrainMaterial material, int level, boolean cave) { + Placement(BlockPos pos, IBlockState state, TerrainMaterial material, int level, boolean cave, + IBlockState originalSupport) { this.pos = pos; this.state = state; this.material = material; this.level = level; this.cave = cave; + this.originalSupport = originalSupport; + IBlockState dirt = supportAfterPlacement(material, state); + this.dirtifiedSupport = material.grass && dirt != null && !dirt.equals(originalSupport) ? dirt : null; } } } diff --git a/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/SmoothingFoliage.java b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/SmoothingFoliage.java new file mode 100644 index 0000000..015ab1d --- /dev/null +++ b/src/main/java/zone/moddev/mc/skysterrainsmoother/internal/SmoothingFoliage.java @@ -0,0 +1,69 @@ +package zone.moddev.mc.skysterrainsmoother.internal; + +import net.minecraft.block.BlockDoublePlant; +import net.minecraft.block.BlockSnow; +import net.minecraft.block.state.IBlockState; +import net.minecraft.init.Blocks; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.chunk.Chunk; + +/** Small plants and a single snow layer may give way to a transition in a fresh chunk. */ +final class SmoothingFoliage { + static boolean isCover(IBlockState state) { + return isPlant(state) || isThinSnow(state); + } + + private static boolean isThinSnow(IBlockState state) { + return state.getBlock() == Blocks.SNOW_LAYER && state.getValue(BlockSnow.LAYERS) == 1; + } + + static boolean isPlant(IBlockState state) { + return state.getBlock() == Blocks.TALLGRASS || state.getBlock() == Blocks.YELLOW_FLOWER + || state.getBlock() == Blocks.RED_FLOWER || state.getBlock() == Blocks.DEADBUSH + || state.getBlock() == Blocks.DOUBLE_PLANT; + } + + static boolean canReplace(World world, Chunk chunk, BlockPos target) { + IBlockState state = chunk.getBlockState(target); + + if (state.getBlock().hasTileEntity(state) + || chunk.getTileEntity(target, Chunk.EnumCreateEntityType.CHECK) != null) { + return false; + } + if (state.getBlock().isAir(state, world, target) || isThinSnow(state)) { + return true; + } + if (!isPlant(state)) { + return false; + } + if (state.getBlock() != Blocks.DOUBLE_PLANT) { + return true; + } + if (target.getY() >= 255 + || state.getValue(BlockDoublePlant.HALF) != BlockDoublePlant.EnumBlockHalf.LOWER) { + return false; + } + + IBlockState upper = chunk.getBlockState(target.up()); + + return upper.getBlock() == Blocks.DOUBLE_PLANT + && upper.getValue(BlockDoublePlant.HALF) == BlockDoublePlant.EnumBlockHalf.UPPER + && chunk.getTileEntity(target.up(), Chunk.EnumCreateEntityType.CHECK) == null; + } + + static int surfaceHeight(Chunk chunk, int localX, int localZ, int startX, int startZ) { + int surfaceY = chunk.getHeightValue(localX, localZ) - 1; + + // A decorator may already have reached here from a neighbouring chunk. + while (surfaceY >= 0 && isCover(chunk.getBlockState( + new BlockPos(startX + localX, surfaceY, startZ + localZ)))) { + surfaceY--; + } + + return surfaceY; + } + + private SmoothingFoliage() { + } +} diff --git a/src/main/recipe-book/recipes.json b/src/main/recipe-book/recipes.json new file mode 100644 index 0000000..7d809fc --- /dev/null +++ b/src/main/recipe-book/recipes.json @@ -0,0 +1,42 @@ +[ + { + "recipe": "skysterrainsmoother:sand_slab", + "owner": "skysterrainsmoother", + "ingredients": [ + { + "item": "minecraft:sand", + "data": 0 + } + ] + }, + { + "recipe": "skysterrainsmoother:sand_from_slabs", + "owner": "skysterrainsmoother", + "ingredients": [ + { + "item": "skysterrainsmoother:sand_slab", + "data": 0 + } + ] + }, + { + "recipe": "skysterrainsmoother:red_sand_slab", + "owner": "skysterrainsmoother", + "ingredients": [ + { + "item": "minecraft:sand", + "data": 1 + } + ] + }, + { + "recipe": "skysterrainsmoother:red_sand_from_slabs", + "owner": "skysterrainsmoother", + "ingredients": [ + { + "item": "skysterrainsmoother:sand_slab", + "data": 2 + } + ] + } +] diff --git a/src/main/resources/assets/skysterrainsmoother/lang/de_at.lang b/src/main/resources/assets/skysterrainsmoother/lang/de_at.lang index 343875f..bedfd0b 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/de_at.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/de_at.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sandstufn tile.skysterrainsmoother.red_sand_slab.name=Rote Sandstufn +config.skysterrainsmoother.enabled=Geländeglättung aktivieren +config.skysterrainsmoother.smoothingLevel=Glättungsstufe (1–3) +config.skysterrainsmoother.enableMineralogy=Mineralogy-Stufen verwenden +config.skysterrainsmoother.enabled.tooltip=Glättet neue trockene Hänge und Höhlenböden der Oberwelt. Ausschalten stoppt auch den separaten Grass-Slabs-Durchlauf. Bestehende Chunks bleiben unverändert. Minecraft neu starten. +config.skysterrainsmoother.smoothingLevel.tooltip=1: Platten. 2: Platten und Viertelblöcke. 3: zusätzlich Achtelblöcke. Fehlende Formen werden nur durch sicher passende Platten ersetzt. Minecraft neu starten. +config.skysterrainsmoother.enableMineralogy.tooltip=Nutzt verfügbare Platten natürlicher Mineralogy-Gesteine, wenn die Mod installiert ist. Deaktivierte Plattentypen werden übersprungen. Minecraft neu starten. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/de_au.lang b/src/main/resources/assets/skysterrainsmoother/lang/de_au.lang index d50e4b8..03737ba 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/de_au.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/de_au.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sandstufe tile.skysterrainsmoother.red_sand_slab.name=Rote Sandstufe +config.skysterrainsmoother.enabled=Geländeglättung aktivieren +config.skysterrainsmoother.smoothingLevel=Glättungsstufe (1–3) +config.skysterrainsmoother.enableMineralogy=Mineralogy-Stufen verwenden +config.skysterrainsmoother.enabled.tooltip=Glättet neue trockene Hänge und Höhlenböden der Oberwelt. Ausschalten stoppt auch den separaten Grass-Slabs-Durchlauf. Bestehende Chunks bleiben unverändert. Minecraft neu starten. +config.skysterrainsmoother.smoothingLevel.tooltip=1: Platten. 2: Platten und Viertelblöcke. 3: zusätzlich Achtelblöcke. Fehlende Formen werden nur durch sicher passende Platten ersetzt. Minecraft neu starten. +config.skysterrainsmoother.enableMineralogy.tooltip=Nutzt verfügbare Platten natürlicher Mineralogy-Gesteine, wenn die Mod installiert ist. Deaktivierte Plattentypen werden übersprungen. Minecraft neu starten. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/de_de.lang b/src/main/resources/assets/skysterrainsmoother/lang/de_de.lang index d50e4b8..03737ba 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/de_de.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/de_de.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sandstufe tile.skysterrainsmoother.red_sand_slab.name=Rote Sandstufe +config.skysterrainsmoother.enabled=Geländeglättung aktivieren +config.skysterrainsmoother.smoothingLevel=Glättungsstufe (1–3) +config.skysterrainsmoother.enableMineralogy=Mineralogy-Stufen verwenden +config.skysterrainsmoother.enabled.tooltip=Glättet neue trockene Hänge und Höhlenböden der Oberwelt. Ausschalten stoppt auch den separaten Grass-Slabs-Durchlauf. Bestehende Chunks bleiben unverändert. Minecraft neu starten. +config.skysterrainsmoother.smoothingLevel.tooltip=1: Platten. 2: Platten und Viertelblöcke. 3: zusätzlich Achtelblöcke. Fehlende Formen werden nur durch sicher passende Platten ersetzt. Minecraft neu starten. +config.skysterrainsmoother.enableMineralogy.tooltip=Nutzt verfügbare Platten natürlicher Mineralogy-Gesteine, wenn die Mod installiert ist. Deaktivierte Plattentypen werden übersprungen. Minecraft neu starten. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/en_ca.lang b/src/main/resources/assets/skysterrainsmoother/lang/en_ca.lang index bc0a31d..e6603cc 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/en_ca.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/en_ca.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sand Slab tile.skysterrainsmoother.red_sand_slab.name=Red Sand Slab +config.skysterrainsmoother.enabled=Enable terrain smoothing +config.skysterrainsmoother.smoothingLevel=Smoothing level (1–3) +config.skysterrainsmoother.enableMineralogy=Use Mineralogy slabs +config.skysterrainsmoother.enabled.tooltip=Smooth new dry Overworld slopes and cave floors. Turning this off also stops the separate Grass Slabs pass. Existing chunks stay unchanged. Restart Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: slabs. 2: slabs and steps. 3: slabs, steps and corners. Missing shapes use a slab only where it fits safely. Restart Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Use available natural Mineralogy rock slabs when the mod is installed. Disabled slab types are skipped. Restart Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/en_en.lang b/src/main/resources/assets/skysterrainsmoother/lang/en_en.lang index bc0a31d..e6603cc 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/en_en.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/en_en.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sand Slab tile.skysterrainsmoother.red_sand_slab.name=Red Sand Slab +config.skysterrainsmoother.enabled=Enable terrain smoothing +config.skysterrainsmoother.smoothingLevel=Smoothing level (1–3) +config.skysterrainsmoother.enableMineralogy=Use Mineralogy slabs +config.skysterrainsmoother.enabled.tooltip=Smooth new dry Overworld slopes and cave floors. Turning this off also stops the separate Grass Slabs pass. Existing chunks stay unchanged. Restart Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: slabs. 2: slabs and steps. 3: slabs, steps and corners. Missing shapes use a slab only where it fits safely. Restart Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Use available natural Mineralogy rock slabs when the mod is installed. Disabled slab types are skipped. Restart Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/en_gb.lang b/src/main/resources/assets/skysterrainsmoother/lang/en_gb.lang index bc0a31d..e6603cc 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/en_gb.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/en_gb.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sand Slab tile.skysterrainsmoother.red_sand_slab.name=Red Sand Slab +config.skysterrainsmoother.enabled=Enable terrain smoothing +config.skysterrainsmoother.smoothingLevel=Smoothing level (1–3) +config.skysterrainsmoother.enableMineralogy=Use Mineralogy slabs +config.skysterrainsmoother.enabled.tooltip=Smooth new dry Overworld slopes and cave floors. Turning this off also stops the separate Grass Slabs pass. Existing chunks stay unchanged. Restart Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: slabs. 2: slabs and steps. 3: slabs, steps and corners. Missing shapes use a slab only where it fits safely. Restart Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Use available natural Mineralogy rock slabs when the mod is installed. Disabled slab types are skipped. Restart Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/en_pt.lang b/src/main/resources/assets/skysterrainsmoother/lang/en_pt.lang index bc0a31d..e6603cc 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/en_pt.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/en_pt.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sand Slab tile.skysterrainsmoother.red_sand_slab.name=Red Sand Slab +config.skysterrainsmoother.enabled=Enable terrain smoothing +config.skysterrainsmoother.smoothingLevel=Smoothing level (1–3) +config.skysterrainsmoother.enableMineralogy=Use Mineralogy slabs +config.skysterrainsmoother.enabled.tooltip=Smooth new dry Overworld slopes and cave floors. Turning this off also stops the separate Grass Slabs pass. Existing chunks stay unchanged. Restart Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: slabs. 2: slabs and steps. 3: slabs, steps and corners. Missing shapes use a slab only where it fits safely. Restart Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Use available natural Mineralogy rock slabs when the mod is installed. Disabled slab types are skipped. Restart Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/en_us.lang b/src/main/resources/assets/skysterrainsmoother/lang/en_us.lang index bc0a31d..e6603cc 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/en_us.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/en_us.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Sand Slab tile.skysterrainsmoother.red_sand_slab.name=Red Sand Slab +config.skysterrainsmoother.enabled=Enable terrain smoothing +config.skysterrainsmoother.smoothingLevel=Smoothing level (1–3) +config.skysterrainsmoother.enableMineralogy=Use Mineralogy slabs +config.skysterrainsmoother.enabled.tooltip=Smooth new dry Overworld slopes and cave floors. Turning this off also stops the separate Grass Slabs pass. Existing chunks stay unchanged. Restart Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: slabs. 2: slabs and steps. 3: slabs, steps and corners. Missing shapes use a slab only where it fits safely. Restart Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Use available natural Mineralogy rock slabs when the mod is installed. Disabled slab types are skipped. Restart Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/es_es.lang b/src/main/resources/assets/skysterrainsmoother/lang/es_es.lang index 13bbf46..847b125 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/es_es.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/es_es.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Losa de arena tile.skysterrainsmoother.red_sand_slab.name=Losa de arena roja +config.skysterrainsmoother.enabled=Activar el suavizado del terreno +config.skysterrainsmoother.smoothingLevel=Nivel de suavizado (1–3) +config.skysterrainsmoother.enableMineralogy=Usar losas de Mineralogy +config.skysterrainsmoother.enabled.tooltip=Suaviza pendientes secas y suelos de cuevas nuevos del mundo normal. Al desactivarlo también se detiene la generación separada de Grass Slabs. Los chunks existentes no cambian. Reinicia Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: losas. 2: losas y peldaños. 3: losas, peldaños y esquinas. Las formas que faltan solo se sustituyen por losas donde encajan sin empeorar el desnivel. Reinicia Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Usa las losas disponibles de rocas naturales de Mineralogy si está instalado. Omite los tipos de losa desactivados. Reinicia Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/es_mx.lang b/src/main/resources/assets/skysterrainsmoother/lang/es_mx.lang index 13bbf46..847b125 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/es_mx.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/es_mx.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Losa de arena tile.skysterrainsmoother.red_sand_slab.name=Losa de arena roja +config.skysterrainsmoother.enabled=Activar el suavizado del terreno +config.skysterrainsmoother.smoothingLevel=Nivel de suavizado (1–3) +config.skysterrainsmoother.enableMineralogy=Usar losas de Mineralogy +config.skysterrainsmoother.enabled.tooltip=Suaviza pendientes secas y suelos de cuevas nuevos del mundo normal. Al desactivarlo también se detiene la generación separada de Grass Slabs. Los chunks existentes no cambian. Reinicia Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: losas. 2: losas y peldaños. 3: losas, peldaños y esquinas. Las formas que faltan solo se sustituyen por losas donde encajan sin empeorar el desnivel. Reinicia Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Usa las losas disponibles de rocas naturales de Mineralogy si está instalado. Omite los tipos de losa desactivados. Reinicia Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/fr_ca.lang b/src/main/resources/assets/skysterrainsmoother/lang/fr_ca.lang index f4fcc90..644928e 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/fr_ca.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/fr_ca.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Dalle de sable tile.skysterrainsmoother.red_sand_slab.name=Dalle de sable rouge +config.skysterrainsmoother.enabled=Activer le lissage du terrain +config.skysterrainsmoother.smoothingLevel=Niveau de lissage (1–3) +config.skysterrainsmoother.enableMineralogy=Utiliser les dalles de Mineralogy +config.skysterrainsmoother.enabled.tooltip=Adoucit les nouvelles pentes sèches et les sols de grottes du monde normal. Désactiver arrête aussi la génération séparée de Grass Slabs. Les chunks existants ne changent pas. Redémarrez Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1 : dalles. 2 : dalles et quarts de bloc. 3 : dalles, quarts et huitièmes de bloc. Une forme manquante n'est remplacée par une dalle que si elle convient. Redémarrez Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Utilise les dalles disponibles des roches naturelles de Mineralogy si le mod est installé. Les types désactivés sont ignorés. Redémarrez Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/fr_fr.lang b/src/main/resources/assets/skysterrainsmoother/lang/fr_fr.lang index f4fcc90..644928e 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/fr_fr.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/fr_fr.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Dalle de sable tile.skysterrainsmoother.red_sand_slab.name=Dalle de sable rouge +config.skysterrainsmoother.enabled=Activer le lissage du terrain +config.skysterrainsmoother.smoothingLevel=Niveau de lissage (1–3) +config.skysterrainsmoother.enableMineralogy=Utiliser les dalles de Mineralogy +config.skysterrainsmoother.enabled.tooltip=Adoucit les nouvelles pentes sèches et les sols de grottes du monde normal. Désactiver arrête aussi la génération séparée de Grass Slabs. Les chunks existants ne changent pas. Redémarrez Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1 : dalles. 2 : dalles et quarts de bloc. 3 : dalles, quarts et huitièmes de bloc. Une forme manquante n'est remplacée par une dalle que si elle convient. Redémarrez Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Utilise les dalles disponibles des roches naturelles de Mineralogy si le mod est installé. Les types désactivés sont ignorés. Redémarrez Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/ja_jp.lang b/src/main/resources/assets/skysterrainsmoother/lang/ja_jp.lang index 10cbffc..0b30dd7 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/ja_jp.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/ja_jp.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=砂のハーフブロック tile.skysterrainsmoother.red_sand_slab.name=赤い砂のハーフブロック +config.skysterrainsmoother.enabled=地形の平滑化を有効化 +config.skysterrainsmoother.smoothingLevel=平滑化レベル (1–3) +config.skysterrainsmoother.enableMineralogy=Mineralogyのハーフブロックを使用 +config.skysterrainsmoother.enabled.tooltip=新しく生成されるオーバーワールドの乾いた斜面と洞窟の床をなだらかにします。無効にするとGrass Slabs独自の生成も停止します。既存のチャンクは変わりません。再起動が必要です。 +config.skysterrainsmoother.smoothingLevel.tooltip=1:ハーフブロック。2:ハーフブロックと段差用の小ブロック。3:さらに角用の小ブロック。形がない場合は安全に収まる場所だけハーフブロックを使います。再起動が必要です。 +config.skysterrainsmoother.enableMineralogy.tooltip=Mineralogy導入時に、利用可能な自然岩のハーフブロックを使います。無効にされた種類は使いません。Minecraftの再起動が必要です。 diff --git a/src/main/resources/assets/skysterrainsmoother/lang/ko_kr.lang b/src/main/resources/assets/skysterrainsmoother/lang/ko_kr.lang index 92553d3..2d393b1 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/ko_kr.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/ko_kr.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=모래 반 블록 tile.skysterrainsmoother.red_sand_slab.name=붉은 모래 반 블록 +config.skysterrainsmoother.enabled=지형 완화 활성화 +config.skysterrainsmoother.smoothingLevel=완화 단계 (1–3) +config.skysterrainsmoother.enableMineralogy=Mineralogy 반 블록 사용 +config.skysterrainsmoother.enabled.tooltip=새로 생성되는 오버월드의 마른 경사면과 동굴 바닥을 완만하게 만듭니다. 끄면 Grass Slabs의 별도 생성도 중지됩니다. 기존 청크는 바뀌지 않습니다. 다시 시작해야 합니다. +config.skysterrainsmoother.smoothingLevel.tooltip=1: 반 블록. 2: 반 블록과 계단 조각. 3: 반 블록, 계단 조각과 모서리 조각. 없는 모양은 안전하게 맞는 곳에서만 반 블록으로 대신합니다. 다시 시작해야 합니다. +config.skysterrainsmoother.enableMineralogy.tooltip=Mineralogy가 설치되어 있으면 사용 가능한 자연 암석 반 블록을 사용합니다. 비활성화된 종류는 건너뜁니다. Minecraft를 다시 시작해야 합니다. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/pt_br.lang b/src/main/resources/assets/skysterrainsmoother/lang/pt_br.lang index ebafd32..3d63a47 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/pt_br.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/pt_br.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Laje de Areia tile.skysterrainsmoother.red_sand_slab.name=Laje de Areia Vermelha +config.skysterrainsmoother.enabled=Ativar suavização do terreno +config.skysterrainsmoother.smoothingLevel=Nível de suavização (1–3) +config.skysterrainsmoother.enableMineralogy=Usar lajes do Mineralogy +config.skysterrainsmoother.enabled.tooltip=Suaviza novas encostas secas e pisos de cavernas do mundo normal. Desativar também interrompe a geração separada do Grass Slabs. Os chunks existentes não mudam. Reinicie o Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: lajes. 2: lajes e peças de um quarto de bloco. 3: também peças de um oitavo de bloco. Formas ausentes só usam uma laje onde ela cabe com segurança. Reinicie o Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Usa as lajes disponíveis de rochas naturais do Mineralogy quando ele está instalado. Tipos de laje desativados são ignorados. Reinicie o Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/pt_pt.lang b/src/main/resources/assets/skysterrainsmoother/lang/pt_pt.lang index 57891ce..79bb2b5 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/pt_pt.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/pt_pt.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Degrau de Areia tile.skysterrainsmoother.red_sand_slab.name=Degrau de Areia Vermelha +config.skysterrainsmoother.enabled=Ativar suavização do terreno +config.skysterrainsmoother.smoothingLevel=Nível de suavização (1–3) +config.skysterrainsmoother.enableMineralogy=Usar lajes do Mineralogy +config.skysterrainsmoother.enabled.tooltip=Suaviza novas encostas secas e pisos de grutas do mundo normal. Desativar também interrompe a geração separada do Grass Slabs. Os chunks existentes não mudam. Reinicie o Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: meios blocos. 2: meios blocos e quartos de bloco. 3: também oitavos de bloco. Formas em falta só usam um meio bloco onde caiba em segurança. Reinicie o Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Usa os degraus disponíveis de rochas naturais do Mineralogy quando está instalado. Os tipos desativados são ignorados. Reinicie o Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/ru_ru.lang b/src/main/resources/assets/skysterrainsmoother/lang/ru_ru.lang index 649f4ee..9dfebaa 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/ru_ru.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/ru_ru.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=Песчаная плита tile.skysterrainsmoother.red_sand_slab.name=Плита красного песка +config.skysterrainsmoother.enabled=Включить сглаживание рельефа +config.skysterrainsmoother.smoothingLevel=Уровень сглаживания (1–3) +config.skysterrainsmoother.enableMineralogy=Использовать плиты Mineralogy +config.skysterrainsmoother.enabled.tooltip=Сглаживает новые сухие склоны и полы пещер обычного мира. Выключение также останавливает отдельную генерацию Grass Slabs. Существующие чанки не меняются. Перезапустите Minecraft. +config.skysterrainsmoother.smoothingLevel.tooltip=1: плиты. 2: плиты и четверти блока. 3: также восьмые доли блока. Недостающую форму заменяет плита только там, где она безопасно помещается. Перезапустите Minecraft. +config.skysterrainsmoother.enableMineralogy.tooltip=Использует доступные плиты природных пород Mineralogy, если мод установлен. Отключённые виды плит пропускаются. Перезапустите Minecraft. diff --git a/src/main/resources/assets/skysterrainsmoother/lang/zh_cn.lang b/src/main/resources/assets/skysterrainsmoother/lang/zh_cn.lang index b6599a0..3a11c0b 100644 --- a/src/main/resources/assets/skysterrainsmoother/lang/zh_cn.lang +++ b/src/main/resources/assets/skysterrainsmoother/lang/zh_cn.lang @@ -1,2 +1,8 @@ tile.skysterrainsmoother.sand_slab.name=沙子台阶 tile.skysterrainsmoother.red_sand_slab.name=红沙台阶 +config.skysterrainsmoother.enabled=启用地形平滑 +config.skysterrainsmoother.smoothingLevel=平滑等级 (1–3) +config.skysterrainsmoother.enableMineralogy=使用 Mineralogy 台阶 +config.skysterrainsmoother.enabled.tooltip=平滑新生成的主世界干燥斜坡和洞穴地面。关闭后也会停止Grass Slabs的独立生成。已有区块不会改变。需要重启Minecraft。 +config.skysterrainsmoother.smoothingLevel.tooltip=1:台阶。2:台阶和四分之一方块。3:再加入八分之一方块。缺少的形状仅在安全合适的位置用台阶代替。需要重启Minecraft。 +config.skysterrainsmoother.enableMineralogy.tooltip=安装Mineralogy后使用可用的天然岩石台阶。已禁用的台阶类型会跳过。需要重启Minecraft。 diff --git a/src/test/java/zone/moddev/mc/skysterrainsmoother/EclipseDependencyContractTest.java b/src/test/java/zone/moddev/mc/skysterrainsmoother/EclipseDependencyContractTest.java new file mode 100644 index 0000000..b9e0a99 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysterrainsmoother/EclipseDependencyContractTest.java @@ -0,0 +1,27 @@ +package zone.moddev.mc.skysterrainsmoother; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EclipseDependencyContractTest { + @Test + void explicitLocalCandidateSurvivesEclipseRefreshesWithoutChangingCiLocks() throws Exception { + String build = new String(Files.readAllBytes(Paths.get("build.gradle")), StandardCharsets.UTF_8); + + assertTrue(build.contains("System.getenv('CI') != null")); + assertTrue(build.contains("!providers.gradleProperty('buildingPiecesDirectory').isPresent()")); + assertTrue(build.contains("arguments.removeAll")); + assertTrue(build.contains("arguments.add('-PbuildingPiecesDirectory=' + relativePath(buildingPiecesDirectory))")); + assertTrue(build.contains("arguments.add('-PbuildingPiecesCoreVersion=' + piecesVersion)")); + assertTrue(build.contains("preferences.setProperty('arguments', arguments.join(' '))")); + assertTrue(build.contains("Eclipse Buildship lost the selected content checkouts")); + assertTrue(build.contains("Eclipse Buildship retained an incorrect Building Pieces version override")); + assertTrue(build.contains("grassLibraries != [grassSlabsDevelopmentJar.canonicalFile]")); + assertTrue(build.contains("piecesLibraries != [buildingPiecesDevelopmentJar.canonicalFile]")); + assertTrue(build.contains("expected ${expectedVersion}, found ${metadata.mod_version}")); + } +} diff --git a/src/test/java/zone/moddev/mc/skysterrainsmoother/FootprintTest.java b/src/test/java/zone/moddev/mc/skysterrainsmoother/FootprintTest.java index e668346..d5edf69 100644 --- a/src/test/java/zone/moddev/mc/skysterrainsmoother/FootprintTest.java +++ b/src/test/java/zone/moddev/mc/skysterrainsmoother/FootprintTest.java @@ -56,6 +56,32 @@ void disconnectedAndThreeQuadrantFootprintsAreSlabs() { } } + @Test + void trimOnlyTheDownhillPartOfMixedTerraces() { + assertEquals(3, Footprint.trimDownhillEdges(7, 4), "keep the north step above a south drop"); + assertEquals(6, Footprint.trimDownhillEdges(7, 8), "keep the east step above a west drop"); + assertEquals(2, Footprint.trimDownhillEdges(7, 12), "keep the northeast corner above both drops"); + assertEquals(6, Footprint.trimDownhillEdges(6, 8), "an uphill-facing step already fits"); + assertEquals(0, Footprint.trimDownhillEdges(15, 5), "opposite drops leave no safe piece"); + assertThrows(IllegalArgumentException.class, () -> Footprint.trimDownhillEdges(16, 0)); + assertThrows(IllegalArgumentException.class, () -> Footprint.trimDownhillEdges(0, 16)); + } + + @Test + void everyTrimmedFootprintKeepsOnlyRequestedSafeQuadrants() { + for (int requested = 0; requested < 16; requested++) { + for (int lowerEdges = 0; lowerEdges < 16; lowerEdges++) { + int trimmed = Footprint.trimDownhillEdges(requested, lowerEdges); + assertEquals(0, trimmed & ~requested, "never add an unrequested quadrant"); + for (int direction = 0; direction < 4; direction++) { + if ((lowerEdges & 1 << direction) != 0) { + assertEquals(0, trimmed & Footprint.EDGES[direction], "never raise a downhill edge"); + } + } + } + } + } + @Test void borderPolicyNeverReadsTheUnownedNorthOrWestBorder() { for (int blockX = -2; blockX <= 18; blockX++) { diff --git a/src/test/java/zone/moddev/mc/skysterrainsmoother/MineralogyLaunchContractTest.java b/src/test/java/zone/moddev/mc/skysterrainsmoother/MineralogyLaunchContractTest.java new file mode 100644 index 0000000..c5984be --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysterrainsmoother/MineralogyLaunchContractTest.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.skysterrainsmoother; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class MineralogyLaunchContractTest { + @Test + void optionalProfilesReuseTheNormalLauncherWithoutAddingRuntimeDependencies() throws Exception { + String build = text("gradle/mineralogy-runs.gradle"); + assertTrue(build.contains("tasks.register('runMineralogyClient', JavaExec)")); + assertTrue(build.contains("tasks.register('runMineralogyServer', JavaExec)")); + assertTrue(build.contains("configureVerification(delegate, 'runClient')")); + assertTrue(build.contains("configureVerification(delegate, 'runServer')")); + assertTrue(build.contains("run-mineralogy")); + assertTrue(build.contains("ATTR_EXCLUDE_TEST_CODE")); + assertTrue(build.contains("bin/test")); + assertFalse(build.contains("implementation ")); + assertFalse(build.contains("runtimeOnly ")); + assertFalse(build.contains("into sourceSets.main")); + assertTrue(text("build.gradle").contains( + "contents.replace('${MC_VERSION}', project.minecraft_version.toString())")); + } + + @Test + void sourceJarsAreChecksumLockedAndPreparationPreservesPlayerData() throws Exception { + Properties lock = new Properties(); + lock.load(new java.io.StringReader(text("gradle/mineralogy-runtime.properties"))); + for (String mod : new String[] { "mineralogy", "orespawn" }) { + assertTrue(lock.getProperty(mod + ".sha256").matches("[A-F0-9]{64}")); + assertFalse(Paths.get(lock.getProperty(mod + ".path")).isAbsolute()); + } + String script = text("gradle/mineralogy-runs.gradle"); + assertTrue(script.contains(".gradle/qualification-dependencies/")); + assertTrue(script.contains("retainedJar.isFile() ? retainedJar : siblingJar")); + assertTrue(script.contains("onlyIf")); + assertTrue(script.contains("runtimeSources.values().every { it.isFile() }")); + assertTrue(script.contains("Missing optional")); + assertTrue(script.contains("does not match gradle/mineralogy-runtime.properties")); + assertFalse(script.contains("delete(gameDirectory")); + assertTrue(script.contains("original optional jars remain untouched")); + assertTrue(script.contains("minecraft.accessTransformers.from(accessRules)")); + assertTrue(script.contains("getValue('FMLAT')")); + } + + @Test + void probesUseSeparateGeneratedWorldsAndRequireFreshAndReloadEvidence() throws Exception { + String script = text("gradle/mineralogy-runs.gradle"); + assertTrue(script.contains("buildDirectory.dir('mineralogy-integration-run')")); + assertTrue(script.contains("server-port=0")); + assertTrue(script.contains("server-ip=127.0.0.1")); + assertTrue(script.contains("level-type=BIOMESOP")); + assertTrue(script.contains("fresh_complete")); + assertTrue(script.contains("reload_complete")); + assertTrue(script.contains("requireMineralogy")); + assertTrue(script.contains("testMineralogyShapes")); + } + + private static String text(String path) throws Exception { + return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } +} diff --git a/src/test/java/zone/moddev/mc/skysterrainsmoother/ProjectContractTest.java b/src/test/java/zone/moddev/mc/skysterrainsmoother/ProjectContractTest.java index cb41094..b93fda7 100644 --- a/src/test/java/zone/moddev/mc/skysterrainsmoother/ProjectContractTest.java +++ b/src/test/java/zone/moddev/mc/skysterrainsmoother/ProjectContractTest.java @@ -122,7 +122,7 @@ void sandResourcesAndGravityContract() throws Exception { assertTrue(content.endsWith("\n")); assertFalse(content.contains("\r")); assertFalse(content.contains("\uFEFF")); - assertEquals(2, content.split("\n").length); + assertEquals(8, content.split("\n").length); } } } diff --git a/src/test/java/zone/moddev/mc/skysterrainsmoother/PublicationContractTest.java b/src/test/java/zone/moddev/mc/skysterrainsmoother/PublicationContractTest.java index 08f4e50..7e0bbf8 100644 --- a/src/test/java/zone/moddev/mc/skysterrainsmoother/PublicationContractTest.java +++ b/src/test/java/zone/moddev/mc/skysterrainsmoother/PublicationContractTest.java @@ -33,7 +33,12 @@ void publicationNeedsThisModsMetadataAndExplicitApproval() throws Exception { assertTrue(workflow.contains("Build, test, and audit")); assertTrue(workflow.contains("sha256sum --check SHA256SUMS")); assertTrue(workflow.contains("publishMavenJavaPublicationToReleaseRepository")); - assertFalse(workflow.contains("1677588")); + assertTrue(workflow.contains("curseforge-retry-attempts: 1")); + // The content mods are dependencies, not this mod's upload destination. + for (String dependency : new String[] { "1677588", "1754162" }) { + assertNotEquals(dependency, projectId); + assertTrue(workflow.contains(dependency + "(required)")); + } assertTrue(workflow.indexOf(" publish_maven:") < workflow.indexOf(" publish_curseforge:")); assertTrue(workflow.indexOf(" publish_curseforge:") < workflow.indexOf(" publish_github:")); } diff --git a/src/test/java/zone/moddev/mc/skysterrainsmoother/RecipeAdvancementContractTest.java b/src/test/java/zone/moddev/mc/skysterrainsmoother/RecipeAdvancementContractTest.java new file mode 100644 index 0000000..5d2e6c1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysterrainsmoother/RecipeAdvancementContractTest.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.skysterrainsmoother; + +import com.google.gson.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RecipeAdvancementContractTest { + @Test + void everyBookEntryRewardsItsRecipeAndUsesAlternativeUnlockCriteria() throws Exception { + JsonArray entries = new JsonParser().parse(read(Paths.get("src/main/recipe-book/recipes.json"))).getAsJsonArray(); + assertTrue(entries.size() > 0); + Set recipes = new HashSet<>(); + for (JsonElement element : entries) { + JsonObject entry = element.getAsJsonObject(); + String recipe = entry.get("recipe").getAsString(); + assertTrue(recipes.add(recipe), recipe); + assertEquals("skysterrainsmoother", entry.get("owner").getAsString()); + Path path = Paths.get("build/resources/main/assets/skysterrainsmoother/advancements/recipes", + recipe.split(":", 2)[1] + ".json"); + JsonObject advancement = new JsonParser().parse(read(path)).getAsJsonObject(); + assertFalse(advancement.has("parent"), + "Large recipe catalogues must not share a recursive visibility parent: " + recipe); + assertEquals(recipe, advancement.getAsJsonObject("rewards").getAsJsonArray("recipes").get(0).getAsString()); + JsonObject criteria = advancement.getAsJsonObject("criteria"); + assertEquals("minecraft:recipe_unlocked", criteria.getAsJsonObject("has_the_recipe").get("trigger").getAsString()); + JsonArray requirements = advancement.getAsJsonArray("requirements"); + assertEquals(1, requirements.size(), "Unlock criteria must be alternatives: " + recipe); + assertEquals(criteria.size(), requirements.get(0).getAsJsonArray().size()); + for (Map.Entry criterion : criteria.entrySet()) { + if (criterion.getKey().equals("has_the_recipe")) continue; + assertEquals("minecraft:inventory_changed", criterion.getValue().getAsJsonObject().get("trigger").getAsString()); + } + } + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/zone/moddev/mc/skysterrainsmoother/config/ConfigScreenContractTest.java b/src/test/java/zone/moddev/mc/skysterrainsmoother/config/ConfigScreenContractTest.java new file mode 100644 index 0000000..6d74f1f --- /dev/null +++ b/src/test/java/zone/moddev/mc/skysterrainsmoother/config/ConfigScreenContractTest.java @@ -0,0 +1,119 @@ +package zone.moddev.mc.skysterrainsmoother.config; + +import java.io.File; +import java.nio.file.Path; +import net.minecraftforge.common.config.Configuration; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.relauncher.FMLInjectionData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zone.moddev.mc.skysterrainsmoother.SkysTerrainSmoother; +import static org.junit.jupiter.api.Assertions.*; + +class ConfigScreenContractTest { + @TempDir + Path temporaryDirectory; + + @BeforeEach + void provideMinecraftHome() throws Exception { + java.lang.reflect.Field home = FMLInjectionData.class.getDeclaredField("minecraftHome"); + home.setAccessible(true); + home.set(null, temporaryDirectory.toFile()); + } + + @Test + void configButtonHasAClientFactory() { + assertEquals("zone.moddev.mc.skysterrainsmoother.client.TerrainSmootherGuiFactory", + SkysTerrainSmoother.class.getAnnotation(Mod.class).guiFactory()); + } + + @Test + void newConfigurationsStartAtLevelThree() { + TerrainSmootherConfig.load(temporaryDirectory.resolve("new-world.cfg").toFile()); + assertEquals(3, SkysTerrainSmoother.level); + assertEquals("3", TerrainSmootherConfig.configuration() + .getCategory("worldgen").get("smoothingLevel").getDefault()); + } + + @Test + void loadingKeepsTheHelpTextOnEveryProperty() { + TerrainSmootherConfig.load(temporaryDirectory.resolve("help.cfg").toFile()); + Configuration pending = TerrainSmootherConfig.configuration(); + assertFalse(pending.getCategory("worldgen").get("enabled").getComment().trim().isEmpty()); + assertFalse(pending.getCategory("worldgen").get("smoothingLevel").getComment().trim().isEmpty()); + assertFalse(pending.getCategory("compat").get("enableMineralogy").getComment().trim().isEmpty()); + } + + @Test + void allLanguagesExplainEachConfigEntry() throws Exception { + try (java.util.stream.Stream languages = java.nio.file.Files.list( + java.nio.file.Paths.get("src/main/resources/assets/skysterrainsmoother/lang"))) { + for (Path language : languages.collect(java.util.stream.Collectors.toList())) { + java.util.Properties translations = new java.util.Properties(); + try (java.io.Reader reader = java.nio.file.Files.newBufferedReader(language, + java.nio.charset.StandardCharsets.UTF_8)) { + translations.load(reader); + } + assertFalse(translations.getProperty("config.skysterrainsmoother.enabled.tooltip", "").trim().isEmpty(), + language + " needs help for enabled"); + assertFalse(translations.getProperty("config.skysterrainsmoother.smoothingLevel.tooltip", "").trim().isEmpty(), + language + " needs help for smoothingLevel"); + assertFalse(translations.getProperty("config.skysterrainsmoother.enableMineralogy.tooltip", "").trim().isEmpty(), + language + " needs help for enableMineralogy"); + } + } + } + + @Test + void savingChoicesDoesNotChangeTheRunningSession() { + File file = temporaryDirectory.resolve("settings.cfg").toFile(); + Configuration existing = new Configuration(file); + existing.load(); + existing.get("worldgen", "smoothingLevel", 1).set(1); + existing.save(); + TerrainSmootherConfig.load(file); + Configuration pending = TerrainSmootherConfig.configuration(); + + assertTrue(pending.getCategory("worldgen").requiresMcRestart()); + assertTrue(pending.getCategory("compat").requiresMcRestart()); + pending.get("worldgen", "enabled", true).set(false); + pending.get("worldgen", "smoothingLevel", 1).set(3); + pending.get("compat", "enableMineralogy", true).set(false); + TerrainSmootherConfig.saveChanges(); + + // Saving the GUI must not re-arbitrate generation or turn on migration mid-world. + assertTrue(SkysTerrainSmoother.enabled); + assertEquals(1, SkysTerrainSmoother.level); + assertTrue(SkysTerrainSmoother.mineralogy); + Configuration written = new Configuration(file); + written.load(); + assertFalse(written.get("worldgen", "enabled", true).getBoolean()); + assertEquals(3, written.get("worldgen", "smoothingLevel", 1).getInt()); + assertFalse(written.get("compat", "enableMineralogy", true).getBoolean()); + + TerrainSmootherConfig.load(file); + assertFalse(SkysTerrainSmoother.enabled); + assertEquals(3, SkysTerrainSmoother.level); + assertFalse(SkysTerrainSmoother.mineralogy); + } + + @Test + void allThreeLevelsAndTheExistingBoundsArePreserved() { + File file = temporaryDirectory.resolve("levels.cfg").toFile(); + + for (int requested : new int[] { -1, 1, 2, 3, 9 }) { + Configuration input = new Configuration(file); + input.load(); + input.get("worldgen", "smoothingLevel", 1).set(requested); + input.save(); + TerrainSmootherConfig.load(file); + assertEquals(Math.max(1, Math.min(3, requested)), SkysTerrainSmoother.level); + assertEquals("1", TerrainSmootherConfig.configuration() + .getCategory("worldgen").get("smoothingLevel").getMinValue()); + assertEquals("3", TerrainSmootherConfig.configuration() + .getCategory("worldgen").get("smoothingLevel").getMaxValue()); + } + } + +}