From 5ba25c952ffdd93853d257c6c6adbb8370cde005 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 27 Oct 2026 12:54:37 +0000 Subject: [PATCH] Fix imported ore placement on 1.12.2 --- .github/workflows/ci.yml | 6 +- CHANGELOG.txt | 13 + README.md | 2 +- docs/CONFIGURATION.md | 14 + docs/MIGRATION.md | 7 + docs/VERSIONS.md | 2 +- gradle.properties | 2 +- gradle/README.md | 12 +- gradle/release/artifacts.gradle | 6 +- .../verification/legacy-and-migration.gradle | 79 ----- gradle/verification/support.gradle | 3 +- .../orespawn/compat/LegacyOs3Bridge.java | 2 +- .../zone/moddev/mc/orespawn/OreSpawn.java | 2 +- .../orespawn/client/GeologyEditorSession.java | 18 +- .../orespawn/client/OreSourceEditorView.java | 120 +++++++ .../client/OreSourceGroupSettingsScreen.java | 26 +- .../orespawn/client/OreSourceListScreen.java | 8 +- .../LegacyMineralogyProfileMigration.java | 2 +- .../worldgen/LegacyOs3ProfileMigration.java | 2 +- .../orespawn/worldgen/OreSourcePolicies.java | 35 +- .../worldgen/OreSpawnOreGeneration.java | 43 ++- .../orespawn/ReleaseWorkflowContractTest.java | 14 + .../client/LegacyOreSourceEditorTest.java | 182 ++++++++++ .../worldgen/LegacyPlacementChannelTest.java | 160 +++++++++ .../worldgen/LegacyVanillaOreTest.java | 21 ++ .../ore-source-editor/custom-channel.json | 128 +++++++ .../ore-source-editor/fresh-standard.json | 125 +++++++ .../ore-source-editor/os1-import.json | 142 ++++++++ .../ore-source-editor/os3-import.json | 142 ++++++++ tools/qualification/README.md | 53 +++ .../editor/EditorFixtureMod.java | 319 ++++++++++++++++++ tools/qualification/editor/probe.init.gradle | 31 ++ .../OreSourceQualificationMod.java | 161 +++++++++ .../ore-sources/probe.init.gradle | 26 ++ tools/qualification/ore-sources/qualify.py | 183 ++++++++++ 35 files changed, 1971 insertions(+), 120 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceEditorView.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/LegacyOreSourceEditorTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyPlacementChannelTest.java create mode 100644 src/test/resources/ore-source-editor/custom-channel.json create mode 100644 src/test/resources/ore-source-editor/fresh-standard.json create mode 100644 src/test/resources/ore-source-editor/os1-import.json create mode 100644 src/test/resources/ore-source-editor/os3-import.json create mode 100644 tools/qualification/README.md create mode 100644 tools/qualification/editor/EditorFixtureMod.java create mode 100644 tools/qualification/editor/probe.init.gradle create mode 100644 tools/qualification/ore-sources/OreSourceQualificationMod.java create mode 100644 tools/qualification/ore-sources/probe.init.gradle create mode 100644 tools/qualification/ore-sources/qualify.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb6d9eb3..43f8edf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,9 +126,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.1.2.112021.jar - build/libs/OreSpawn-4.1.2.112021-sources.jar - build/libs/OreSpawn-4.1.2.112021-javadoc.jar + build/libs/OreSpawn-4.1.3.112021.jar + build/libs/OreSpawn-4.1.3.112021-sources.jar + build/libs/OreSpawn-4.1.3.112021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f075477d..9719e393 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,16 @@ +Version 4.1.3.112021 + +* Apply hybrid and replacement background limits to imported built-in ore + patterns, just as for ordinary native rules. Keep explicit channels and + third-party deposit patterns independent. +* Retain older Ore Sources selections under their saved pattern names without + rewriting imported settings or regenerating existing chunks. +* Show imported placement choices in the editor without duplicate old-channel + rows. Distinguish an inactive source from a missing mod, and keep custom + channels and saved choices intact. +* Keep the empty Ore Sources message inside its pane in smaller windows. +* Remove old 1.10-only ABI test tasks that could start an empty server on 1.12. + Version 4.1.2.112021 * Respect Manage Vanilla Ores for imported OS1/OS3 rules. With it off, leave diff --git a/README.md b/README.md index 3c53402a..81b073f0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome palettes and world materials, flat bedrock, and bounded ore retrogen. -This branch builds target-qualified version `4.1.2.112021`: the OreSpawn 4.1.2 +This branch builds target-qualified version `4.1.3.112021`: the OreSpawn 4.1.3 feature set for Minecraft 1.12.2 and Forge. It adds stable generation identity, non-loading geology sampling, per-resource background control, existing-world provider merge policy, an Ore Sources arbiter for equivalent provider ores, a diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3d73eacb..c0f61665 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -309,6 +309,14 @@ informational because the original rules remain independent. The compact help and row tooltips explain fixed, selectable, missing and inactive states without reducing the alias-list height. +Built-in patterns use the standard channel whether their names are short +(`default`, `vein`) or fully qualified (`orespawn:default`, `orespawn:vein`). +This lets a child's hybrid or replacement policy reduce imported ordinary +veins too. An explicit `placement_channel` always wins, and third-party pattern +channels remain independent. Older saved source selections still select the +same rule; changing this classification does not rewrite their ore settings +or existing terrain. + Native vanilla ore rules are active placement sources only when `manage_vanilla_ores` is enabled. Changing that option refreshes the pending Ore Sources snapshot before the editor opens. While it is disabled, Group @@ -338,6 +346,12 @@ BaseSciences has no ordinary ore-source role. An Ore Dictionary member whose generator is not controlled by OreSpawn is reported as external generation and is never suppressed, so total abundance may still be increased by that mod. +Older imported profiles may still store a built-in pattern name as their +placement channel. The Ore Sources editor shows a single ordinary placement +row for that choice; opening the screen does not rewrite the saved profile. +Explicit custom channels stay separate. A source whose placement is switched +off remains listed as output-only, rather than being reported as a missing mod. + ## Fluid Deposits, Retrogen, And Bedrock Each `fluid_deposits` entry has a stable provider-namespaced rule ID, an diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 742e94ba..439a78ee 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -159,6 +159,13 @@ Original), preserving prior placement frequency, ordering and output behaviour. Missing selected sources and dormant policies remain recorded. Policy changes affect new chunks only and do not add retrogen. +Legacy imports may spell built-in patterns as `orespawn:default` or another +fully qualified built-in name. These are ordinary generation, so hybrid and +replacement background limits apply to them. Saved Ore Sources choices under +those older inferred channel names remain valid without changing the saved +ore rule. Explicit channels, custom patterns, imported quantities and heights, +and already generated chunks are left alone. + The old `place_crude_oil` and singleton `oil` fields migrate to `place_fluid_deposits` and a provider-owned rule. A valid block becomes `:fluid_deposit/`; `minecraft:air` creates no rule. Mineralogy diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index dc7e11f1..67af7977 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -52,7 +52,7 @@ Examples: | Minecraft | Loader | Target | Example full OreSpawn version | | --- | --- | ---: | --- | | 1.10.2 | Forge | `110021` | `4.0.6.110021` | -| 1.12.2 | Forge | `112021` | `4.1.2.112021` | +| 1.12.2 | Forge | `112021` | `4.1.3.112021` | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | diff --git a/gradle.properties b/gradle.properties index e0772499..109768fd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,7 +30,7 @@ mod_vendor=Minecraft Mod Development mod_license=LGPL-2.1 mod_license_name=GNU Lesser General Public License, Version 2.1 mod_license_url=https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt -mod_version=4.1.2.112021 +mod_version=4.1.3.112021 mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. diff --git a/gradle/README.md b/gradle/README.md index 1acdcfac..08193d24 100644 --- a/gradle/README.md +++ b/gradle/README.md @@ -11,7 +11,7 @@ to the tasks they configure: | Script | Responsibility | | --- | --- | | `verification/support.gradle` | Sealed fixtures, Java 8 verification, Forge-run delegation, runtime log audits. | -| `verification/legacy-and-migration.gradle` | Surface and migration worlds, Mineralogy lineage, opt-in OS1/OS3 ABI probes. | +| `verification/legacy-and-migration.gradle` | Surface and migration worlds, including both Mineralogy lineages. | | `verification/client-and-benchmark.gradle` | Client integration and opt-in worldgen benchmark. | | `verification/packaged-forge.gradle` | Fresh/reload test of the reobfuscated jar under installed Forge 14. | | `release/artifacts.gradle` | API and release jars, release configuration and artifact audits, checksums. | @@ -28,8 +28,12 @@ The routine offline gate runs `clean check build javadoc verifyReleaseArtifacts Run `clientIntegrationProcess` separately for rendered client and editor coverage, and `genEclipseRuns verifyEclipseProductionClasspath` for ordinary production-only IDE launches. -`legacyAbiIntegrationTest` and `benchmarkIntegrationProcess` remain opt-in -compatibility and performance gates. The benchmark requires `benchmarkRunDir`; -the packaged server gate requires `packagedMinecraftServerJar` and +`benchmarkIntegrationProcess` is an opt-in performance gate and requires +`benchmarkRunDir`. The packaged server gate requires `packagedMinecraftServerJar` and `packagedForgeLibrariesRoot`, and its server command ends with literal `nogui`. All gates support the existing `--offline --no-daemon` workflow. + +The ordinary tests cover the OS3 bridge and legacy Codec linkage. Published +OS3 3.2.2 and 3.3.1 consumer binaries are also checked in separate packaged +Forge runs with the sealed 1.12 fixtures. Those checks use the original +target-native binaries, not the removed 1.10 fixture source layout. diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle index db74d293..c338de23 100644 --- a/gradle/release/artifacts.gradle +++ b/gradle/release/artifacts.gradle @@ -81,7 +81,7 @@ tasks.register('verifyReleaseConfiguration') { description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' doLast { - if (project.mod_version != '4.1.2.112021' + if (project.mod_version != '4.1.3.112021' || project.mod_group != expectedMavenGroup) { throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") } @@ -108,8 +108,8 @@ tasks.register('verifyReleaseConfiguration') { 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', 'README.md', 'CHANGELOG.txt' ].each { path -> - if (!file(path).getText('UTF-8').contains('4.1.2.112021')) { - throw new GradleException("Authoritative release location does not contain 4.1.2.112021: ${path}") + if (!file(path).getText('UTF-8').contains('4.1.3.112021')) { + throw new GradleException("Authoritative release location does not contain 4.1.3.112021: ${path}") } } if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[4.0.6,5.0.0)')) { diff --git a/gradle/verification/legacy-and-migration.gradle b/gradle/verification/legacy-and-migration.gradle index fe345e27..f0ac446f 100644 --- a/gradle/verification/legacy-and-migration.gradle +++ b/gradle/verification/legacy-and-migration.gradle @@ -1,5 +1,4 @@ def verification = project.ext.oreSpawnVerification -def legacyFixtureArtifacts = verification.fixtureArtifacts def legacyFixtureWorlds = verification.fixtureWorlds def legacyMineralogy112OracleJar = verification.mineralogy112Oracle def verifyLegacyFixtures = tasks.named('verifyLegacyFixtures') @@ -162,84 +161,6 @@ if (project.hasProperty('migrationRunDir')) { } } -// These probes were compiled against older OreSpawn APIs. Run them on demand to -// confirm that those public binaries still load without rebuilding the mods. -def os1AbiFixtureClasses = file("${buildDir}/legacy-abi/os1/classes") -def os3AbiFixtureClasses = file("${buildDir}/legacy-abi/os3/classes") - -task compileOs1AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { - source fileTree('src/os1AbiFixture/java') - classpath = files(sourceSets.main.compileClasspath, - new File(legacyFixtureArtifacts, 'OreSpawn_1.10.2-1.1.0.jar')) - destinationDirectory = os1AbiFixtureClasses - options.encoding = 'UTF-8' -} - -task os1AbiFixtureJar(type: Jar, dependsOn: compileOs1AbiFixture) { - archiveFileName = 'os1abiprobe.jar' - destinationDirectory = file("${buildDir}/legacy-abi/os1") - from os1AbiFixtureClasses - from 'src/os1AbiFixture/resources' -} - -task compileOs3AbiFixture(type: JavaCompile, dependsOn: verifyLegacyFixtures) { - source fileTree('src/os3AbiFixture/java') - classpath = files(sourceSets.main.compileClasspath, - new File(legacyFixtureArtifacts, 'OreSpawn-1.10.2-3.2.2.104.jar')) - destinationDirectory = os3AbiFixtureClasses - options.encoding = 'UTF-8' -} - -task os3AbiFixtureJar(type: Jar, dependsOn: compileOs3AbiFixture) { - archiveFileName = 'os3abiprobe.jar' - destinationDirectory = file("${buildDir}/legacy-abi/os3") - from os3AbiFixtureClasses - from 'src/os3AbiFixture/resources' -} - -def configureLegacyAbiProcess = { String generation, Task fixtureJar, String markerName -> - File runDirectory = file("${buildDir}/legacy-abi/${generation}/run") - Task prepare = tasks.create("prepare${generation.capitalize()}AbiRun", Copy) { - dependsOn fixtureJar - into new File(runDirectory, 'mods') - from fixtureJar.archiveFile - doFirst { project.delete(runDirectory) } - doLast { - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - new File(runDirectory, 'server.properties').setText( - 'eula=true\nonline-mode=false\nlevel-name=world\nlevel-seed=zsjpxah\n' + - 'spawn-animals=false\nspawn-monsters=false\n', 'UTF-8') - } - } - tasks.create("${generation}AbiIntegrationProcess", JavaExec) { - group = 'verification' - dependsOn prepare - workingDir runDirectory - configureFromForgeRun(delegate, 'runServer') - doLast { - File marker = new File(runDirectory, "world/${markerName}") - if (!marker.isFile() || !marker.text.contains('registered=true')) { - throw new GradleException("${generation.toUpperCase()} binary ABI probe did not complete: ${marker}") - } - File latest = new File(runDirectory, 'logs/latest.log') - if (latest.isFile() && latest.text.contains('Encountered an unexpected exception')) { - throw new GradleException("${generation.toUpperCase()} binary ABI probe logged a server failure") - } - } - } -} - -configureLegacyAbiProcess('os1', os1AbiFixtureJar, 'os1-abi-probe.properties') -configureLegacyAbiProcess('os3', os3AbiFixtureJar, 'os3-abi-probe.properties') - -task legacyAbiIntegrationTest { - group = 'verification' - dependsOn os1AbiIntegrationProcess, os3AbiIntegrationProcess -} - -// Forge 1.12's published OS3 ABI fixture remains an opt-in external gate; the -// 1.10 OS1/OS3 compiled probes are not part of this target's standard check. - // The same old save is exercised with 1.10-origin and native 1.12 Mineralogy // settings. Both paths must keep their intended values through a reload. def legacyMineralogyArchive = new File(legacyFixtureWorlds, 'os3-331-default-source.zip') diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle index ad86ed54..f456a4a6 100644 --- a/gradle/verification/support.gradle +++ b/gradle/verification/support.gradle @@ -22,7 +22,7 @@ def legacyFixtureHashes = [ task verifyLegacyFixtures { group = 'verification' - description = 'Verifies the sealed ABI and migration corpus used only by isolated tests.' + description = 'Verifies the sealed Mineralogy and migration fixtures used only by isolated tests.' inputs.files legacyFixtureHashes.keySet().collect { new File(legacyFixtureRoot, it) } doLast { legacyFixtureHashes.each { String relativePath, String expectedHash -> @@ -309,7 +309,6 @@ tasks.named('check') { // The other verification scripts use these helpers and fixture locations. // Keep this list small so the scripts remain understandable on their own. ext.oreSpawnVerification = [ - fixtureArtifacts: legacyFixtureArtifacts, fixtureWorlds: legacyFixtureWorlds, mineralogy112Oracle: legacyMineralogy112OracleJar, java8Launcher: java8Launcher, diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java index 84dd558c..c58b71c4 100644 --- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java +++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java @@ -853,7 +853,7 @@ private static void writeHumanUpgradeReport(Path destination) throws IOException } } List lines = new ArrayList<>(); - lines.add("OreSpawn 4.1.2.112021 Upgrade Report"); + lines.add("OreSpawn 4.1.3.112021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4."); diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index 05731823..938a5e14 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -52,7 +52,7 @@ public class OreSpawn { public static final String MODID = "orespawn"; public static final String NAME = "OreSpawn"; - public static final String VERSION = "4.1.2.112021"; + public static final String VERSION = "4.1.3.112021"; private static final Logger LOGGER = LogManager.getLogger(); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index e6068d0a..21443391 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -409,7 +409,7 @@ List oreSourceGroups() { List result = new ArrayList<>(); for (Entry entry : section("ore_source_policies").entrySet()) { if (!entry.getValue().isJsonObject()) continue; - JsonObject policy = entry.getValue().getAsJsonObject(); + JsonObject policy = OreSourceEditorView.policy(root, entry.getValue().getAsJsonObject()); String material = string(policy, "material", ""); String domain = string(policy, "domain", ""); if (!validResource(material) || !validResource(domain)) continue; @@ -983,8 +983,8 @@ private static boolean hasManagedPlacementSource(JsonObject policy) { } private static void refreshOreSourceStatus(JsonObject policy) { - Set missing = new HashSet<>(); - Set inactivePlacements = new HashSet<>(); + Set loaded = new HashSet<>(); + Set loadedPlacements = new HashSet<>(); boolean external = false; boolean review = bool(policy, "review_required", false); if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { @@ -992,8 +992,10 @@ private static void refreshOreSourceStatus(JsonObject policy) { if (!element.isJsonObject()) continue; JsonObject candidate = element.getAsJsonObject(); String sourceId = string(candidate, "source_id", ""); - if (!bool(candidate, "loaded", false)) missing.add(sourceId); - if (!bool(candidate, "placement_active", false)) inactivePlacements.add(sourceId); + if (bool(candidate, "loaded", false)) { + loaded.add(sourceId); + loadedPlacements.add(sourceId + "|" + string(candidate, "placement_channel", "orespawn:standard")); + } external |= bool(candidate, "external", false) && bool(candidate, "loaded", false); } } @@ -1005,10 +1007,10 @@ private static void refreshOreSourceStatus(JsonObject policy) { return; } for (String id : outputs.keySet()) { - if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; } + if (!loaded.contains(id)) { policy.addProperty("status", "missing_source"); return; } } - for (String id : placements.values()) { - if (missing.contains(id) || inactivePlacements.contains(id)) { + for (Entry placement : placements.entrySet()) { + if (!loadedPlacements.contains(placement.getValue() + "|" + placement.getKey())) { policy.addProperty("status", "missing_source"); return; } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceEditorView.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceEditorView.java new file mode 100644 index 00000000..0bb26129 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceEditorView.java @@ -0,0 +1,120 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; + +/** Presents old inferred channel names without rewriting the world's saved choices. */ +final class OreSourceEditorView { + private static final String STANDARD = "orespawn:standard"; + private static final Set OLD_CHANNELS = new HashSet<>(Arrays.asList( + "orespawn:default", "orespawn:vein", "orespawn:normal_cloud", + "orespawn:precision", "orespawn:clusters", "orespawn:underfluids")); + + private OreSourceEditorView() { } + + static JsonObject policy(JsonObject root, JsonObject saved) { + JsonObject view = JsonCopies.copy(saved); + JsonObject placements = object(view, "placement_sources"); + JsonArray candidates = view.has("candidates") && view.get("candidates").isJsonArray() + ? view.getAsJsonArray("candidates") : new JsonArray(); + String domain = text(view, "domain"); + List aliases = new ArrayList<>(); + + for (Map.Entry entry : placements.entrySet()) { + String channel = entry.getKey(); + if (!OLD_CHANNELS.contains(channel) || !entry.getValue().isJsonPrimitive()) continue; + + String source = entry.getValue().getAsString(); + if (inferredAlias(root, source, domain, channel) + && loadedCandidate(candidates, source, STANDARD) != null) { + aliases.add(channel); + } + } + + // More than one old pattern may have been selected deliberately. Keep + // those rows separate rather than choosing a winner on the player's behalf. + boolean combine = placements.has(STANDARD) || aliases.size() == 1; + if (combine) { + for (String alias : aliases) { + if (!placements.has(STANDARD)) placements.add(STANDARD, placements.get(alias)); + placements.remove(alias); + } + } + + JsonArray visible = new JsonArray(); + for (JsonElement element : candidates) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String source = text(candidate, "source_id"); + String channel = text(candidate, "placement_channel"); + JsonObject current = loadedCandidate(candidates, source, STANDARD); + + if (!combine && !aliases.isEmpty() && STANDARD.equals(channel) && current != null) { + boolean projected = false; + for (String alias : aliases) { + if (!inferredAlias(root, source, domain, alias)) continue; + + JsonObject retained = JsonCopies.copy(current); + retained.addProperty("placement_channel", alias); + visible.add(retained); + projected = true; + } + if (projected) continue; + } + + if (current != null && OLD_CHANNELS.contains(channel) + && inferredAlias(root, source, domain, channel)) { + continue; + } + visible.add(candidate); + } + + view.add("candidates", visible); + view.add("placement_sources", placements); + return view; + } + + private static boolean inferredAlias(JsonObject root, String source, String domain, String channel) { + JsonObject ore = object(object(root, "ores"), source); + JsonObject dimensions = object(ore, "dimensions"); + JsonObject rule = dimensions.has(domain) ? object(dimensions, domain) + : object(object(ore, "dimension_selectors"), domain); + if (rule.has("placement_channel")) return false; + + JsonElement pattern = rule.get("pattern"); + if (pattern == null || (!pattern.isJsonObject() && !pattern.isJsonPrimitive())) return false; + return channel.equals(pattern.isJsonObject() + ? text(pattern.getAsJsonObject(), "type") : pattern.getAsString()); + } + + private static JsonObject loadedCandidate(JsonArray candidates, String source, String channel) { + for (JsonElement element : candidates) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (source.equals(text(candidate, "source_id")) + && channel.equals(text(candidate, "placement_channel")) + && candidate.has("loaded") && candidate.get("loaded").getAsBoolean()) { + return candidate; + } + } + return null; + } + + private static JsonObject object(JsonObject parent, String key) { + return parent.has(key) && parent.get(key).isJsonObject() + ? parent.getAsJsonObject(key) : new JsonObject(); + } + + private static String text(JsonObject parent, String key) { + return parent.has(key) && parent.get(key).isJsonPrimitive() ? parent.get(key).getAsString() : ""; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java index 11438f0c..029326fe 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -152,11 +152,10 @@ protected void init() { @Override protected String rowText(int index) { String channel = channels.get(index); List choices = OreSourceListScreen.placementCandidates(group, channel); - String selected = group.placements.get(channel); - int choice = OreSourceListScreen.indexOf(choices, selected); - String source = choice < 0 + OreSourceCandidate selected = selectedPlacementCandidate(group, channel); + String source = selected == null ? I18n.format("label.orespawn.ore_source.missing") - : OreSourceListScreen.candidateLabel(choices.get(choice)); + : OreSourceListScreen.candidateLabel(selected); return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + placementSourceLabel(source, choices.size()); } @@ -189,6 +188,9 @@ protected void init() { lines.add(I18n.format("tooltip.orespawn.ore_source.placement_rules")); if ("keep_separate".equals(group.mode)) { lines.add(I18n.format("tooltip.orespawn.ore_source.placement_requires_consolidated")); + } else if (selectedPlacementCandidate(group, channel) != null + && !selectedPlacementCandidate(group, channel).active) { + lines.add(I18n.format("tooltip.orespawn.ore_source.output_only")); } else if (choices.isEmpty()) { lines.add(I18n.format("tooltip.orespawn.ore_source.placement_missing")); } else if (choices.size() == 1) { @@ -325,6 +327,16 @@ static boolean placementSelectable(OreSourceGroup group, String channel) { && OreSourceListScreen.placementCandidates(group, channel).size() > 1; } + static OreSourceCandidate selectedPlacementCandidate(OreSourceGroup group, String channel) { + String selected = group.placements.get(channel); + for (OreSourceCandidate candidate : group.candidates) { + if (candidate.loaded && candidate.sourceId.equals(selected) && candidate.channel.equals(channel)) { + return candidate; + } + } + return null; + } + static String placementStatus(OreSourceGroup group, List channels) { if (channels.isEmpty()) return I18n.format("label.orespawn.ore_source.no_placement_rules"); if ("keep_separate".equals(group.mode)) { @@ -333,7 +345,11 @@ static String placementStatus(OreSourceGroup group, List channels) { boolean selectable = false; for (String channel : channels) { int choices = OreSourceListScreen.placementCandidates(group, channel).size(); - if (choices == 0) return I18n.format("tooltip.orespawn.ore_source.placement_missing"); + if (choices == 0) { + return I18n.format(selectedPlacementCandidate(group, channel) == null + ? "tooltip.orespawn.ore_source.placement_missing" + : "tooltip.orespawn.ore_source.output_only"); + } selectable |= choices > 1; } return I18n.format(selectable ? "tooltip.orespawn.ore_source.placement_select" diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java index 281cb6e8..eba9479e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -507,8 +507,12 @@ public void render(int mouseX, int mouseY, float partialTick) { new TextComponentString(error), width - 24)), width / 2, height - 39, 0xFF5555); } if (selected(orderedGroups()) == null) { - drawCenteredString(font, new TextComponentTranslation("label.orespawn.ore_source.none"), - leftPaneX + (leftPaneWidth / 2), CONTENT_TOP + 10, 0xA0A0A0); + int y = CONTENT_TOP + 10; + for (String line : font.listFormattedStringToWidth( + I18n.format("label.orespawn.ore_source.none"), leftPaneWidth - 12)) { + drawString(font, new TextComponentString(line), leftPaneX + 6, y, 0xA0A0A0); + y += font.FONT_HEIGHT + 2; + } } updateWeightBounds(); super.render(mouseX, mouseY, partialTick); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java index 79c4db3f..a2621574 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -152,7 +152,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configDirectory, Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); List missing = missingBlocks(igneous, metamorphic, sedimentary); List lines = new ArrayList<>(); - lines.add("OreSpawn 4.1.2.112021 Upgrade Report"); + lines.add("OreSpawn 4.1.3.112021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java index f394e9f2..da91caf2 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java @@ -65,7 +65,7 @@ private static void writeInitialUpgradeReport(Path configDirectory, boolean forceRetrogen, boolean flatBedrock, boolean retrogenBedrock, int bedrockLayers) throws IOException { String newline = System.lineSeparator(); - String text = "OreSpawn 4.1.2.112021 Upgrade Report" + newline + String text = "OreSpawn 4.1.3.112021 Upgrade Report" + newline + "================================" + newline + newline + "RESULT: Legacy OreSpawn settings were imported into the OS4 profile." + newline + "- Manage vanilla ores: " + manageVanilla + newline diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java index 18c79f58..6fb1afe7 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -167,16 +167,45 @@ static ResourceLocation material(JsonObject root, JsonObject ore, ItemStack outp static ResourceLocation placementChannel(JsonObject rule) { ResourceLocation explicit = resource(string(rule, "placement_channel", "")); - if (explicit != null) return explicit; + + if (explicit != null) { + return explicit; + } + JsonObject pattern = rule.has("pattern") && rule.get("pattern").isJsonObject() ? rule.getAsJsonObject("pattern") : null; + if (pattern != null) { ResourceLocation type = resource(string(pattern, "type", "")); - if (type != null) return type; + + if (type != null) { + return patternChannel(type); + } } + String legacy = string(rule, "pattern", ""); ResourceLocation type = legacy.indexOf(':') >= 0 ? resource(legacy) : null; - return type != null ? type : STANDARD; + return type != null ? patternChannel(type) : STANDARD; + } + + static ResourceLocation patternChannel(ResourceLocation type) { + // Imports name built-in patterns in full, but they still place ordinary veins. + // Only these known patterns share a channel; custom patterns stay independent. + if ("orespawn".equals(type.getNamespace())) { + switch (type.getPath()) { + case "default": + case "vein": + case "normal_cloud": + case "precision": + case "clusters": + case "underfluids": + return STANDARD; + default: + break; + } + } + + return type; } static Inference inferMaterial(Iterable oreNames) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java index 8a5beaad..e2063d4b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -534,8 +534,8 @@ private static List arbitrate(JsonObject profile, List origi profile, first.material, first.policyDomain, available); List selected = new ArrayList<>(); for (Entry placement : placements.entrySet()) { - ResourceLocation channel = placement.getKey(); ResourceLocation source = placement.getValue(); + ResourceLocation channel = available.get(source); for (BakedOre candidate : entry.getValue()) { if (channel.equals(candidate.channel) && source.equals(candidate.ruleId)) { selected.add(candidate.withSources(sources)); @@ -570,19 +570,54 @@ static Map selectedPlacementRules(JsonObject JsonObject policy = policies.getAsJsonObject(key); if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) return null; Map result = new LinkedHashMap<>(); - for (Entry placement : objectOrEmpty( - policy, "placement_sources").entrySet()) { + JsonObject placements = objectOrEmpty(policy, "placement_sources"); + + for (Entry placement : placements.entrySet()) { ResourceLocation channel = resource(placement.getKey()); ResourceLocation source; try { source = resource(placement.getValue().getAsString()); } catch (RuntimeException invalid) { continue; } - if (channel != null && source != null && channel.equals(availableSourceChannels.get(source))) { + + if (channel == null || source == null) { + continue; + } + + ResourceLocation currentChannel = availableSourceChannels.get(source); + boolean retainedAlias = !placements.has(OreSourcePolicies.STANDARD.toString()) + && legacyChannelSelection(profile, source, domain, channel, currentChannel); + + if (channel.equals(currentChannel) || retainedAlias) { result.put(channel, source); } } return Collections.unmodifiableMap(result); } + private static boolean legacyChannelSelection(JsonObject profile, ResourceLocation source, + ResourceLocation domain, ResourceLocation savedChannel, ResourceLocation currentChannel) { + if (!OreSourcePolicies.STANDARD.equals(currentChannel) + || !OreSourcePolicies.STANDARD.equals(OreSourcePolicies.patternChannel(savedChannel))) { + return false; + } + + JsonObject ore = objectOrEmpty(objectOrEmpty(profile, "ores"), source.toString()); + JsonObject dimensions = objectOrEmpty(ore, "dimensions"); + JsonObject placement = dimensions.has(domain.toString()) + ? objectOrEmpty(dimensions, domain.toString()) + : objectOrEmpty(objectOrEmpty(ore, "dimension_selectors"), domain.toString()); + + // Honour the old inferred name without changing a saved selection. A newer + // explicit channel or standard-channel choice must not fall back to this alias. + if (placement.has("placement_channel")) { + return false; + } + + JsonObject pattern = objectOrEmpty(placement, "pattern"); + ResourceLocation oldChannel = resource(pattern.has("type") + ? string(pattern, "type", "") : string(placement, "pattern", "")); + return savedChannel.equals(oldChannel); + } + static int weightedIndex(double sample, double... weights) { double total = 0.0D; for (double weight : weights) if (Double.isFinite(weight) && weight > 0.0D) total += weight; diff --git a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java index d83acc59..e9d13595 100644 --- a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -13,6 +13,20 @@ import org.junit.jupiter.api.Test; class ReleaseWorkflowContractTest { + @Test + void verificationDoesNotOfferTheRemovedForge110AbiFixtures() throws Exception { + String migration = readScript("gradle/verification/legacy-and-migration.gradle"); + for (String removed : new String[] { + "compileOs1AbiFixture", "os1AbiFixtureJar", "prepareOs1AbiRun", "os1AbiIntegrationProcess", + "compileOs3AbiFixture", "os3AbiFixtureJar", "prepareOs3AbiRun", "os3AbiIntegrationProcess", + "legacyAbiIntegrationTest", "configureLegacyAbiProcess", "src/os1AbiFixture", "src/os3AbiFixture" }) { + assertFalse(migration.contains(removed), "Obsolete 1.10 fixture wiring: " + removed); + } + assertTrue(migration.contains("legacyMineralogyGates")); + assertTrue(migration.contains("dependsOn legacyMineralogyGates")); + assertFalse(readScript("gradle/README.md").contains("legacyAbiIntegrationTest")); + } + @Test void releaseAndHostedWorkflowContractsRemainTargetNative() throws Exception { Properties properties = new Properties(); diff --git a/src/test/java/zone/moddev/mc/orespawn/client/LegacyOreSourceEditorTest.java b/src/test/java/zone/moddev/mc/orespawn/client/LegacyOreSourceEditorTest.java new file mode 100644 index 00000000..1a07e753 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/LegacyOreSourceEditorTest.java @@ -0,0 +1,182 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; +import zone.moddev.mc.orespawn.test.Forge14TestBootstrap; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +class LegacyOreSourceEditorTest { + private static final String GROUP = "orespawn:iron|minecraft:overworld"; + + @BeforeAll + static void bootstrap() { + Forge14TestBootstrap.registerVanilla(); + } + + @Test + void emptyListMessageWrapsInsideThePane() throws Exception { + String source = new String(Files.readAllBytes(Paths.get( + "src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java")), StandardCharsets.UTF_8); + assertTrue(source.contains("font.listFormattedStringToWidth(")); + assertTrue(source.contains("leftPaneWidth - 12")); + assertTrue(source.contains("leftPaneX + 6, y")); + } + + @Test + void importedSelectionsHaveOneLiveStandardRowWithoutChangingTheSavedProfile() throws Exception { + for (String name : Arrays.asList("os1-import", "os3-import")) { + JsonObject fixture = fixture(name); + GeologyEditorSession session = session(fixture); + String before = session.root().toString(); + OreSourceGroup group = session.oreSourceGroups().get(0); + + assertEquals(Collections.singletonList("orespawn:standard"), + OreSourceGroupSettingsScreen.placementChannels(group), name); + assertEquals("consolidated", group.status, name); + assertEquals(2, group.candidates.size(), name); + assertEquals(2, OreSourceListScreen.placementCandidates(group, "orespawn:standard").size()); + assertEquals(before, session.root().toString(), "Viewing must not rewrite saved choices"); + } + } + + @Test + void explicitCustomChannelsAndFreshStandardSelectionsStaySeparate() throws Exception { + for (String name : Arrays.asList("custom-channel", "fresh-standard")) { + GeologyEditorSession session = session(fixture(name)); + String before = session.root().toString(); + OreSourceGroup group = session.oreSourceGroups().get(0); + String expected = name.equals("custom-channel") ? "example:custom_iron" : "orespawn:standard"; + + assertEquals(Collections.singletonList(expected), + OreSourceGroupSettingsScreen.placementChannels(group)); + assertEquals("consolidated", group.status); + assertEquals(before, session.root().toString()); + } + } + + @Test + void anExplicitOldDefaultChannelIsNotAnInferredAlias() throws Exception { + JsonObject fixture = fixture("custom-channel"); + fixture.getAsJsonObject("ores").getAsJsonObject("example:iron") + .getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") + .addProperty("placement_channel", "orespawn:default"); + JsonObject policy = policy(fixture); + policy.getAsJsonObject("placement_sources").remove("example:custom_iron"); + policy.getAsJsonObject("placement_sources").addProperty("orespawn:default", "example:iron"); + for (com.google.gson.JsonElement entry : policy.getAsJsonArray("candidates")) { + entry.getAsJsonObject().addProperty("placement_channel", "orespawn:default"); + } + + assertEquals(Collections.singletonList("orespawn:default"), + OreSourceGroupSettingsScreen.placementChannels(session(fixture).oreSourceGroups().get(0))); + } + + @Test + void aNewStandardChoiceWinsButAnActuallyMissingSourceStillNeedsAttention() throws Exception { + JsonObject fixture = fixture("os3-import"); + policy(fixture).getAsJsonObject("placement_sources") + .addProperty("orespawn:standard", "example:alternative_iron"); + OreSourceGroup group = session(fixture).oreSourceGroups().get(0); + + assertEquals(Collections.singletonList("orespawn:standard"), + OreSourceGroupSettingsScreen.placementChannels(group)); + assertEquals("example:alternative_iron", group.placements.get("orespawn:standard")); + assertEquals("consolidated", group.status); + + policy(fixture).getAsJsonObject("placement_sources").addProperty("orespawn:standard", "missing:iron"); + assertEquals("missing_source", session(fixture).oreSourceGroups().get(0).status); + } + + @Test + void editsRoundTripWithoutChangingRulesAndCancelLeavesTheOriginalUntouched() throws Exception { + JsonObject fixture = fixture("os3-import"); + String original = fixture.toString(); + GeologyEditorSession session = session(fixture); + String rules = session.root().get("ores").toString(); + + session.setOreSourcePlacement(GROUP, "orespawn:standard", "example:alternative_iron"); + assertEquals("example:alternative_iron", + session(session.profile().toJson()).oreSourceGroups().get(0).placements.get("orespawn:standard")); + assertEquals(rules, session.root().get("ores").toString()); + assertEquals(original, fixture.toString(), "Discarding a session must leave its input intact"); + } + + @Test + void loadedInactiveSourcesDoNotLookMissing() throws Exception { + JsonObject fixture = fixture("os1-import"); + for (com.google.gson.JsonElement entry : policy(fixture).getAsJsonArray("candidates")) { + entry.getAsJsonObject().addProperty("placement_active", false); + } + + OreSourceGroup group = session(fixture).oreSourceGroups().get(0); + assertEquals("consolidated", group.status); + assertNotNull(OreSourceGroupSettingsScreen.selectedPlacementCandidate(group, "orespawn:standard")); + assertFalse(OreSourceGroupSettingsScreen.placementSelectable(group, "orespawn:standard")); + } + + @Test + void manageVanillaSwitchKeepsImportedChoicesAndCustomRules() throws Exception { + for (String name : Arrays.asList("os1-import", "os3-import", "custom-channel", "fresh-standard")) { + GeologyEditorSession session = session(fixture(name)); + String rules = session.root().get("ores").toString(); + String choices = policy(session.root()).get("placement_sources").toString(); + + session.setManageVanillaOres(false); + assertEquals(choices, policy(session.root()).get("placement_sources").toString(), name); + session.setManageVanillaOres(true); + assertEquals(choices, policy(session.root()).get("placement_sources").toString(), name); + assertEquals(rules, session.root().get("ores").toString(), name); + } + } + + @Test + void unrelatedAliasesAndMultipleSavedPatternChoicesAreNotSilentlyMerged() throws Exception { + JsonObject fixture = fixture("os3-import"); + JsonObject placements = policy(fixture).getAsJsonObject("placement_sources"); + placements.addProperty("orespawn:vein", "example:alternative_iron"); + fixture.getAsJsonObject("ores").getAsJsonObject("example:alternative_iron") + .getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") + .addProperty("pattern", "orespawn:vein"); + String before = fixture.toString(); + OreSourceGroup group = session(fixture).oreSourceGroups().get(0); + + assertEquals(placements.toString(), new com.google.gson.Gson().toJsonTree(group.placements).toString()); + assertNotNull(OreSourceGroupSettingsScreen.selectedPlacementCandidate(group, "orespawn:default")); + assertNotNull(OreSourceGroupSettingsScreen.selectedPlacementCandidate(group, "orespawn:vein")); + assertEquals(Arrays.asList("orespawn:default", "orespawn:vein"), + OreSourceGroupSettingsScreen.placementChannels(group)); + assertEquals(before, fixture.toString()); + } + + static JsonObject fixture(String name) throws Exception { + try (InputStreamReader reader = new InputStreamReader( + LegacyOreSourceEditorTest.class.getResourceAsStream("/ore-source-editor/" + name + ".json"), + StandardCharsets.UTF_8)) { + return new JsonParser().parse(reader).getAsJsonObject(); + } + } + + private static GeologyEditorSession session(JsonObject root) { + return new GeologyEditorSession(WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false))); + } + + private static JsonObject policy(JsonObject root) { + return root.getAsJsonObject("ore_source_policies").getAsJsonObject(GROUP); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyPlacementChannelTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyPlacementChannelTest.java new file mode 100644 index 00000000..8cb934e0 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyPlacementChannelTest.java @@ -0,0 +1,160 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.gson.JsonObject; +import net.minecraft.util.ResourceLocation; +import org.junit.jupiter.api.Test; + +class LegacyPlacementChannelTest { + + @Test + void namespacedBuiltInPatternsUseTheOrdinaryChannel() { + for (String name : new String[] { + "default", "vein", "normal_cloud", "precision", "clusters", "underfluids" }) { + JsonObject rule = rule("orespawn:" + name); + String before = rule.toString(); + + assertEquals(OreSourcePolicies.STANDARD, OreSourcePolicies.placementChannel(rule), name); + assertEquals(before, rule.toString(), "Channel inference must not rewrite imported settings"); + } + } + + @Test + void builtInPatternObjectsUseTheSameOrdinaryChannel() { + for (String name : new String[] { + "default", "vein", "normal_cloud", "precision", "clusters", "underfluids" }) { + JsonObject rule = objectRule("orespawn:" + name); + String before = rule.toString(); + + assertEquals(OreSourcePolicies.STANDARD, OreSourcePolicies.placementChannel(rule), name); + assertEquals(before, rule.toString()); + } + } + + @Test + void thirdPartyAndUnrecognisedOreSpawnPatternsKeepTheirOwnChannels() { + for (String pattern : new String[] { + "realisticdeposits:stratiform_seam", "example:default", "example:vein", + "orespawn:registered_custom" }) { + ResourceLocation expected = new ResourceLocation(pattern); + + assertEquals(expected, OreSourcePolicies.placementChannel(rule(pattern))); + assertEquals(expected, OreSourcePolicies.placementChannel(objectRule(pattern))); + } + } + + @Test + void explicitChannelsAlwaysWinIncludingTheOldDefaultName() { + for (String channel : new String[] { + "example:custom", "orespawn:default", "orespawn:standard" }) { + for (JsonObject rule : new JsonObject[] { + rule("orespawn:default"), objectRule("orespawn:vein"), + rule("realisticdeposits:stratiform_seam") }) { + rule.addProperty("placement_channel", channel); + String before = rule.toString(); + + assertEquals(new ResourceLocation(channel), OreSourcePolicies.placementChannel(rule)); + assertEquals(before, rule.toString()); + } + } + } + + @Test + void bareLegacyNamesAndMissingPatternsKeepTheirExistingFallback() { + assertEquals(OreSourcePolicies.STANDARD, OreSourcePolicies.placementChannel(new JsonObject())); + + for (String name : new String[] { "default", "vein", "cloud", "cluster", "under-fluid" }) { + assertEquals(OreSourcePolicies.STANDARD, OreSourcePolicies.placementChannel(rule(name))); + } + } + + @Test + void oldSourceSelectionsOnlyMatchTheirOriginalPattern() { + JsonObject profile = selectionProfile("orespawn:vein"); + String before = profile.toString(); + + assertTrue(selected(profile).isEmpty(), "An unrelated old channel must not activate a source"); + assertEquals(before, profile.toString()); + } + + @Test + void aNewExplicitChannelDoesNotResurrectAnOldSelection() { + JsonObject profile = selectionProfile("orespawn:default"); + profile.getAsJsonObject("ores").getAsJsonObject("orespawn:legacy/iron") + .getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") + .addProperty("placement_channel", "orespawn:standard"); + + assertTrue(selected(profile).isEmpty()); + } + + @Test + void aNewStandardSelectionTakesPrecedenceWithoutFallingBack() { + JsonObject profile = selectionProfile("orespawn:default"); + profile.getAsJsonObject(OreSourcePolicies.SECTION).getAsJsonObject("orespawn:iron|minecraft:overworld") + .getAsJsonObject("placement_sources").addProperty("orespawn:standard", "missing:iron"); + + assertTrue(selected(profile).isEmpty()); + } + + @Test + void aSavedInferredChannelStillSelectsTheSameSourceWithoutRewritingIt() { + JsonObject profile = selectionProfile("orespawn:default"); + String before = profile.toString(); + Map choices = selected(profile); + + assertEquals(1, choices.size()); + assertEquals(new ResourceLocation("orespawn:legacy/iron"), + choices.get(new ResourceLocation("orespawn:default"))); + assertEquals(before, profile.toString()); + } + + private static JsonObject selectionProfile(String savedChannel) { + JsonObject dimension = rule("orespawn:default"); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", dimension); + JsonObject ore = new JsonObject(); + ore.add("dimensions", dimensions); + JsonObject ores = new JsonObject(); + ores.add("orespawn:legacy/iron", ore); + + JsonObject placements = new JsonObject(); + placements.addProperty(savedChannel, "orespawn:legacy/iron"); + JsonObject policy = new JsonObject(); + policy.addProperty("mode", "consolidated"); + policy.add("placement_sources", placements); + JsonObject policies = new JsonObject(); + policies.add("orespawn:iron|minecraft:overworld", policy); + + JsonObject profile = new JsonObject(); + profile.add("ores", ores); + profile.add(OreSourcePolicies.SECTION, policies); + return profile; + } + + private static Map selected(JsonObject profile) { + Map available = new LinkedHashMap<>(); + available.put(new ResourceLocation("orespawn:legacy/iron"), OreSourcePolicies.STANDARD); + return OreSpawnOreGeneration.selectedPlacementRules(profile, + new ResourceLocation("orespawn:iron"), WorldIds.OVERWORLD, available); + } + + private static JsonObject rule(String pattern) { + JsonObject rule = new JsonObject(); + rule.addProperty("pattern", pattern); + return rule; + } + + private static JsonObject objectRule(String type) { + JsonObject pattern = new JsonObject(); + pattern.addProperty("type", type); + pattern.add("settings", new JsonObject()); + JsonObject rule = new JsonObject(); + rule.add("pattern", pattern); + return rule; + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyVanillaOreTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyVanillaOreTest.java index 7edecd00..5ebedd99 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyVanillaOreTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/LegacyVanillaOreTest.java @@ -375,6 +375,27 @@ void intentionalLegacyDeclarationsAreNotCollapsedIntoOneRule() throws Exception assertTrue(takeover(baked, WorldIds.OVERWORLD, Blocks.DIAMOND_ORE)); } + @Test + void importedDefaultPatternReceivesHybridAndReplacementScalingWithoutChangingItsSettings() throws Exception { + JsonObject profile = profile(true); + profile.getAsJsonObject("ores").add("orespawn:legacy/diamond_ore", rule(false, true)); + String before = profile.toString(); + Object[] imported = ores(bake(profile), WorldIds.OVERWORLD); + + assertEquals(1, imported.length); + ResourceLocation channel = (ResourceLocation) field(imported[0], "channel"); + ResourceLocation material = new ResourceLocation("orespawn:diamond"); + + assertEquals(OreSourcePolicies.STANDARD, channel); + assertEquals(0.15D, OreSpawnOreGeneration.channelBackgroundScale(material, channel, 0.15D, 1.0D)); + assertEquals(0.0D, OreSpawnOreGeneration.channelBackgroundScale(material, channel, 0.0D, 1.0D)); + assertEquals(0.375D, field(imported[0], "frequency")); + assertEquals(7, field(imported[0], "minY")); + assertEquals(11, field(imported[0], "maxY")); + assertEquals(false, field(imported[0], "retrogen")); + assertEquals(before, profile.toString()); + } + private static JsonObject profile(boolean manageVanilla) { JsonObject profile = new JsonObject(); profile.addProperty("manage_vanilla_ores", manageVanilla); diff --git a/src/test/resources/ore-source-editor/custom-channel.json b/src/test/resources/ore-source-editor/custom-channel.json new file mode 100644 index 00000000..ce7ebe65 --- /dev/null +++ b/src/test/resources/ore-source-editor/custom-channel.json @@ -0,0 +1,128 @@ +{ + "schema_version": 7, + "manage_vanilla_ores": true, + "ore_material_groups": { + "orespawn:iron": { + "display_name": "Iron", + "ore_dictionary_entries": [ + "oreIron" + ] + } + }, + "ores": { + "example:iron": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 3.25, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "placement_channel": "example:custom_iron", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:alternative_iron": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 1.75, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "placement_channel": "example:custom_iron", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "orespawn", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:disabled_gold": { + "enabled": false, + "dimensions": { + "minecraft:overworld": { + "enabled": false, + "frequency": 0.5, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "placement_channel": "example:custom_iron", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:gold_ore", + "dimension_selectors": {} + } + }, + "ore_source_policies": { + "orespawn:iron|minecraft:overworld": { + "material": "orespawn:iron", + "domain": "minecraft:overworld", + "mode": "consolidated", + "output_mode": "balanced", + "review_required": false, + "outputs": { + "example:iron": 1 + }, + "placement_sources": { + "example:custom_iron": "example:iron" + }, + "candidates": [ + { + "source_id": "example:iron", + "owner": "minecraft", + "owner_name": "Minecraft", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "example:custom_iron", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + }, + { + "source_id": "example:alternative_iron", + "owner": "orespawn", + "owner_name": "OreSpawn", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "example:custom_iron", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + } + ] + } + } +} diff --git a/src/test/resources/ore-source-editor/fresh-standard.json b/src/test/resources/ore-source-editor/fresh-standard.json new file mode 100644 index 00000000..0074c890 --- /dev/null +++ b/src/test/resources/ore-source-editor/fresh-standard.json @@ -0,0 +1,125 @@ +{ + "schema_version": 7, + "manage_vanilla_ores": true, + "ore_material_groups": { + "orespawn:iron": { + "display_name": "Iron", + "ore_dictionary_entries": [ + "oreIron" + ] + } + }, + "ores": { + "example:iron": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 3.25, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:alternative_iron": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 1.75, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "orespawn", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:disabled_gold": { + "enabled": false, + "dimensions": { + "minecraft:overworld": { + "enabled": false, + "frequency": 0.5, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:gold_ore", + "dimension_selectors": {} + } + }, + "ore_source_policies": { + "orespawn:iron|minecraft:overworld": { + "material": "orespawn:iron", + "domain": "minecraft:overworld", + "mode": "consolidated", + "output_mode": "balanced", + "review_required": false, + "outputs": { + "example:iron": 1 + }, + "placement_sources": { + "orespawn:standard": "example:iron" + }, + "candidates": [ + { + "source_id": "example:iron", + "owner": "minecraft", + "owner_name": "Minecraft", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:standard", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + }, + { + "source_id": "example:alternative_iron", + "owner": "orespawn", + "owner_name": "OreSpawn", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:standard", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + } + ] + } + } +} diff --git a/src/test/resources/ore-source-editor/os1-import.json b/src/test/resources/ore-source-editor/os1-import.json new file mode 100644 index 00000000..6a4c693d --- /dev/null +++ b/src/test/resources/ore-source-editor/os1-import.json @@ -0,0 +1,142 @@ +{ + "schema_version": 7, + "manage_vanilla_ores": true, + "ore_material_groups": { + "orespawn:iron": { + "display_name": "Iron", + "ore_dictionary_entries": [ + "oreIron" + ] + } + }, + "ores": { + "orespawn:legacy/minecraft_iron_ore_0": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 3.25, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:alternative_iron": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 1.75, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "orespawn", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:disabled_gold": { + "enabled": false, + "dimensions": { + "minecraft:overworld": { + "enabled": false, + "frequency": 0.5, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:gold_ore", + "dimension_selectors": {} + } + }, + "ore_source_policies": { + "orespawn:iron|minecraft:overworld": { + "material": "orespawn:iron", + "domain": "minecraft:overworld", + "mode": "consolidated", + "output_mode": "balanced", + "review_required": false, + "outputs": { + "orespawn:legacy/minecraft_iron_ore_0": 1 + }, + "placement_sources": { + "orespawn:default": "orespawn:legacy/minecraft_iron_ore_0" + }, + "candidates": [ + { + "source_id": "orespawn:legacy/minecraft_iron_ore_0", + "owner": "minecraft", + "owner_name": "Minecraft", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:default", + "ore_dictionary": [ + "oreIron" + ], + "loaded": false, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + }, + { + "source_id": "orespawn:legacy/minecraft_iron_ore_0", + "owner": "minecraft", + "owner_name": "Minecraft", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:standard", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + }, + { + "source_id": "example:alternative_iron", + "owner": "orespawn", + "owner_name": "OreSpawn", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:standard", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + } + ] + } + } +} diff --git a/src/test/resources/ore-source-editor/os3-import.json b/src/test/resources/ore-source-editor/os3-import.json new file mode 100644 index 00000000..8ae24d53 --- /dev/null +++ b/src/test/resources/ore-source-editor/os3-import.json @@ -0,0 +1,142 @@ +{ + "schema_version": 7, + "manage_vanilla_ores": true, + "ore_material_groups": { + "orespawn:iron": { + "display_name": "Iron", + "ore_dictionary_entries": [ + "oreIron" + ] + } + }, + "ores": { + "orespawn:legacy/iron_ore": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 3.25, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:alternative_iron": { + "enabled": true, + "dimensions": { + "minecraft:overworld": { + "enabled": true, + "frequency": 1.75, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "orespawn", + "block": "minecraft:iron_ore", + "dimension_selectors": {} + }, + "example:disabled_gold": { + "enabled": false, + "dimensions": { + "minecraft:overworld": { + "enabled": false, + "frequency": 0.5, + "quantity": 5, + "retrogen": false, + "host_blocks": [ + "minecraft:stone" + ], + "pattern": "orespawn:default", + "min_y": 12, + "max_y": 18 + } + }, + "source_mod": "minecraft", + "block": "minecraft:gold_ore", + "dimension_selectors": {} + } + }, + "ore_source_policies": { + "orespawn:iron|minecraft:overworld": { + "material": "orespawn:iron", + "domain": "minecraft:overworld", + "mode": "consolidated", + "output_mode": "balanced", + "review_required": false, + "outputs": { + "orespawn:legacy/iron_ore": 1 + }, + "placement_sources": { + "orespawn:default": "orespawn:legacy/iron_ore" + }, + "candidates": [ + { + "source_id": "orespawn:legacy/iron_ore", + "owner": "minecraft", + "owner_name": "Minecraft", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:default", + "ore_dictionary": [ + "oreIron" + ], + "loaded": false, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + }, + { + "source_id": "orespawn:legacy/iron_ore", + "owner": "minecraft", + "owner_name": "Minecraft", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:standard", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + }, + { + "source_id": "example:alternative_iron", + "owner": "orespawn", + "owner_name": "OreSpawn", + "owner_version": "4.1.2.112021", + "registry_id": "minecraft:iron_ore", + "metadata": 0, + "placement_channel": "orespawn:standard", + "ore_dictionary": [ + "oreIron" + ], + "loaded": true, + "placement_active": true, + "external": false, + "enrichment": false, + "review_required": false + } + ] + } + } +} diff --git a/tools/qualification/README.md b/tools/qualification/README.md new file mode 100644 index 00000000..b9cecc8c --- /dev/null +++ b/tools/qualification/README.md @@ -0,0 +1,53 @@ +# Optional Ore Sources checks + +These fixtures are only for testing. Ordinary builds, Eclipse production +launches and published jars do not include them. Use fresh disposable game +directories, not a player's save or the normal development launch folder. + +## Imported profiles in the editor + +Build the separate editor fixture with: + +```text +gradlew prepareOreSourceEditorFixture --init-script tools/qualification/editor/probe.init.gradle +``` + +Install the resulting `build/editor-fixtures/editor-fixtures.jar` beside the +matching OreSpawn jar in a disposable Forge 1.12.2 client. Its menu provides +four profiles: carried OS1-style settings, an OS3 import, an explicit custom +channel, and a fresh standard channel. This checks saved profile compatibility; +it does not add an OS1 loader to Minecraft 1.12. + +Only the disposable profile files are saved. Normal configuration and worlds +are not opened. For an automated pass, add +`-Deditorfixtures.automated=true` to the Java arguments. The fixture checks the +real screen, source selection, Cancel, Done, management switches and file +reload, saves screenshots after rendering, and exits with `editor-results/PASS.txt`. +Repeat at scaled GUI sizes 426 by 265 and 320 by 240. + +## Dedicated-server generation + +Build the runtime probe with: + +```text +gradlew prepareOreSourceQualification --init-script tools/qualification/ore-sources/probe.init.gradle +``` + +Run `python tools/qualification/ore-sources/qualify.py` with absolute paths for: + +- `--java`: a Java 8 executable; +- `--runtime`: an official Forge 1.12.2-14.23.5.2859 server installation with + its libraries, Minecraft server jar and previously accepted EULA; +- `--candidate`: the new reobfuscated OreSpawn jar; +- `--baseline`: the published 4.1.2.112021 jar; +- `--probe`: `build/ore-source-qualification/ore-source-probe.jar`; +- `--output`: a new disposable directory that does not already exist. + +The script uses only Python's standard library. It runs hidden servers on +Windows, always ends server commands with literal `nogui`, and retains failures. +It checks ten profiles through fresh generation, exact reload, later generation +and a second reload. A small independent pattern supplies a fixed deposit while +ordinary iron uses the imported built-in pattern. Hybrid must reduce ordinary +iron; replacement must remove it; custom channels and the independent deposit +must remain active. It also retains a failing published control and proves an +additive published world survives the update without rewriting old terrain. diff --git a/tools/qualification/editor/EditorFixtureMod.java b/tools/qualification/editor/EditorFixtureMod.java new file mode 100644 index 00000000..015e04a7 --- /dev/null +++ b/tools/qualification/editor/EditorFixtureMod.java @@ -0,0 +1,319 @@ +package zone.moddev.mc.orespawn.client; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.resources.I18n; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.util.ScreenShotHelper; +import net.minecraft.util.text.TextFormatting; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLInitializationEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +/** Disposable editor fixtures; this mod is never part of an OreSpawn release. */ +@Mod(modid = "editorfixtures", name = "Ore Sources visual test", version = "1", + acceptedMinecraftVersions = "[1.12.2]", dependencies = "required-after:orespawn") +public final class EditorFixtureMod { + private static final List FIXTURES = Arrays.asList( + "os1-import", "os3-import", "custom-channel", "fresh-standard"); + private static final String GROUP = "orespawn:iron|minecraft:overworld"; + private final String requestedFixture = System.getProperty("editorfixtures.fixture", ""); + private final FixtureMenu menu = new FixtureMenu(); + private OreSpawnWorldSettingsScreen root; + private GeologyEditorSession session; + private WorldGeologyProfile starting; + private String selected; + private int fixtureIndex; + private int stage; + private int ticks; + private boolean opened; + private boolean editing; + private boolean finished; + private String pendingScreenshot; + private int captureFrames; + private String result = "Choose a profile. All changes stay in this disposable test folder."; + + @Mod.EventHandler + public void initialize(FMLInitializationEvent event) { + MinecraftForge.EVENT_BUS.register(this); + } + + @SubscribeEvent + public void capture(GuiScreenEvent.DrawScreenEvent.Post event) { + if (pendingScreenshot == null || !(event.getGui() instanceof OreSourceGroupSettingsScreen)) return; + if (++captureFrames < 5) return; + Minecraft mc = Minecraft.getMinecraft(); + + // Capture after the screen has drawn, not between client ticks. + int texture = org.lwjgl.opengl.GL11.glGetInteger(org.lwjgl.opengl.GL11.GL_TEXTURE_BINDING_2D); + try { + ScreenShotHelper.saveScreenshot(mc.gameDir, pendingScreenshot, + mc.displayWidth, mc.displayHeight, mc.getFramebuffer()); + } finally { + GlStateManager.bindTexture(texture); + } + pendingScreenshot = null; + captureFrames = 0; + } + + @SubscribeEvent + public void tick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END || finished) return; + Minecraft mc = Minecraft.getMinecraft(); + + try { + if (!opened && mc.currentScreen instanceof GuiMainMenu) { + opened = true; + mc.displayGuiScreen(menu); + } + if (!opened || !Boolean.getBoolean("editorfixtures.automated")) return; + require(ticks < 3600, "The editor test timed out"); + if (++ticks % 12 != 0) return; + + switch (stage) { + case 0: + if (!requestedFixture.isEmpty()) { + fixtureIndex = FIXTURES.indexOf(requestedFixture); + require(fixtureIndex >= 0, "Unknown editor fixture " + requestedFixture); + } + open(FIXTURES.get(fixtureIndex)); + showPlacementScreen(); + pendingScreenshot = selected + "-before.png"; + stage = 1; + break; + case 1: + if (pendingScreenshot != null) return; + inspectAndEdit(mc); + mc.displayGuiScreen(root); + press(root, "gui.cancel"); + require(starting.toJson().equals(WorldGeologyProfileManager.pendingNewWorldProfile().toJson()), + "Cancel changed the accepted profile"); + stage = 2; + break; + case 2: + open(FIXTURES.get(fixtureIndex)); + showPlacementScreen(); + pendingScreenshot = selected + "-save.png"; + stage = 3; + break; + case 3: + if (pendingScreenshot != null) return; + inspectAndEdit(mc); + mc.displayGuiScreen(root); + press(root, "gui.done"); + require(mc.currentScreen == menu, "Done did not return to the fixture menu"); + WorldGeologyProfile accepted = WorldGeologyProfileManager.pendingNewWorldProfile(); + GeologyEditorSession reloaded = new GeologyEditorSession(accepted); + String channel = selected.equals("custom-channel") ? "example:custom_iron" : "orespawn:standard"; + require("example:alternative_iron".equals(ironGroup(reloaded).placements.get(channel)), + "Done did not retain the chosen source"); + require(starting.toJson().get("ores").equals(accepted.toJson().get("ores")), + "Editing a placement source changed customised ore rules"); + write(selected + "-accepted.json", accepted.toJson()); + JsonObject disk = new JsonParser().parse(new String(Files.readAllBytes( + output().resolve(selected + "-accepted.json")), StandardCharsets.UTF_8)).getAsJsonObject(); + require(accepted.toJson().equals(WorldGeologyProfile.fromJson(disk, accepted).toJson()), + "The accepted profile changed on reload"); + checkManagementSwitch(accepted, channel); + fixtureIndex++; + if (!requestedFixture.isEmpty() || fixtureIndex == FIXTURES.size()) { + Files.write(output().resolve("PASS.txt"), + "Editor checks passed: screen, selection, Cancel, Done and file reload.\n" + .getBytes(StandardCharsets.UTF_8)); + finished = true; + mc.shutdown(); + } else { + stage = 0; + } + break; + default: + throw new IllegalStateException("Unexpected fixture stage " + stage); + } + } catch (Throwable failure) { + failure.printStackTrace(); + try { + Files.write(output().resolve("FAIL.txt"), failure.toString().getBytes(StandardCharsets.UTF_8)); + } catch (IOException writing) { + failure.addSuppressed(writing); + } + finished = true; + mc.shutdown(); + } + } + + private void open(String name) throws Exception { + selected = name; + Path saved = output().resolve(name + "-manual.json"); + JsonObject json; + if (!Boolean.getBoolean("editorfixtures.automated") && Files.exists(saved)) { + json = new JsonParser().parse(new String(Files.readAllBytes(saved), StandardCharsets.UTF_8)).getAsJsonObject(); + } else { + try (InputStreamReader reader = new InputStreamReader(getClass().getResourceAsStream( + "/ore-source-editor/" + name + ".json"), StandardCharsets.UTF_8)) { + json = new JsonParser().parse(reader).getAsJsonObject(); + } + } + + starting = WorldGeologyProfile.fromJson(json, WorldGeologyProfile.recommended(false)); + write(name + "-input.json", starting.toJson()); + WorldGeologyProfileManager.setPendingNewWorldProfile(starting); + root = new OreSpawnWorldSettingsScreen(menu, starting); + session = (GeologyEditorSession) field(root, "session"); + editing = true; + Minecraft.getMinecraft().displayGuiScreen(root); + } + + private void showPlacementScreen() { + OreSourceListScreen sources = new OreSourceListScreen(root, session); + Minecraft.getMinecraft().displayGuiScreen(sources); + Minecraft.getMinecraft().displayGuiScreen(new OreSourceGroupSettingsScreen(sources, session, GROUP)); + } + + private void inspectAndEdit(Minecraft mc) throws Exception { + require(mc.currentScreen instanceof OreSourceGroupSettingsScreen, "Placement screen did not open"); + OreSourceGroupSettingsScreen screen = (OreSourceGroupSettingsScreen) mc.currentScreen; + CompactScrollList rows = (CompactScrollList) field(screen, "placementList"); + require(rows.size() == 1, "An old inferred channel left a duplicate row"); + require(!rows.rowText(0).contains(I18n.format("label.orespawn.ore_source.missing")), + "A loaded placement source was labelled missing"); + require(rows.rowEnabled(0), "The two-source fixture should allow a selection"); + rows.onRowPressed(0); + } + + private static void press(OreSpawnScreen screen, String translation) { + for (GuiButton button : screen.buttons) { + if (button instanceof Button && I18n.format(translation).equals( + TextFormatting.getTextWithoutFormattingCodes(button.displayString))) { + ((Button) button).press(); + return; + } + } + throw new IllegalStateException("Could not find " + translation); + } + + private void checkManagementSwitch(WorldGeologyProfile profile, String channel) throws Exception { + Minecraft mc = Minecraft.getMinecraft(); + OreSpawnWorldSettingsScreen settings = new OreSpawnWorldSettingsScreen(menu, profile); + GeologyEditorSession working = (GeologyEditorSession) field(settings, "session"); + String choices = working.root().getAsJsonObject("ore_source_policies") + .getAsJsonObject(GROUP).get("placement_sources").toString(); + + for (int i = 0; i < 2; i++) { + mc.displayGuiScreen(settings); + ((Button) field(settings, "vanillaOresButton")).press(); + press(settings, "button.orespawn.materials"); + require(choices.equals(working.root().getAsJsonObject("ore_source_policies") + .getAsJsonObject(GROUP).get("placement_sources").toString()), + "Manage Vanilla Ores changed a saved choice"); + require("example:alternative_iron".equals(ironGroup(working).placements.get(channel)), + "Manage Vanilla Ores lost the displayed choice"); + } + mc.displayGuiScreen(menu); + } + + private static OreSourceGroup ironGroup(GeologyEditorSession session) { + for (OreSourceGroup group : session.oreSourceGroups()) { + if (GROUP.equals(group.key)) return group; + } + throw new IllegalStateException("The Iron group disappeared"); + } + + private static Object field(Object owner, String name) throws ReflectiveOperationException { + Field field = owner.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(owner); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } + + private static Path output() throws IOException { + Path directory = Minecraft.getMinecraft().gameDir.toPath().resolve("editor-results"); + Files.createDirectories(directory); + return directory; + } + + private static void write(String name, JsonObject json) throws IOException { + String text = new GsonBuilder().setPrettyPrinting().create().toJson(json) + "\n"; + Files.write(output().resolve(name), text.getBytes(StandardCharsets.UTF_8)); + } + + private final class FixtureMenu extends GuiScreen { + @Override + public void initGui() { + if (editing) { + editing = false; + WorldGeologyProfile accepted = WorldGeologyProfileManager.pendingNewWorldProfile(); + boolean changed = !starting.toJson().equals(accepted.toJson()); + result = changed ? "Saved test changes for " + selected + "." : "No changes saved for " + selected + "."; + if (changed) { + try { + write(selected + "-manual.json", accepted.toJson()); + } catch (IOException failure) { + throw new IllegalStateException("Could not save the disposable profile", failure); + } + } + } + + String[] labels = { "OS1 imported profile", "OS3 imported profile", "Explicit custom channel", "Fresh standard channel" }; + for (int i = 0; i < FIXTURES.size(); i++) { + buttonList.add(new GuiButton(i, width / 2 - 155, 60 + i * 26, 310, 20, labels[i])); + } + buttonList.add(new GuiButton(10, width / 2 - 155, height - 28, 310, 20, "Quit test")); + } + + @Override + protected void actionPerformed(GuiButton button) throws IOException { + if (button.id == 10) { + mc.shutdown(); + return; + } + + try { + open(FIXTURES.get(button.id)); + } catch (Exception failure) { + throw new IOException("Could not open the fixture", failure); + } + } + + @Override + public void drawScreen(int mouseX, int mouseY, float partialTicks) { + drawDefaultBackground(); + drawCenteredString(fontRenderer, "Ore Sources visual test", width / 2, 18, 0xFFFFFF); + List lines = fontRenderer.listFormattedStringToWidth(result, width - 40); + int y = 174; + for (String line : lines) { + drawCenteredString(fontRenderer, line, width / 2, y, 0xCCCCCC); + y += 11; + } + super.drawScreen(mouseX, mouseY, partialTicks); + } + + @Override + protected void keyTyped(char character, int keyCode) throws IOException { + if (keyCode == org.lwjgl.input.Keyboard.KEY_ESCAPE) mc.shutdown(); + else super.keyTyped(character, keyCode); + } + } +} diff --git a/tools/qualification/editor/probe.init.gradle b/tools/qualification/editor/probe.init.gradle new file mode 100644 index 00000000..99b93a71 --- /dev/null +++ b/tools/qualification/editor/probe.init.gradle @@ -0,0 +1,31 @@ +// Opt-in editor fixtures. Normal builds and release jars do not include them. +gradle.afterProject { project, state -> + if (project != project.rootProject || state.failure != null) return + + def classes = project.tasks.register('compileOreSourceEditorFixture', JavaCompile) { + dependsOn project.tasks.named('classes') + source project.file('tools/qualification/editor/EditorFixtureMod.java') + classpath = project.files(project.sourceSets.main.output, project.sourceSets.main.compileClasspath) + destinationDirectory = project.layout.buildDirectory.dir('editor-fixtures/classes') + options.encoding = 'UTF-8' + } + + def input = project.tasks.register('oreSourceEditorFixtureInput', Jar) { + dependsOn classes + from classes.flatMap { it.destinationDirectory } + from(project.file('src/test/resources')) { include 'ore-source-editor/**' } + archiveFileName = 'editor-fixtures-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('editor-fixtures') + } + + def release = project.renamer.classes(input) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('editor-fixtures/editor-fixtures.jar') + } + + project.tasks.register('prepareOreSourceEditorFixture') { + dependsOn release + group = 'verification' + description = 'Builds the optional, packaged Ore Sources visual-test mod.' + } +} diff --git a/tools/qualification/ore-sources/OreSourceQualificationMod.java b/tools/qualification/ore-sources/OreSourceQualificationMod.java new file mode 100644 index 00000000..4ab512a5 --- /dev/null +++ b/tools/qualification/ore-sources/OreSourceQualificationMod.java @@ -0,0 +1,161 @@ +package zone.moddev.mc.orespawn.qualification; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.MessageDigest; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import net.minecraft.block.Block; +import net.minecraft.init.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.WorldServer; +import net.minecraft.world.chunk.Chunk; +import net.minecraftforge.common.DimensionManager; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.common.event.FMLServerStartedEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import zone.moddev.mc.orespawn.api.OreGenerationContext; +import zone.moddev.mc.orespawn.api.OrePatternType; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.StandardPatternSettings; + +/** Exercises saved profiles through a real, disposable Forge 1.12 server. */ +@Mod(modid = "oresourcequalification", name = "Ore Sources qualification", version = "1", + acceptedMinecraftVersions = "[1.12.2]", dependencies = "required-after:orespawn") +public final class OreSourceQualificationMod { + public OreSourceQualificationMod() { + MinecraftForge.EVENT_BUS.register(this); + } + + @SubscribeEvent + public void registerPatterns(RegistryEvent.Register event) { + event.getRegistry().register(OrePatternType.create(StandardPatternSettings.CODEC, settings -> context -> { + OreGenerationContext generation = (OreGenerationContext) context; + boolean changed = false; + // This small deposit has a fixed shape, independent of background random calls. + for (int x = 4; x < 8; x++) { + for (int z = 4; z < 8; z++) { + changed |= generation.tryPlace((generation.chunkX() << 4) + x, 200, + (generation.chunkZ() << 4) + z, 1001L); + } + } + return changed; + }).setRegistryName(new ResourceLocation("oresourcequalification", "deposit"))); + } + + @Mod.EventHandler + public void started(FMLServerStartedEvent event) throws Exception { + MinecraftServer server = FMLCommonHandler.instance().getMinecraftServerInstance(); + String phase = System.getProperty("oresourceprobe.phase", "fresh"); + JsonObject report = new JsonObject(); + report.addProperty("phase", phase); + try { + JsonObject profile = OreSpawnApi.getActiveProfile(server).get().toJson(); + report.add("profile", profile); + if (!Boolean.getBoolean("oresourceprobe.bootstrap")) { + boolean managed = Boolean.parseBoolean(System.getProperty("oresourceprobe.manage", "true")); + require(profile.get("manage_vanilla_ores").getAsBoolean() == managed, "Management switch changed"); + WorldServer world = DimensionManager.getWorld(0); + int base = Integer.getInteger("oresourceprobe.base", 64); + int oldBase = Integer.getInteger("oresourceprobe.oldBase", base); + boolean reload = Boolean.getBoolean("oresourceprobe.reload"); + load(world, oldBase, false); + if (!reload) load(world, base, true); + report.add("terrain", scan(world, base)); + if (oldBase != base) report.add("old_terrain", scan(world, oldBase)); + JsonObject terrain = report.getAsJsonObject("terrain"); + require(terrain.get("deposit").getAsInt() == 256, "Independent deposit changed shape or disappeared"); + String expectation = System.getProperty("oresourceprobe.expect", "positive"); + if ("zero".equals(expectation)) { + require(terrain.get("ordinary").getAsInt() == 0, "Replacement left ordinary iron behind"); + } else { + require(terrain.get("ordinary").getAsInt() > 0, "Ordinary iron was unexpectedly suppressed"); + } + } + server.saveAllWorlds(false); + report.addProperty("passed", true); + System.out.println("ORE_SOURCE_QUALIFICATION_PASS " + phase); + } catch (Throwable failure) { + report.addProperty("passed", false); + report.addProperty("failure", failure.toString()); + failure.printStackTrace(); + } finally { + Files.write(Paths.get(phase + "-result.json"), (new GsonBuilder().setPrettyPrinting().create() + .toJson(report) + "\n").getBytes(StandardCharsets.UTF_8)); + server.initiateShutdown(); + } + } + + private static void load(WorldServer world, int base, boolean populate) { + int from = populate ? base - 1 : base; + int until = populate ? base + 6 : base + 4; + for (int x = from; x < until; x++) { + for (int z = from; z < until; z++) world.getChunkProvider().provideChunk(x, z); + } + } + + private static JsonObject scan(WorldServer world, int base) throws Exception { + JsonObject result = new JsonObject(); + MessageDigest blocks = MessageDigest.getInstance("SHA-256"); + MessageDigest ordinaryPositions = MessageDigest.getInstance("SHA-256"); + MessageDigest depositPositions = MessageDigest.getInstance("SHA-256"); + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + int ordinary = 0; + int deposit = 0; + for (int cx = base; cx < base + 4; cx++) { + for (int cz = base; cz < base + 4; cz++) { + Chunk chunk = world.getChunkProvider().provideChunk(cx, cz); + require(chunk.isTerrainPopulated(), "Audit chunk has not populated"); + blocks.update(chunk.getBiomeArray()); + for (int y = 0; y < 256; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int wx = (cx << 4) + x; + int wz = (cz << 4) + z; + position.setPos(wx, y, wz); + net.minecraft.block.state.IBlockState state = chunk.getBlockState(position); + number(blocks, Block.getStateId(state)); + if (state.getBlock() != Blocks.IRON_ORE) continue; + MessageDigest coordinates; + if (y >= 192) { deposit++; coordinates = depositPositions; } + else { ordinary++; coordinates = ordinaryPositions; } + number(coordinates, wx); + number(coordinates, y); + number(coordinates, wz); + } + } + } + } + } + result.addProperty("ordinary", ordinary); + result.addProperty("deposit", deposit); + result.addProperty("blocks_sha256", hex(blocks.digest())); + result.addProperty("ordinary_sha256", hex(ordinaryPositions.digest())); + result.addProperty("deposit_sha256", hex(depositPositions.digest())); + return result; + } + + private static void number(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private static String hex(byte[] bytes) { + StringBuilder text = new StringBuilder(); + for (byte value : bytes) text.append(String.format("%02x", value & 255)); + return text.toString(); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } +} diff --git a/tools/qualification/ore-sources/probe.init.gradle b/tools/qualification/ore-sources/probe.init.gradle new file mode 100644 index 00000000..035a4842 --- /dev/null +++ b/tools/qualification/ore-sources/probe.init.gradle @@ -0,0 +1,26 @@ +// Optional server fixtures stay outside the ordinary source sets and jars. +gradle.afterProject { project, state -> + if (project != project.rootProject || state.failure != null) return + def classes = project.tasks.register('compileOreSourceQualification', JavaCompile) { + dependsOn project.tasks.named('classes') + source project.file('tools/qualification/ore-sources/OreSourceQualificationMod.java') + classpath = project.files(project.sourceSets.main.output, project.sourceSets.main.compileClasspath) + destinationDirectory = project.layout.buildDirectory.dir('ore-source-qualification/classes') + options.encoding = 'UTF-8' + } + def input = project.tasks.register('oreSourceQualificationInput', Jar) { + dependsOn classes + from classes.flatMap { it.destinationDirectory } + archiveFileName = 'ore-source-probe-deobf.jar' + destinationDirectory = project.layout.buildDirectory.dir('ore-source-qualification') + } + def packaged = project.renamer.classes(input) { + map.from project.minecraft.dependency.toSrgFile + output = project.layout.buildDirectory.file('ore-source-qualification/ore-source-probe.jar') + } + project.tasks.register('prepareOreSourceQualification') { + dependsOn packaged + group = 'verification' + description = 'Builds the isolated Forge 1.12 Ore Sources server probe.' + } +} diff --git a/tools/qualification/ore-sources/qualify.py b/tools/qualification/ore-sources/qualify.py new file mode 100644 index 00000000..46289954 --- /dev/null +++ b/tools/qualification/ore-sources/qualify.py @@ -0,0 +1,183 @@ +"""Run imported-channel checks in fresh, disposable Forge 1.12 installations.""" +import argparse +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import shutil +import subprocess + +parser = argparse.ArgumentParser(description=__doc__) +for name in ('java', 'runtime', 'candidate', 'baseline', 'probe', 'output'): + parser.add_argument('--' + name, type=Path, required=True) +args = parser.parse_args() +assert not args.output.exists(), 'Keep earlier evidence; choose a new output directory' +args.output.mkdir(parents=True) +forge = 'forge-1.12.2-14.23.5.2859-universal.jar' +profile_path = Path('world/serverconfig/orespawn-worldgen.json') +ore_id = 'orespawn:legacy/iron_ore' +deposit_id = 'oresourcequalification:deposit' +group_id = 'orespawn:iron|minecraft:overworld' +flags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0 + + +def write(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8') + + +def prepare(name, jar, source=None): + directory = args.output / name + assert not directory.exists(), directory + directory.mkdir() + for item in (forge, 'minecraft_server.1.12.2.jar', 'libraries', 'eula.txt'): + old, new = args.runtime / item, directory / item + shutil.copytree(old, new) if old.is_dir() else shutil.copy2(old, new) + (directory / 'mods').mkdir() + shutil.copy2(jar, directory / 'mods' / jar.name) + shutil.copy2(args.probe, directory / 'mods' / args.probe.name) + if source: + for item in ('world', 'config'): + shutil.copytree(source / item, directory / item) + (directory / 'server.properties').write_text( + 'level-name=world\nlevel-seed=zsjpxah\nlevel-type=FLAT\n' + 'generator-settings=3;minecraft:bedrock,223*minecraft:stone;1;decoration\n' + 'online-mode=false\nserver-ip=127.0.0.1\nserver-port=0\n' + 'generate-structures=false\nspawn-animals=false\nspawn-monsters=false\n' + 'view-distance=2\nmax-tick-time=-1\n', encoding='utf-8') + for item in ('banned-players.json', 'banned-ips.json', 'ops.json', 'whitelist.json'): + (directory / item).write_text('[]\n', encoding='utf-8') + return directory + + +def run(directory, phase, base=64, old_base=64, reload=False, zero=False, managed=True, + bootstrap=False, expect_failure=False): + properties = {'phase': phase, 'base': base, 'oldBase': old_base, 'reload': str(reload).lower(), + 'manage': str(managed).lower(), 'expect': 'zero' if zero else 'positive', + 'bootstrap': str(bootstrap).lower()} + command = [str(args.java), '-Xms256m', '-Xmx2g'] + command += ['-Doresourceprobe.' + key + '=' + str(value) for key, value in properties.items()] + command += ['-jar', forge, 'nogui'] + write(directory / (phase + '-command.json'), command) + with (directory / (phase + '-console.log')).open('w', encoding='utf-8') as log: + result = subprocess.run(command, cwd=directory, stdout=log, stderr=subprocess.STDOUT, + creationflags=flags, timeout=240, check=False) + shutil.copytree(directory / 'logs', directory / (phase + '-logs')) + assert result.returncode == 0, (directory, phase, result.returncode) + report = json.loads((directory / (phase + '-result.json')).read_text(encoding='utf-8')) + assert not list(directory.glob('crash-reports/*')), directory + if expect_failure: + assert not report['passed'] and 'Replacement left ordinary iron behind' in report['failure'], report + else: + assert report['passed'], (directory, phase, report.get('failure'), report.get('terrain')) + print(('EXPECTED_RED' if expect_failure else 'PASS'), directory.name, phase, flush=True) + return report + + +bootstrap = prepare('bootstrap', args.candidate) +run(bootstrap, 'bootstrap', bootstrap=True) +factory = json.loads((bootstrap / profile_path).read_text(encoding='utf-8')) + + +def profile(scale, shape='string', custom=None, saved=False, managed=True): + root = deepcopy(factory) + root['manage_vanilla_ores'] = managed + root['place_fluid_deposits'] = False + root['ore_source_policies'] = {} + for rule in root.get('ores', {}).values(): + rule['enabled'] = False + placement = {'enabled': True, 'frequency': 8.0, 'quantity': 8, 'min_y': 12, 'max_y': 18, + 'host_blocks': ['minecraft:stone'], 'pattern': 'orespawn:default', 'retrogen': False} + if shape == 'object': + placement['pattern'] = {'type': 'orespawn:default', 'settings': {}} + if custom: + placement['placement_channel'] = custom + ordinary = {'block': 'minecraft:iron_ore', 'material': 'orespawn:iron', 'enabled': True, + 'source_mod': 'minecraft', 'retrogen': False, 'dimensions': {'minecraft:overworld': placement}, + 'dimension_selectors': {}} + if shape == 'selector': + ordinary['dimensions'] = {} + ordinary['dimension_selectors'] = {'orespawn:all_except_nether_end': placement} + root['ores'][ore_id] = ordinary + root['ores'][deposit_id] = {'block': 'minecraft:iron_ore', 'material': 'orespawn:iron', + 'enabled': True, 'source_mod': 'oresourcequalification', 'retrogen': False, + 'dimension_selectors': {}, + 'dimensions': {'minecraft:overworld': {'enabled': True, 'frequency': 1.0, 'quantity': 16, + 'min_y': 196, 'max_y': 210, + # Neighbouring vanilla decoration can turn these high stone cells into gravel or dirt. + 'host_blocks': ['minecraft:stone', 'minecraft:gravel', 'minecraft:dirt'], + 'pattern': {'type': deposit_id, 'settings': {}}, 'background_generation_scale': scale}}} + if saved: + root['ore_source_policies'][group_id] = {'material': 'orespawn:iron', 'domain': 'minecraft:overworld', + 'mode': 'consolidated', 'output_mode': 'balanced', 'review_required': False, + 'outputs': {ore_id: 1.0, deposit_id: 1.0}, + 'placement_sources': {'orespawn:default': ore_id, deposit_id: deposit_id}} + return root + + +cases = [('additive', 1.0, 'string', None, False, True), + ('hybrid', 0.25, 'string', None, False, True), + ('replacement', 0.0, 'string', None, False, True), + ('custom-channel', 0.0, 'string', 'example:custom', False, True), + ('explicit-old-channel', 0.0, 'string', 'orespawn:default', False, True), + ('object-replacement', 0.0, 'object', None, False, True), + ('selector-replacement', 0.0, 'selector', None, False, True), + ('saved-additive', 1.0, 'string', None, True, True), + ('saved-replacement', 0.0, 'string', None, True, True), + ('management-off', 1.0, 'string', None, False, False)] + + +def check_rules(expected, actual): + for key in (ore_id, deposit_id): + assert actual['ores'][key] == expected['ores'][key], (key, 'saved rule changed') + selected = expected['ore_source_policies'].get(group_id, {}).get('placement_sources') + if selected: + assert actual['ore_source_policies'][group_id]['placement_sources'] == selected + + +def check_case(case): + name, scale, shape, custom, saved, managed = case + root = profile(scale, shape, custom, saved, managed) + directory = prepare(name, args.candidate) + write(directory / profile_path, root) + base = -32 if shape == 'selector' else 64 + zero = scale == 0.0 and custom is None + first = run(directory, 'fresh', base, base, zero=zero, managed=managed) + check_rules(root, first['profile']) + reloaded = run(directory, 'reload', base, base, True, zero, managed) + assert first['terrain'] == reloaded['terrain'], name + later = run(directory, 'later', base + 16, base, zero=zero, managed=managed) + assert first['terrain'] == later['old_terrain'], name + stable = run(directory, 'stable', base + 16, base, True, zero, managed) + assert stable['terrain'] == later['terrain'] and stable['old_terrain'] == first['terrain'], name + check_rules(root, stable['profile']) + return name, {'first': first, 'later': later, 'passed': True} + + +with ThreadPoolExecutor(max_workers=2) as pool: + results = dict(pool.map(check_case, cases)) +assert 0 < results['hybrid']['first']['terrain']['ordinary'] < results['additive']['first']['terrain']['ordinary'] +for name in ('hybrid', 'replacement', 'custom-channel', 'explicit-old-channel', 'object-replacement', + 'saved-additive', 'saved-replacement', 'management-off'): + assert results[name]['first']['terrain']['deposit_sha256'] == results['additive']['first']['terrain']['deposit_sha256'] + +# Keep a failing published-release control and a copied-world upgrade beside the green runs. +red = prepare('published-replacement-red', args.baseline) +write(red / profile_path, profile(0.0)) +run(red, 'red', zero=True, expect_failure=True) +old = prepare('published-additive', args.baseline) +old_profile = profile(1.0) +write(old / profile_path, old_profile) +old_result = run(old, 'fresh') +assert old_result['terrain'] == results['additive']['first']['terrain'], 'Untouched additive generation changed' +upgrade = prepare('published-upgrade', args.candidate, old) +upgraded = run(upgrade, 'upgrade', 80, 64) +assert upgraded['old_terrain'] == old_result['terrain'] +assert upgraded['terrain'] == results['additive']['later']['terrain'] +check_rules(old_profile, upgraded['profile']) +reloaded = run(upgrade, 'reload', 80, 64, True) +assert reloaded['old_terrain'] == old_result['terrain'] and reloaded['terrain'] == upgraded['terrain'] +write(args.output / 'results.json', {'passed': True, 'cases': results, 'upgrade': upgraded, + 'candidate_sha256': hashlib.sha256(args.candidate.read_bytes()).hexdigest()}) +print('ORE_SOURCE_MATRIX_PASS cases=10 boots=45 upgrade=true', flush=True)