From 57ca01eaf152e930f78d7bbc11075b189c9f91df Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 2 Oct 2026 22:34:46 +0100 Subject: [PATCH] Bring OreSpawn 4.1 to Minecraft 1.14.4 --- .github/workflows/ci.yml | 6 +- CHANGELOG.txt | 18 + README.md | 20 +- build.gradle | 1223 +---------------- docs/AGENTS.md | 17 - docs/API.md | 18 +- docs/BIOMES.md | 16 +- docs/CONFIGURATION.md | 42 +- docs/DEVELOPER_GUIDE.md | 17 +- docs/PLAYER_GUIDE.md | 18 +- docs/PROVIDERS.md | 14 +- docs/README.md | 4 +- docs/VERSIONS.md | 9 +- docs/examples/examplemod-orespawn.json | 2 +- docs/examples/orespawn-global.json | 4 +- docs/examples/orespawn-world.json | 4 +- docs/schemas/orespawn-global.schema.json | 2 +- docs/schemas/orespawn-provider.schema.json | 2 +- docs/schemas/orespawn-world.schema.json | 2 +- docs/schemas/profile-common.schema.json | 56 +- gradle.properties | 2 +- gradle/README.md | 61 + gradle/ide/eclipse.gradle | 392 ++++++ gradle/release/artifacts.gradle | 390 ++++++ gradle/release/publishing.gradle | 96 ++ .../verification/client-and-benchmark.gradle | 152 ++ gradle/verification/native-ores.gradle | 110 ++ gradle/verification/ore-sources.gradle | 211 +++ gradle/verification/packaged-forge.gradle | 224 +++ gradle/verification/performance.gradle | 157 +++ gradle/verification/support.gradle | 126 ++ .../client/BiomeEditorProbeBridge.java | 32 + .../orespawn/testmod/SurfaceProbeTestMod.java | 56 +- .../orespawn/client/ClientProbeTestMod.java | 60 +- .../java/com/mojang/serialization/Codec.java | 5 +- .../zone/moddev/mc/orespawn/OreSpawn.java | 1 + .../moddev/mc/orespawn/OreSpawnConfig.java | 2 +- .../mc/orespawn/api/BiomeRegionSize.java | 2 +- .../mc/orespawn/api/CompiledOrePattern.java | 6 +- .../moddev/mc/orespawn/api/GeologyFamily.java | 2 +- .../mc/orespawn/api/GeologySampler.java | 3 +- .../mc/orespawn/api/OreGenerationContext.java | 43 + .../mc/orespawn/api/OrePatternType.java | 5 +- .../mc/orespawn/api/OrePlacementContext.java | 7 +- .../moddev/mc/orespawn/api/OreSpawnApi.java | 3 +- .../mc/orespawn/api/OreSpawnBiomes.java | 13 +- .../orespawn/api/OreSpawnOreIntegration.java | 5 +- .../mc/orespawn/api/ProviderStatus.java | 2 +- .../mc/orespawn/api/WorldgenProvider.java | 36 +- .../api/client/WorldSettingsExtension.java | 32 + .../WorldSettingsExtensionRegistry.java | 78 ++ .../client/WorldSettingsScreenFactory.java | 12 + .../orespawn/client/BiomeDirectoryModel.java | 341 +++++ .../orespawn/client/BiomePaletteScreen.java | 125 +- .../client/BiomePaletteSettingsScreen.java | 163 +++ .../orespawn/client/BiomePlacementScreen.java | 18 +- .../client/BiomeReplacementRules.java | 153 +++ .../client/BiomeWorldMaterialsScreen.java | 373 +++-- .../moddev/mc/orespawn/client/Button.java | 2 +- .../moddev/mc/orespawn/client/CogButton.java | 26 + .../mc/orespawn/client/CompactScrollList.java | 290 ++++ .../client/DimensionMaterialsScreen.java | 15 +- .../orespawn/client/GeologyEditorSession.java | 1177 +++++++++++++++- .../client/GeologyMaterialsScreen.java | 12 +- .../client/OreSourceGroupSettingsScreen.java | 379 +++++ .../orespawn/client/OreSourceListScreen.java | 497 +++++++ .../client/OreSpawnModDirectoryModel.java | 124 ++ .../orespawn/client/OreSpawnModsScreen.java | 189 +++ .../mc/orespawn/client/OreSpawnScreen.java | 20 +- .../client/OreSpawnWorldSettingsScreen.java | 29 +- .../mc/orespawn/client/TextFieldWidget.java | 9 +- .../WorldSettingsExtensionNavigation.java | 42 + .../documentation/DocumentationExporter.java | 1 - .../WorldgenIntegrationManager.java | 127 +- .../moddev/mc/orespawn/util/FluidBlocks.java | 19 + .../moddev/mc/orespawn/util/JsonCopies.java | 7 +- .../worldgen/BakedTerrainDimension.java | 2 +- .../worldgen/BiomeFeatureInstaller.java | 5 +- .../worldgen/BiomeWorldgenBootstrap.java | 2 +- .../worldgen/BiomeWorldgenManager.java | 19 +- .../mc/orespawn/worldgen/ContextFeature.java | 2 +- .../worldgen/FeaturePlaceContext.java | 2 +- .../worldgen/FluidDepositFeature.java | 32 +- .../mc/orespawn/worldgen/GeomeConfig.java | 37 +- .../mc/orespawn/worldgen/GeomeGeology.java | 12 +- .../worldgen/LegacyConfigMigrator.java | 2 +- .../LegacyMineralogyProfileMigration.java | 14 +- .../worldgen/MmdOreConflictCatalog.java | 64 + .../worldgen/OreHeightDistribution.java | 3 +- .../orespawn/worldgen/OreMaterialGroups.java | 320 +++++ .../mc/orespawn/worldgen/OrePattern.java | 4 +- .../orespawn/worldgen/OreRetrogenManager.java | 2 +- .../orespawn/worldgen/OreSourcePolicies.java | 808 +++++++++++ .../worldgen/OreSpawnOreGeneration.java | 349 ++++- .../worldgen/VanillaOreFeatureGate.java | 4 +- .../worldgen/VanillaSpringCompatibility.java | 2 +- .../worldgen/WorldGeologyProfile.java | 20 +- .../worldgen/WorldGeologyProfileManager.java | 135 +- .../moddev/mc/orespawn/worldgen/WorldIds.java | 2 +- .../worldgen/WorldMaterialWeather.java | 8 +- .../orespawn/worldgen/WorldgenBenchmark.java | 2 +- .../worldgen/math/CubicInterpolator.java | 157 +-- .../orespawn/worldgen/math/NoiseLayer2D.java | 11 +- .../orespawn/worldgen/math/PerlinNoise2D.java | 4 +- .../resources/assets/orespawn/lang/de_au.json | 159 ++- .../resources/assets/orespawn/lang/de_de.json | 159 ++- .../resources/assets/orespawn/lang/en_ca.json | 159 ++- .../resources/assets/orespawn/lang/en_en.json | 159 ++- .../resources/assets/orespawn/lang/en_gb.json | 159 ++- .../resources/assets/orespawn/lang/en_pt.json | 159 ++- .../resources/assets/orespawn/lang/en_us.json | 159 ++- .../resources/assets/orespawn/lang/es_es.json | 159 ++- .../resources/assets/orespawn/lang/es_mx.json | 159 ++- .../resources/assets/orespawn/lang/fr_ca.json | 159 ++- .../resources/assets/orespawn/lang/fr_fr.json | 159 ++- .../resources/assets/orespawn/lang/ja_jp.json | 159 ++- .../resources/assets/orespawn/lang/ko_kr.json | 159 ++- .../resources/assets/orespawn/lang/pt_br.json | 159 ++- .../resources/assets/orespawn/lang/ru_ru.json | 159 ++- .../resources/assets/orespawn/lang/zh_cn.json | 159 ++- .../assets/orespawn/textures/gui/cog.png | Bin 0 -> 321 bytes .../testmod/NativeOreProbeTestMod.java | 198 +++ .../worldgen/NativeOreProbeBridge.java | 14 + .../resources/META-INF/mods.toml | 23 + .../resources/pack.mcmeta | 8 + .../testmod/FutureAdvantageTestMod.java | 38 + .../resources/META-INF/mods.toml | 16 + .../data/forge/tags/blocks/ores/sulfur.json | 4 + .../resources/pack.mcmeta | 8 + .../testmod/FutureMineralsTestMod.java | 130 ++ .../resources/META-INF/mods.toml | 16 + .../data/forge/tags/blocks/ores/sulfur.json | 4 + .../resources/pack.mcmeta | 8 + .../mc/orespawn/LocalizationParityTest.java | 8 +- .../orespawn/PlatformClassShadowingTest.java | 2 +- .../orespawn/ReleaseWorkflowContractTest.java | 19 +- .../api/OreGenerationContextTest.java | 56 + .../mc/orespawn/api/WorldgenProviderTest.java | 9 +- .../WorldSettingsExtensionRegistryTest.java | 125 ++ .../client/BiomeDirectoryModelTest.java | 167 +++ .../client/BiomeReplacementRulesTest.java | 59 + .../client/CompactScrollListTest.java | 61 + .../client/GeologyEditorSessionTest.java | 99 ++ .../client/OreSpawnModDirectoryModelTest.java | 112 ++ .../client/OreSpawnScreenLayoutTest.java | 23 +- .../WorldgenIntegrationManagerTest.java | 4 + .../orespawn/test/Forge28TestBootstrap.java | 34 + .../worldgen/BiomePaletteOrderingTest.java | 153 +++ .../worldgen/GeomeTransitionTest.java | 3 +- .../worldgen/MmdOreConflictCatalogTest.java | 54 + .../orespawn/worldgen/NativeOreHostTest.java | 263 ++++ .../worldgen/OreMaterialGroupsTest.java | 65 + .../worldgen/OreSourcePoliciesTest.java | 103 ++ .../testmod/WorldHashProbeTestMod.java | 113 ++ .../resources/META-INF/mods.toml | 9 + src/worldHashTest/resources/pack.mcmeta | 1 + 156 files changed, 12778 insertions(+), 1757 deletions(-) delete mode 100644 docs/AGENTS.md create mode 100644 gradle/README.md create mode 100644 gradle/ide/eclipse.gradle create mode 100644 gradle/release/artifacts.gradle create mode 100644 gradle/release/publishing.gradle create mode 100644 gradle/verification/client-and-benchmark.gradle create mode 100644 gradle/verification/native-ores.gradle create mode 100644 gradle/verification/ore-sources.gradle create mode 100644 gradle/verification/packaged-forge.gradle create mode 100644 gradle/verification/performance.gradle create mode 100644 gradle/verification/support.gradle create mode 100644 src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/CogButton.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalog.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java create mode 100644 src/main/resources/assets/orespawn/textures/gui/cog.png create mode 100644 src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java create mode 100644 src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/worldgen/NativeOreProbeBridge.java create mode 100644 src/nativeOreIntegrationTest/resources/META-INF/mods.toml create mode 100644 src/nativeOreIntegrationTest/resources/pack.mcmeta create mode 100644 src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/testmod/FutureAdvantageTestMod.java create mode 100644 src/oreSourceAdvantageTest/resources/META-INF/mods.toml create mode 100644 src/oreSourceAdvantageTest/resources/data/forge/tags/blocks/ores/sulfur.json create mode 100644 src/oreSourceAdvantageTest/resources/pack.mcmeta create mode 100644 src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java create mode 100644 src/oreSourceMineralsTest/resources/META-INF/mods.toml create mode 100644 src/oreSourceMineralsTest/resources/data/forge/tags/blocks/ores/sulfur.json create mode 100644 src/oreSourceMineralsTest/resources/pack.mcmeta create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/test/Forge28TestBootstrap.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/NativeOreHostTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java create mode 100644 src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java create mode 100644 src/worldHashTest/resources/META-INF/mods.toml create mode 100644 src/worldHashTest/resources/pack.mcmeta diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad20ee60..72419d81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,9 +126,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.114041.jar - build/libs/OreSpawn-4.0.16.114041-sources.jar - build/libs/OreSpawn-4.0.16.114041-javadoc.jar + build/libs/OreSpawn-4.1.0.114041.jar + build/libs/OreSpawn-4.1.0.114041-sources.jar + build/libs/OreSpawn-4.1.0.114041-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 09f2106d..0221e5f1 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,21 @@ +Version 4.1.0.114041 + +* Reject invalid Geome IDs instead of silently renaming them. +* Keep expanded mod metadata intact when Eclipse rebuilds resources. +* Add an isolated shared-ore client profile and real Nether quartz checks. +* Retry tag-only ore and fluid hosts after Forge loads the world's tags, + before spawn generation. Keep working block/family rules unchanged so + managed Nether quartz follows its saved rule without moving existing veins. + +* Add a directory for loaded provider mods and optional add-on settings screens. +* Group OreSpawn-managed ores by exact block tags. Balanced, Single, Custom, + and Keep Original policies keep one placement budget per channel without + changing existing worlds' saved behaviour. +* Add a biome directory with exact new-terrain replacement, palette settings, + and dimension-wide materials controls. Existing chunks are never rewritten. +* Preserve missing-mod settings and dormant imported Ore Dictionary aliases. + Provider, global, and world schemas are now 5, 8, and 7; API major remains 1. + Version 4.0.16.114041 * Adopt the shared 4.0.16 release identity. Forge 1.14 has neither diff --git a/README.md b/README.md index 8232afee..afc582c1 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. -This branch builds target-qualified version `4.0.16.114041`: the OreSpawn 4.0.16 -feature set for Minecraft 1.14.4 and Forge. See the +This branch builds target-qualified version `4.1.0.114041` for Minecraft 1.14.4 +and Forge. It adds material-group ore arbitration, exact biome replacements, +and a directory for installed provider mods to the OreSpawn 4.0.16 base. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. ## What Happens When It Is Installed? @@ -54,6 +55,15 @@ Important files: Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. +**Ore Sources** groups ores by exact block tags such as `forge:ores/sulfur`. +When two loaded ordinary MMD providers share one tag, new worlds use a +Balanced output policy and one placement budget per channel. Older worlds keep +their saved Keep Original behaviour. Imported Ore Dictionary aliases remain +visible but dormant until you assign a known exact block tag. The **Biomes** +directory can replace one loaded biome with another in new terrain; its +**Overworld Materials** control applies across the dimension, not just the +selected biome. + When an already-generated world has saved Mineralogy 1.10, 1.12, or 5.x mod metadata but no OreSpawn world profile, OreSpawn reads the matching published configuration contract and records the exact engine, numeric settings, rock @@ -122,7 +132,11 @@ launches exclude tests and fixtures. Published jars are deterministic, SRG-reobfuscated for the Forge 28 runtime, audited for their access transformer and contents, and accompanied by SHA-256 checksums. -Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. +For a shared-ore GUI test, use the separate **runOreSourcesClient** Eclipse +profile. It loads two dummy providers and keeps its worlds in `run-ore-sources`. +See [the build guide](gradle/README.md#trying-shared-ores-in-the-editor) for the +test blocks and controls. These providers are never included in release jars. + Public developer and AI integration guidance lives in `docs/` and is included in the built jar. diff --git a/build.gradle b/build.gradle index cb00e8b5..4c657e55 100644 --- a/build.gradle +++ b/build.gradle @@ -99,6 +99,9 @@ minecraft { mods { orespawn { source sourceSets.main } } } register('client') + register('oreSourcesClient') { + workingDir.convention layout.projectDirectory.dir('run-ore-sources') + } register('server') { args '--nogui' } register('data') { workingDir.convention layout.projectDirectory.dir('run-data') @@ -128,9 +131,8 @@ def prepareBundledDocumentation = tasks.register('prepareBundledDocumentation', } sourceSets.main.resources { - // Eclipse rebuilds bin/main from declared resource source folders. Keeping - // the generated documentation in the source set prevents a Buildship - // refresh from silently removing the guide copied by processResources. + // Bundle guides with the production resources. + // Eclipse uses the processed copy to retain expanded mod metadata on rebuilds. srcDir 'src/generated/resources' } @@ -151,15 +153,15 @@ def configureForge28Run = { JavaExec runTask, String launchTarget -> runTask.environment 'FORGE_VERSION', forge_version runTask.environment 'FORGE_GROUP', 'net.minecraftforge' runTask.environment 'MC_VERSION', minecraft_version - // Forge 28's exploded-directory locator resolves one physical output per - // mod entry. Give it a merged, build-owned classes/resources directory, - // repeated for the target's legacy duplicate-entry discovery contract. + // Forge 28 needs classes and resources in one directory. + // Repeat it in MOD_CLASSES so both discovery passes find the mod. runTask.environment 'MOD_CLASSES', "${mod_id}%%${forgeRunModClassesDirectory}${File.pathSeparator}" + "${mod_id}%%${forgeRunModClassesDirectory}" } tasks.withType(JavaExec).configureEach { JavaExec runTask -> Map targets = [ runClient: 'fmluserdevclient', + runOreSourcesClient: 'fmluserdevclient', runServer: 'fmluserdevserver', runData: 'fmluserdevdata', runSurfaceIntegrationFresh: 'fmluserdevserver', @@ -275,10 +277,6 @@ tasks.named('processResources', ProcessResources) { minecraft_version_range: project.minecraft_version_range ]) } - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } @@ -286,6 +284,8 @@ def prepareEclipseResources = tasks.register('prepareEclipseResources') { group = 'ide' dependsOn tasks.named('processResources') doLast { + // An older Eclipse import may still have the retired guide in bin/main. + delete file('bin/main/META-INF/orespawn/docs/AGENTS.md') project.copy { from(layout.buildDirectory.dir('resources/main')) into(layout.projectDirectory.dir('bin/main')) @@ -293,1187 +293,26 @@ def prepareEclipseResources = tasks.register('prepareEclipseResources') { } } -// A Forge process is not green merely because it returns exit code zero. The -// loader can log a worldgen/linkage failure and still shut down normally. -def acceptedForge28LogNoise = [ - ~/FML appears to be missing any signature data/, - ~/Found multiple arguments for option fml\.mcVersion/, - ~/Found multiple arguments for option fml\.forgeVersion/, - ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, - ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, - ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / +// Applied scripts have separate local variables. Share only the helpers they need. +ext.oreSpawnBuild = [ + mavenGroup: expectedMavenGroup, + mavenArtifact: expectedMavenArtifact, + mavenCoordinate: expectedMavenCoordinate, + archiveTextSuffixes: archiveTextSuffixes, + archiveTextPatterns: archiveTextPatterns, + normalizeArchiveLineEndings: normalizeArchiveLineEndings, + java8Launcher: java8Launcher, + bundledDocumentationDirectory: bundledDocumentationDirectory, + prepareBundledDocumentation: prepareBundledDocumentation, + prepareEclipseResources: prepareEclipseResources ] -def runtimeCrashSnapshot = { File runDirectory -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (!crashDirectory.isDirectory()) return [] as Set - return fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() }.collect { it.absolutePath } as Set -} - -def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (crashDirectory.isDirectory()) { - def crashes = fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } - if (!crashes.isEmpty()) { - throw new GradleException("${context} produced crash report ${crashes.first()}") - } - } - - File logsDirectory = new File(runDirectory, 'logs') - if (!logsDirectory.isDirectory()) return - def failures = [] - [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] - .findAll { it.isFile() }.each { File log -> - int lineNumber = 0 - log.eachLine('UTF-8') { String line -> - lineNumber++ - boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ - boolean knownNoise = acceptedForge28LogNoise.any { line =~ it } - boolean fatalText = line.contains('Encountered an unexpected exception') || - line.contains('Exception stopping the server') || - line.contains('Migration audit failed') || - line.contains('java.lang.Error:') || - line.contains('NoSuchMethodError') || - line.contains('NoClassDefFoundError') || - line.contains('ExceptionInInitializerError') || - line.contains('Tried to assign a mutable BlockPos') || - line.contains('causing cascading worldgen lag') - if ((unexpectedSeverity && !knownNoise) || fatalText) { - failures.add("${log.name}:${lineNumber}: ${line}") - } - } - } - if (!failures.isEmpty()) { - throw new GradleException("${context} logged unexpected errors:\n" - + failures.take(20).join('\n')) - } -} - -task runtimeLogScannerTest { - group = 'verification' - description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' - doLast { - File probe = file("${buildDir}/runtime-log-scanner-test") - delete probe - File logs = new File(probe, 'logs'); logs.mkdirs() - new File(logs, 'latest.log').setText( - '[main/ERROR] [FML]: FML appears to be missing any signature data\n' - + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' - + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') - assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) - new File(logs, 'latest.log').setText( - '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') - boolean rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') - new File(logs, 'latest.log').setText( - '[Server thread/DEBUG] [FML]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') - new File(logs, 'latest.log').setText( - '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') - delete probe - } -} - -check.dependsOn runtimeLogScannerTest - -task verifyMineralogyOracleIsolation { - group = 'verification' - description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' - doLast { - configurations.each { configuration -> - if (configuration.canBeResolved && - configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { - throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") - } - } - } -} - -check.dependsOn verifyMineralogyOracleIsolation - -['runClient', 'runServer', 'runData'].each { String taskName -> - tasks.matching { it.name == taskName }.all { JavaExec runTask -> - doFirst { - new File(runTask.workingDir, 'mods').mkdirs() - runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) - } - doLast { - assertRuntimeLogsClean(runTask.workingDir, taskName, - runTask.ext.oreSpawnCrashSnapshot as Set) - } - } -} - -def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") -tasks.register('compileClientIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/clientIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = clientIntegrationClasses - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - options.encoding = 'UTF-8' -} -tasks.register('clientIntegrationTestModJar', Jar) { - dependsOn tasks.named('compileClientIntegrationTestMod') - archiveFileName = 'clientprobe.jar' - destinationDirectory = file("${buildDir}/client-integration-fixture") - from clientIntegrationClasses - from 'src/clientIntegrationTest/resources' -} -def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { - map.from minecraft.dependency.toSrgFile - output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') -} - -def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") -task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/biomeIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = surfaceIntegrationClasses - sourceCompatibility = '1.8' - targetCompatibility = '1.8' - options.encoding = 'UTF-8' -} - -task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = file("${buildDir}/surface-integration-fixture") - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' -} - -def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") -task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { - doLast { - delete surfaceIntegrationRunDirectory - surfaceIntegrationRunDirectory.mkdirs() - copy { - from surfaceIntegrationTestModJar.archiveFile - into new File(surfaceIntegrationRunDirectory, 'mods') - } - new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { - dependsOn prepareSurfaceIntegrationTest -} - -def createSurfaceProcess = { String phase, Object dependency -> - task("surfaceIntegration${phase}Process", type: Exec, dependsOn: dependency) { - group = 'verification' - dependsOn { - JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec - runTask.taskDependencies.getDependencies(runTask) - } - doFirst { - JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec - File classpathJar = file("${buildDir}/surface-integration-fixture/${phase.toLowerCase()}-classpath.jar") - classpathJar.parentFile.mkdirs() - java.util.jar.Manifest manifest = new java.util.jar.Manifest() - manifest.mainAttributes.put(java.util.jar.Attributes.Name.MANIFEST_VERSION, '1.0') - manifest.mainAttributes.put(java.util.jar.Attributes.Name.CLASS_PATH, - runTask.classpath.files.collect { it.toURI().toASCIIString() }.join(' ')) - classpathJar.withOutputStream { output -> - new java.util.jar.JarOutputStream(output, manifest).close() - } - List arguments = [] - arguments.addAll(runTask.allJvmArgs) - int classpathFlag = arguments.lastIndexOf('-cp') - if (classpathFlag < 0) { - classpathFlag = arguments.lastIndexOf('-classpath') - } - if (classpathFlag >= 0 && classpathFlag + 1 < arguments.size()) { - arguments.remove(classpathFlag + 1) - arguments.remove(classpathFlag) - } - arguments.add("-Dsurfaceprobe.integrationPhase=${phase.toLowerCase()}") - arguments.add('-cp') - arguments.add(classpathJar.absolutePath) - arguments.add(runTask.mainClass.orNull ?: 'net.minecraftforge.userdev.LaunchTesting') - arguments.addAll(runTask.args) - // The generated ForgeGradle task keeps the project-directory default - // after a clean configuration. The integration world must stay in - // its disposable build-owned directory instead. - workingDir surfaceIntegrationRunDirectory - environment runTask.environment - // Forge 28's launcher (and grossjava9hacks) must run on Java 8 even - // though Gradle and ForgeGradle 7 themselves run on Java 17. - File javaExecutable = java8Launcher.get().executablePath.asFile - // Let Gradle pass the argument vector directly. Wrapping this in - // cmd.exe made the otherwise portable integration gate fail on - // the Linux GitHub Actions runner before Minecraft could start. - commandLine(([javaExecutable.absolutePath] + arguments) as List) - } - } -} - -def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) -surfaceIntegrationFreshProcess.doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) { - throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") - } - assertRuntimeLogsClean(surfaceIntegrationRunDirectory, - 'surface integration fresh phase', [] as Set) -} -def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) -surfaceIntegrationReloadProcess.doLast { - assertRuntimeLogsClean(surfaceIntegrationRunDirectory, - 'surface integration reload phase', [] as Set) -} - -task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { - group = 'verification' - doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties result = new Properties() - marker.withInputStream { result.load(it) } - if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Surface integration reload was not verified: ${marker}") - } - logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) - } -} - -check.dependsOn surfaceIntegrationTest - -task syncForge28EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { - group = 'ide' - doLast { - String mainClass = 'net.minecraftforge.userdev.LaunchTesting' - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String ordinaryModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + - "${mod_id}%%${mainOutput}" - String fixtureOutput = surfaceIntegrationClasses.absolutePath - String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath - String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + - "${mod_id}%%${mainOutput}${File.pathSeparator}" + - "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + - "surfaceprobe%%${fixtureResources}" - def environmentEntries = { String launchTarget -> - " \r\n" + - " \r\n" + - " \r\n" + - " \r\n" + - " \r\n" + - " \r\n" - } - ['Client', 'Server', 'Data'].each { String runName -> - File launch = file("run${runName}.launch") - if (!launch.isFile()) { - throw new GradleException("Missing generated Eclipse launch: ${launch}") - } - String text = launch.getText('UTF-8') - if (text.contains('')) { - String launchTarget = [Client: 'fmluserdevclient', Server: 'fmluserdevserver', - Data: 'fmluserdevdata'][runName] - text = text.replace( - '', - '\r\n' + - environmentEntries(launchTarget) + - " \r\n" + - '') - } - text = text.replace( - '', - "") - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - if (!text.contains('key="target"')) { - String launchTarget = [Client: 'fmluserdevclient', Server: 'fmluserdevserver', - Data: 'fmluserdevdata'][runName] - text = text.replace(' ', - ' \r\n' + - '') - } - launch.setText(text, 'UTF-8') - } - ['Fresh', 'Reload'].each { String phase -> - File launch = file("runSurfaceIntegration${phase}.launch") - if (!launch.isFile()) { - throw new GradleException("Missing generated Eclipse launch: ${launch}") - } - String text = launch.getText('UTF-8') - text = text.replace( - '', - "") - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - if (!text.contains('key="target"')) { - text = text.replace(' - File launch = file(launchName) - String launchText = launch.getText('UTF-8') - if (launchText.contains('Mineralogy-') || - !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") - } - } - } -} - -syncForge28EclipseLaunches.finalizedBy verifyForge28EclipseLaunchIsolation - -tasks.matching { it.name == 'genEclipseRuns' }.all { - finalizedBy syncForge28EclipseLaunches -} - -def packagedSurfaceProbeJar = renamer.classes(tasks.named('surfaceIntegrationTestModJar', Jar)) { - map.from minecraft.dependency.toSrgFile - output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') -} - -tasks.named('jar', Jar) { - archiveClassifier = 'deobf' - destinationDirectory = layout.buildDirectory.dir('libs-dev') - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', - 'Implementation-Title' : base.archivesName.get(), - 'Implementation-Version' : project.version, - 'Implementation-Vendor' : 'SkyBlade1978', - 'OreSpawn-API-Version' : '1', - 'FMLAT' : 'accesstransformer.cfg', - 'Maven-Artifact' : expectedMavenCoordinate, - 'Built-On-Java' : '8', - 'Built-On' : "${project.minecraft_version}-${project.forge_version}" - ]) - } -} - -def releaseJar = renamer.classes(tasks.named('jar', Jar)) { - map.from minecraft.dependency.toSrgFile - archiveClassifier = null - accessTransformers = true - output = layout.buildDirectory.file( - "libs/${base.archivesName.get()}-${project.version}.jar") -} - -tasks.named('sourcesJar', Jar) { - dependsOn prepareBundledDocumentation - filteringCharset = 'UTF-8' - includeEmptyDirs = false - filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-sources', - 'Implementation-Version': project.version - ]) - } -} -tasks.named('javadocJar', Jar) { - filteringCharset = 'UTF-8' - filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-javadoc', - 'Implementation-Version': project.version - ]) - } -} -['apiElements', 'runtimeElements'].each { configurationName -> - configurations.named(configurationName) { artifacts.clear() } - artifacts { add(configurationName, releaseJar) } -} -tasks.named('assemble') { - dependsOn releaseJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -def expectedReleaseFiles = providers.provider { - String prefix = "${base.archivesName.get()}-${project.version}" - ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] -} -def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - doLast { - if (project.mod_version != '4.0.16.114041' - || project.mod_group != expectedMavenGroup - || project.minecraft_version != '1.14.4' - || project.forge_version != '28.2.26' - || project.mapping_channel != 'snapshot' - || project.mapping_version != '20190719-1.14.3') { - throw new GradleException('Unexpected OreSpawn 1.14.4 release identity') - } - if (project.loader_name != 'forge' || project.loader_code != '1' - || project.java_version != '8' || project.gradle_java_version != '17' - || project.java_toolchain_version != '8.0.502+7') { - throw new GradleException('Unexpected dispatcher or Java target metadata') - } - List expectedPublicArtifacts = [ - 'OreSpawn-4.0.16.114041.jar', - 'OreSpawn-4.0.16.114041-sources.jar', - 'OreSpawn-4.0.16.114041-javadoc.jar' - ] - if (base.archivesName.get() != expectedMavenArtifact - || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { - throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') - } - String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') - expectedPublicArtifacts.each { artifactName -> - if (!ciWorkflow.contains("build/libs/${artifactName}")) { - throw new GradleException("CI does not upload expected public artifact ${artifactName}") - } - } - [ - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', - 'README.md', 'CHANGELOG.txt' - ].each { path -> - if (!file(path).getText('UTF-8').contains('4.0.16.114041')) { - throw new GradleException("Release identity missing from ${path}") - } - } - if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { - throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') - } - if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') - .getText('UTF-8').contains('API_VERSION = 1')) { - throw new GradleException('OreSpawn API major must remain 1') - } - if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') - .getText('UTF-8').contains('SCHEMA_VERSION = 6') - || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') - .getText('UTF-8').contains('SCHEMA_VERSION = 5')) { - throw new GradleException('Global/world schemas must remain 6/5') - } - def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) - if (!(schema.properties.schema_version.enum as List).contains(4)) { - throw new GradleException('Provider schema must remain version 4') - } - } -} - -def trackedDocumentationDirectory = file('docs') -def documentationFiles = { - fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { - trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') }.sort() -} -def assertDocumentationTree = { File root, List expected, String label -> - List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } - .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') } - .sort() : [] - if (actual != expected) { - throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") - } - expected.each { relative -> - byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes - byte[] candidate = new File(root, relative).bytes - if (!java.util.Arrays.equals(tracked, candidate)) { - throw new GradleException("${label}/${relative} differs from tracked documentation") - } - } -} - -def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { - group = 'verification' - dependsOn prepareBundledDocumentation - dependsOn tasks.named('processResources') - dependsOn prepareEclipseResources - dependsOn releaseJar - doLast { - List expected = documentationFiles() - if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { - throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") - } - assertDocumentationTree(bundledDocumentationDirectory.asFile, - expected, 'generated resources') - assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, - 'META-INF/orespawn/docs'), expected, 'processed resources') - assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), - expected, 'Eclipse bin/main') - new ZipFile(releaseJar.get().output.get().asFile).withCloseable { zip -> - expected.each { relative -> - def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") - if (entry == null || !java.util.Arrays.equals( - new File(trackedDocumentationDirectory, relative).bytes, - zip.getInputStream(entry).withCloseable { it.bytes })) { - throw new GradleException("Release jar documentation differs at ${relative}") - } - } - } - } -} - -tasks.register('verifyReleaseArtifacts') { - group = 'verification' - dependsOn tasks.named('verifyReleaseConfiguration') - dependsOn verifyDocumentationParity - dependsOn tasks.named('assemble') - doLast { - File libs = layout.buildDirectory.dir('libs').get().asFile - List jars = (libs.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - // Provider interpolation yields GString values; normalize them before - // comparing with real filesystem String names. - List expected = expectedReleaseFiles.get() - .collect { it.toString() }.sort() - if (jars.collect { it.name } != expected) { - throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") - } - jars.each { candidate -> - if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") - new ZipFile(candidate).withCloseable { zip -> - zip.entries().findAll { entry -> - !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } - || entry.name.endsWith('/element-list') - || entry.name.endsWith('/package-list')) - }.each { entry -> - boolean cr = zip.getInputStream(entry).withCloseable { - input -> input.bytes.any { value -> value == 13 } - } - if (cr) throw new GradleException( - "${candidate.name}!/${entry.name} is not LF-normalized") - } - [ - 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', - 'agent-notes/', 'surfaceprobe', 'clientprobe', 'ci-fixtures/', - 'org/junit/', 'org/mockito/', 'net/bytebuddy/', - 'Mineralogy-1.14.4-5.0.1.jar' - ].each { forbidden -> - if (zip.entries().any { it.name.contains(forbidden) }) { - throw new GradleException( - "${candidate.name} contains forbidden ${forbidden}") - } - } - } - } - - File mainJar = new File(libs, expectedReleaseFiles.get()[0]) - new ZipFile(mainJar).withCloseable { zip -> - List names = zip.entries().collect { it.name } - [ - 'META-INF/mods.toml', - 'META-INF/accesstransformer.cfg', - 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', - 'META-INF/orespawn/docs/VERSIONS.md', - 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', - 'AGENTS.md' - ].each { required -> - if (!names.contains(required)) { - throw new GradleException("Release jar is missing ${required}") - } - } - String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) - .getText(StandardCharsets.UTF_8.name()) - if (!metadata.contains('modId="orespawn"') - || !metadata.contains("version=\"${project.version}\"") - || !metadata.contains('versionRange="[1.14.4]"')) { - throw new GradleException('Packaged Forge metadata is incorrect') - } - String transformer = zip.getInputStream( - zip.getEntry('META-INF/accesstransformer.cfg')) - .getText(StandardCharsets.UTF_8.name()) - List actualRules = transformer.readLines() - .collect { it.replaceFirst(/\s*#.*/, '').trim() } - .findAll { !it.isEmpty() } - List expectedRules = [ - 'public-f net.minecraft.world.gen.ChunkGenerator field_222542_c', - 'public-f net.minecraft.world.gen.NoiseChunkGenerator field_222560_g', - 'public net.minecraft.world.biome.Biome field_201874_aj' - ] - if (actualRules != expectedRules) { - throw new GradleException("Unexpected packaged SRG access transformer: ${actualRules}") - } - def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') - def manifest = manifestEntry == null ? null : - new Manifest(zip.getInputStream(manifestEntry)).mainAttributes - if (manifest == null - || manifest.getValue('Implementation-Version') != project.mod_version - || manifest.getValue('OreSpawn-API-Version') != '1' - || manifest.getValue('FMLAT') != 'accesstransformer.cfg' - || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate - || manifest.getValue('Implementation-Timestamp') != null) { - throw new GradleException('Release manifest is incorrect or volatile') - } - zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> - byte[] header = new byte[8] - zip.getInputStream(entry).withCloseable { input -> - if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") - } - int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 52) { - throw new GradleException("${entry.name} uses class major ${major}, expected 52") - } - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> - if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { - throw new GradleException('Sources jar is missing OreSpawn.java') - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> - if (zip.getEntry('index.html') == null - || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { - throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') - } - } - } -} - -tasks.register('writeReleaseChecksums') { - group = 'verification' - dependsOn tasks.named('verifyReleaseArtifacts') - def outputFile = layout.buildDirectory.file('release/SHA256SUMS') - outputs.file(outputFile) - doLast { - File output = outputFile.get().asFile - output.parentFile.mkdirs() - File libs = layout.buildDirectory.dir('libs').get().asFile - String contents = expectedReleaseFiles.get().sort().collect { name -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - new File(libs, name).withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" - }.join('\n') + '\n' - output.setText(contents, 'UTF-8') - } -} - -tasks.register('verifyPreparedReleaseArtifacts') { - group = 'verification' - doLast { - if (!preparedReleaseDir.isPresent()) { - throw new GradleException('preparedReleaseDir is required') - } - File prepared = file(preparedReleaseDir.get()) - // Provider interpolation yields GString values; normalize them before - // comparing with real filesystem String names. - List expected = expectedReleaseFiles.get() - .collect { it.toString() }.sort() - List jars = (prepared.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - List actualNames = jars.collect { it.name.toString() }.sort() - if (actualNames != expected) { - throw new GradleException( - "Prepared release jars ${actualNames} do not match ${expected}") - } - if (jars.any { it.length() == 0L }) { - throw new GradleException('Prepared release contains an empty jar') - } - File checksums = new File(prepared, 'SHA256SUMS') - if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { - throw new GradleException('Prepared release is missing checksums or changelog') - } - List actual = jars.collect { candidate -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - candidate.withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" - }.sort() - if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { - throw new GradleException('Prepared release checksums do not match') - } - } -} - -def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') - .orElse('https://invalid.invalid/missing-maven-upload-url') -def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') -def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') -publishing { - publications { - mavenJava(MavenPublication) { - groupId = expectedMavenGroup - artifactId = expectedMavenArtifact - version = project.version.toString() - if (preparedReleaseDir.isPresent()) { - File prepared = file(preparedReleaseDir.get()) - artifact(new File(prepared, expectedReleaseFiles.get()[0])) - artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } - artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } - } else { - artifact(releaseJar) - artifact(tasks.named('sourcesJar')) - artifact(tasks.named('javadocJar')) - } - pom { - name = 'MMD OreSpawn' - description = project.mod_description - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - licenses { - license { - name = 'GNU Lesser General Public License, Version 2.1' - url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' - } - } - } - } - } - repositories { - maven { - name = 'release' - url = uri(mavenUploadUrl.get()) - credentials { - username = mavenUploadUsername.orNull ?: '' - password = mavenUploadPassword.orNull ?: '' - } - } - } -} -tasks.register('verifyMavenCoordinates') { - group = 'verification' - description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' - dependsOn tasks.named('generatePomFileForMavenJavaPublication') - doLast { - File pomFile = layout.buildDirectory.file( - 'publications/mavenJava/pom-default.xml').get().asFile - if (!pomFile.isFile()) { - throw new GradleException("Generated Maven POM does not exist: ${pomFile}") - } - def pom = new XmlSlurper(false, false).parse(pomFile) - def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] - def expected = [expectedMavenGroup, expectedMavenArtifact, - project.version.toString()] - if (project.group.toString() != expectedMavenGroup || actual != expected) { - throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + - "found ${actual.join(':')}") - } - } -} -tasks.register('validateMavenReleaseCredentials') { - group = 'publishing' - doLast { - if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() - || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { - throw new GradleException( - 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') - } - if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { - throw new GradleException('Release publication must use a remote repository') - } - } -} -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn preparedReleaseDir.isPresent() - ? tasks.named('verifyPreparedReleaseArtifacts') - : tasks.named('verifyReleaseArtifacts') -} - -eclipse { - classpath { - downloadSources = true - downloadJavadoc = true - } - synchronizationTasks 'isolateEclipseProductionRuns' -} -idea { - module { - downloadSources = true - downloadJavadoc = true - } -} -tasks.register('configureEclipseBuildship') { - group = 'ide' - doLast { - File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') - Properties preferences = new Properties() - [ - 'eclipse.preferences.version' : '1', - 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', - 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'connection.project.dir' : '', - 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'override.workspace.settings' : 'true' - ].each { key, value -> preferences.setProperty(key, value) } - preferencesFile.parentFile.mkdirs() - preferencesFile.withOutputStream { - preferences.store(it, 'Generated by OreSpawn Buildship configuration.') - } - } -} -tasks.register('isolateEclipseProductionRuns') { - group = 'ide' - dependsOn tasks.named('genEclipseRuns') - dependsOn syncForge28EclipseLaunches - dependsOn tasks.named('configureEclipseBuildship') - dependsOn prepareEclipseResources - doLast { - [ - 'OreSpawn_Client.launch': 'GradleStart', - 'OreSpawn_Server.launch': 'GradleStartServer' - ].each { String name, String mainClass -> - File launch = file(name) - if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) - && !launch.delete()) { - throw new GradleException("Could not remove obsolete launch ${name}") - } - } - fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> - String contents = launch.getText('UTF-8') - contents = contents.replace( - 'key="MC_VERSION" value="${MC_VERSION}"', - "key=\"MC_VERSION\" value=\"${minecraft_version}\"") - launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') - } - } -} -tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - dependsOn tasks.named('eclipseClasspath') - dependsOn tasks.named('isolateEclipseProductionRuns') - dependsOn tasks.named('verifyLegacyOracleIsolation') - doLast { - File prefs = file('.settings/org.eclipse.buildship.core.prefs') - if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') - Properties buildshipPreferences = new Properties() - prefs.withInputStream { buildshipPreferences.load(it) } - String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath - if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome - || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome - || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { - throw new GradleException( - "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") - } - Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration - .resolvedArtifacts - .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } - .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } - .toSet() - if (!legacyLwjglArtifacts.isEmpty()) { - throw new GradleException( - "Forge 1.14 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") - } - File eclipseClasspath = file('.classpath') - if (!eclipseClasspath.isFile() - || !eclipseClasspath.getText('UTF-8').contains( - 'path="src/generated/resources"')) { - throw new GradleException( - 'Eclipse does not expose the generated production-resource source folder') - } - [ - 'META-INF/mods.toml', - 'META-INF/orespawn/docs/README.md', - 'META-INF/orespawn/docs/VERSIONS.md' - ].each { relative -> - if (!new File('bin/main', relative).isFile()) { - throw new GradleException("Eclipse output is missing ${relative}") - } - } - List forbidden = [ - 'src/test', 'bin/test', 'build/classes/java/test', - 'biomeIntegrationTest', 'clientIntegrationTest', - 'surfaceprobe', 'clientprobe', 'junit-', 'opentest4j-', - 'Mineralogy-1.14.4-5.0.1.jar', 'C:\\Users\\John' - ] - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String expectedModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + - "${mod_id}%%${mainOutput}" - ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> - File launch = file(name) - if (!launch.isFile()) throw new GradleException("Missing ${name}") - String contents = launch.getText('UTF-8') - List leaked = forbidden.findAll { contents.contains(it) } - if (!leaked.isEmpty()) { - throw new GradleException("${name} exposes test/local content: ${leaked}") - } - if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') - || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { - throw new GradleException("${name} is not a production-only OreSpawn launch") - } - if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { - throw new GradleException("${name} lacks Forge 28 merged output discovery") - } - } - } -} - -tasks.register('verifyCommandPortability') { - group = 'verification' - description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' - doLast { - String gradleSource = file('build.gradle').getText('UTF-8') - List commandSources = [file('build.gradle')] - commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) - commandSources.each { File source -> - String text = source.getText('UTF-8') - if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { - throw new GradleException("Shell-specific command wrapper in ${source}") - } - } - if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') - || !gradleSource.contains('join(File.pathSeparator)') - || !gradleSource.contains('${File.pathSeparator}')) { - throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') - } - } -} - -tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } -tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } - -def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') -def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') - -def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> - if (!configuredPath.isPresent()) { - throw new GradleException("Pass -P${propertyName}=") - } - File runtime = file(configuredPath.get()) - if (!runtime.isDirectory()) { - throw new GradleException("${propertyName} does not name a directory: ${runtime}") - } - runtime -} - -def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") -tasks.register('preparePackagedSurfaceIntegration') { - dependsOn releaseJar - dependsOn packagedSurfaceProbeJar - doLast { - delete packagedSurfaceRunDirectory - packagedSurfaceRunDirectory.mkdirs() - copy { - from releaseJar - from packagedSurfaceProbeJar - into new File(packagedSurfaceRunDirectory, 'mods') - } - new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} -def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { - group = 'verification' - dependsOn tasks.named('preparePackagedSurfaceIntegration') - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, - 'packagedForgeServerRuntime') - File launcher = new File(runtime, 'forge-1.14.4-28.2.26.jar') - File vanillaServer = new File(runtime, 'minecraft_server.1.14.4.jar') - File libraries = new File(runtime, 'libraries') - [launcher, vanillaServer, libraries].each { - if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") - } - workingDir packagedSurfaceRunDirectory - commandLine java8Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', - '-jar', launcher.absolutePath, 'nogui' - } - doLast { - File marker = new File(packagedSurfaceRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') - assertRuntimeLogsClean(packagedSurfaceRunDirectory, - 'packaged surface fresh', [] as Set) - } -} -def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { - group = 'verification' - dependsOn packagedSurfaceFresh - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, - 'packagedForgeServerRuntime') - File launcher = new File(runtime, 'forge-1.14.4-28.2.26.jar') - workingDir packagedSurfaceRunDirectory - commandLine java8Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', - '-jar', launcher.absolutePath, 'nogui' - } - doLast { - assertRuntimeLogsClean(packagedSurfaceRunDirectory, - 'packaged surface reload', [] as Set) - } -} -tasks.register('packagedSurfaceIntegrationTest') { - group = 'verification' - dependsOn packagedSurfaceReload - doLast { - File marker = new File(packagedSurfaceRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties values = new Properties() - marker.withInputStream { values.load(it) } - if (values.getProperty('reload_verified') != 'true') { - throw new GradleException('Packaged surface reload was not verified') - } - } -} - -def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") -tasks.register('preparePackagedClientIntegration') { - dependsOn releaseJar - dependsOn packagedClientProbeJar - doLast { - delete packagedClientRunDirectory - packagedClientRunDirectory.mkdirs() - copy { - from releaseJar - from packagedClientProbeJar - into new File(packagedClientRunDirectory, 'mods') - } - new File(packagedClientRunDirectory, 'options.txt').setText( - 'fullscreen:false\nlang:en_us\n', 'UTF-8') - } -} -def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { - group = 'verification' - dependsOn tasks.named('preparePackagedClientIntegration') - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, - 'packagedForgeClientRuntime') - File forgeJsonFile = new File(runtime, - 'versions/forge-28.2.26/forge-28.2.26.json') - File baseJsonFile = new File(runtime, 'versions/1.14.4/1.14.4.json') - File baseJar = new File(runtime, 'versions/1.14.4/1.14.4.jar') - [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), - new File(runtime, 'assets'), new File(runtime, 'natives/forge-28.2.26')].each { - if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") - } - - def slurper = new groovy.json.JsonSlurper() - Map forgeJson = (Map) slurper.parse(forgeJsonFile) - Map baseJson = (Map) slurper.parse(baseJsonFile) - Map classpathByModule = new LinkedHashMap<>() - [baseJson, forgeJson].each { Map metadata -> - ((List) metadata.libraries).each { Map library -> - List rules = (List) library.rules - boolean allowed = rules == null || rules.isEmpty() - if (rules != null) { - rules.each { Map rule -> - Map os = (Map) rule.os - boolean matches = os == null - || (os.name == 'windows' - && (os.arch == null || os.arch == System.getProperty('os.arch'))) - if (matches) allowed = rule.action == 'allow' - } - } - if (!allowed) return - String relative = (String) ((Map) ((Map) library.downloads).artifact).path - File artifact = new File(runtime, "libraries/${relative}") - if (!artifact.isFile()) { - throw new GradleException("Missing official client library: ${artifact}") - } - List coordinates = ((String) library.name).split(':') as List - String module = coordinates.size() >= 2 - ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name - classpathByModule.put(module, artifact) - } - } - List classpathFiles = new ArrayList<>(classpathByModule.values()) - classpathFiles.add(baseJar) - - List gameArguments = [] - gameArguments.addAll((List) ((Map) forgeJson.arguments).game) - gameArguments.addAll([ - '--username', 'OreSpawnValidation', - '--version', (String) forgeJson.id, - '--gameDir', packagedClientRunDirectory.absolutePath, - '--assetsDir', new File(runtime, 'assets').absolutePath, - '--assetIndex', (String) ((Map) baseJson.assetIndex).id, - '--uuid', '00000000-0000-0000-0000-000000000001', - '--accessToken', 'validation-token', - '--userType', 'legacy', - '--versionType', 'release', - '--width', '854', '--height', '480' - ]) - workingDir packagedClientRunDirectory - commandLine java8Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', - "-Djava.library.path=${new File(runtime, 'natives/forge-28.2.26').absolutePath}", - '-Dminecraft.launcher.brand=orespawn-validation', - '-Dminecraft.launcher.version=1', - '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), - (String) forgeJson.mainClass - args gameArguments - } -} -tasks.register('packagedClientIntegrationTest') { - group = 'verification' - dependsOn packagedClientProcess - doLast { - File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') - if (!marker.isFile()) { - throw new GradleException('Packaged client completion marker is missing') - } - Properties values = new Properties() - marker.withInputStream { values.load(it) } - ['world_settings_opened', 'long_editor_roundtrip', - 'first_world_rendered', 'reload_rendered'].each { key -> - if (values.getProperty(key) != 'true') { - throw new GradleException("Packaged client failed ${key}: ${values}") - } - } - assertDocumentationTree(new File(packagedClientRunDirectory, - 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') - assertRuntimeLogsClean(packagedClientRunDirectory, - 'packaged client', [] as Set) - } -} - -tasks.register('packagedRuntimeIntegrationTest') { - group = 'verification' - description = 'Runs the exact reobfuscated jars in official Forge 28 server and client runtimes.' - dependsOn tasks.named('packagedSurfaceIntegrationTest') - dependsOn tasks.named('packagedClientIntegrationTest') -} +apply from: 'gradle/verification/support.gradle' +apply from: 'gradle/verification/client-and-benchmark.gradle' +apply from: 'gradle/release/artifacts.gradle' +apply from: 'gradle/release/publishing.gradle' +apply from: 'gradle/ide/eclipse.gradle' +apply from: 'gradle/verification/packaged-forge.gradle' +apply from: 'gradle/verification/ore-sources.gradle' +apply from: 'gradle/verification/native-ores.gradle' +apply from: 'gradle/verification/performance.gradle' diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index a62c49fb..00000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,17 +0,0 @@ -# OreSpawn Documentation Map - -This index is for navigating the documentation to learn how to integrate with -and use OreSpawn with a mod or modpack. Start with -[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). - -Use the focused guides for implementation details: - -- [API.md](API.md) for the supported Java API; -- [PROVIDERS.md](PROVIDERS.md) for packaged and configurable providers; -- [FEATURES.md](FEATURES.md) for rocks, ores, deposits, and geology; -- [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; -- [TEMPLATES.md](TEMPLATES.md) for selectable world styles; -- [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; -- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified - versioning, skipped functional releases, and branch-release convention; -- [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index c8c42856..a3167e06 100644 --- a/docs/API.md +++ b/docs/API.md @@ -50,8 +50,22 @@ coverage with `OreDefinition.Builder.dimensionSelector(...)` and `OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END`. Explicit dimensions override that selector and prevent duplicate placement. -The builder emits provider schema 4. Legacy provider schemas 1-3 remain -readable. Schema 4 is required for biome palettes and dimension materials. +The builder emits provider schema 5. Legacy provider schemas 1-4 remain +readable. Schema 4 is required for biome palettes and dimension materials; +schema 5 adds explicit material and placement-channel IDs. API major remains 1. + +For custom ore patterns, `OreGenerationContext` extends the existing +`OrePlacementContext` without changing its binary contract. It exposes the +world seed, dimension, chunk coordinates, and an optional geology sampler. +The `tryPlace(x, y, z, outputIdentity)` overload gives separated slices of one +logical deposit the same output choice. Code built against the older context +continues to work through the original `tryPlace` method. + +Client add-ons may register one optional configuration screen per mod through +`WorldSettingsExtensionRegistry.registerConfigScreen(modId, factory)` during +client initialization. OreSpawn shows it in the loaded-mod directory and +returns Escape to that directory. Do not load this client-only API on a +dedicated server. Provider-owned fluid deposits are declarative and may target several dimensions: diff --git a/docs/BIOMES.md b/docs/BIOMES.md index d1d6232b..d1c68568 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -171,10 +171,18 @@ then lexical template ID order. ## World-Creation Editor -**Biomes & World Materials** is visible even when rock strata are disabled. -It lists palettes and materials by dimension, uses installed-registry pickers, -and validates IDs before world creation. The editor is creation-only in 4.0.0; -existing worlds remain editable through their self-contained server profile. +**Biomes** is visible even when rock strata are disabled. Its directory shows +loaded and referenced biomes once, with provider placement rules and effective +palette order. It can replace one loaded source biome with another in newly +generated terrain. The exact replacement layer runs after ordinary palettes; +existing chunks are never rewritten. A missing target stays dormant until its +mod returns. Palette settings expose mode, scope, region size, coverage, +fallback weight, and namespace filters without reordering provider palettes. + +The materials button belongs to the selected dimension, not the selected +biome. Aquifer fluid, snow, and ice substitutions apply across every biome in +that dimension. Forge 1.14.4 has no separate deep-aquifer generator fluid, so +that stored field remains unavailable on this target. ## Performance Boundaries diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 534c41f9..67053ba6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,13 +4,14 @@ OreSpawn uses three JSON contracts: | File | Schema | Purpose | |---|---:|---| -| `config/orespawn-worldgen.json` | 6 | Installed-pack defaults for new worlds | -| `/serverconfig/orespawn-worldgen.json` | 5 | Self-contained snapshot for one world | -| `config/-orespawn.json` | 4 | Optional authoritative provider override | +| `config/orespawn-worldgen.json` | 8 | Installed-pack defaults for new worlds | +| `/serverconfig/orespawn-worldgen.json` | 7 | Self-contained snapshot for one world | +| `config/-orespawn.json` | 5 | Optional authoritative provider override | -A provider may package schema 4 at `data//orespawn/provider.json`. -Legacy provider schemas 1-3 remain accepted. Fluid deposits require schema 3; -biome palettes and dimension materials require schema 4. +A provider may package schema 5 at `data//orespawn/provider.json`. +Legacy provider schemas 1-4 remain accepted. Fluid deposits require schema 3; +biome palettes and dimension materials require schema 4. Explicit ore material +and placement-channel IDs require schema 5. The profile for a new world is merged in this order: passive OreSpawn defaults, packaged or API providers, provider override files, the global configuration, @@ -29,7 +30,7 @@ report for the exact decision. | Field | Values | Meaning | |---|---|---| -| `schema_version` | Contract-specific integer | Global 6, world 5, provider 4 | +| `schema_version` | Contract-specific integer | Global 8, world 7, provider 5 | | `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine | | `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules | | `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features | @@ -44,6 +45,8 @@ report for the exact decision. | `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces | | `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions | | `ores` | object keyed by rule ID | Ore outputs and per-dimension placement | +| `ore_material_groups` | object keyed by material ID | Friendly names and exact block-tag aliases; imported Ore Dictionary names stay dormant | +| `ore_source_policies` | object keyed by material and dimension | Saved output mode, weights, and placement source per channel | | `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement | | `retrogen` | object | Bounded ore retrogen controls | | `flat_bedrock` | object | Opt-in flat bedrock controls | @@ -179,6 +182,7 @@ Each enabled ore dimension uses: | `node_size` | 1-32 | Cluster node size | | `length` | 1-64 | Pattern path length where supported | | `fluid` | registry ID | Fluid used by `underfluids` | +| `placement_channel` | registry ID | Independent placement budget shared by equivalent ores in consolidated modes | At least one of `host_families`, `host_blocks`, or `host_tags` must be present. Hosts may be plain registry IDs or weighted objects such as @@ -196,6 +200,30 @@ The selector `orespawn:all_except_nether_end` covers every dimension except the vanilla Nether and End. Explicit rules in `dimensions` override selector rules for the same ore and dimension, including explicit disabled rules. +### Material groups and source policies + +Provider schema 5 may add a `material` ID to an ore. Otherwise OreSpawn infers +the material only from exact loaded block tags under `*/ores/*`; it does not +guess from a block or mod name. `ore_material_groups` stores a friendly name +and `block_tag_entries` for each group. Imported `ore_dictionary_entries` +remain dormant unless a user gives the group an exact tag mapping. + +`ore_source_policies` stores one material-and-dimension decision: Keep Original +(`keep_separate`) or consolidated Balanced, Single, or Custom output selection. +The `placement_sources` object names one source rule for each placement +channel. Consolidation uses that rule's frequency, shape, depth, and hosts, +then picks the selected output for a whole deposit. It does not give every +output its own placement budget. External generators are shown for context but +cannot be suppressed by OreSpawn. + +For a newly created world, two loaded ordinary MMD providers with the same +exact ore tag start Balanced. A configured Sulfur or Lithium priority chooses +the initial placement source; other conflicts use stable owner/rule-ID order. +Missing providers create no phantom ores. Existing worlds retain their saved +policy, defaulting to Keep Original on upgrade. Both global and world profile +upgrades keep a backup; editing the global defaults in the UI is saved only +when the main OreSpawn editor's Done is pressed. + ## Fluid Deposits, Retrogen, And Bedrock Each `fluid_deposits` entry has a stable provider-namespaced rule ID, an diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index c07642aa..e44820f0 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -12,6 +12,8 @@ | Add covered underground oil or another fluid | Provider schema 3 fluid deposit | | Add or place biomes without a framework dependency | Provider schema 4 biome palette | | Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials | +| Share placement between equivalent ore blocks | Provider schema 5 material and placement channel, with one exact block tag | +| Add an OreSpawn-linked settings screen | Client-only `WorldSettingsExtensionRegistry` | | Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` | Strata are optional. If no enabled terrain dimension has eligible rocks, @@ -21,7 +23,7 @@ blocks or tags. ## Provider JSON Quick Start -Put a schema-4 file in your mod jar at: +Put a schema-5 file in your mod jar at: ```text src/main/resources/data/examplemod/orespawn/provider.json @@ -33,7 +35,7 @@ stone without enabling strata: ```json { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "ores": { @@ -215,3 +217,14 @@ pattern beside every built-in type. Run `gradlew check` (or `gradlew build`, which includes it) before publishing any change to biome registration, palettes, surfaces, feature ordering, height handling, or profile persistence. + +For manual shared-ore testing, generate the Eclipse runs and select +`runOreSourcesClient`. Two isolated dummy providers share a Sulfur tag and use +coloured wool as test outputs. The profile keeps its saves in `run-ore-sources` +and does not create worlds automatically. Normal launches and release jars +exclude these providers. + +The opt-in `nativeOreIntegrationTest` uses an official Forge server supplied +through `packagedForgeServerRuntime`. It checks managed quartz with Forge 28's +netherrack tag and explicit block hosts, exact-save reload, and a depth change +that affects new terrain only. All test worlds stay under `build/`. diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 6a5fb65e..69628f75 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -25,8 +25,10 @@ adds them. You can remove the starter rocks or add blocks from installed mods before creating the world. Mods can also offer new biomes and world materials without enabling strata. -Use **Biomes & World Materials** to inspect installed dimension palettes, -surface blocks, aquifer fluids, snow, and ice. The picker only accepts real +Use **Biomes** to inspect loaded biomes, placement palettes, and surface rules. +Replacing a biome changes only newly generated terrain. The dimension's +**Materials** button changes aquifer fluid, snow, or ice across every biome in +that dimension, not only the biome currently selected. The picker only accepts installed registry entries. Missing optional compatibility biomes are skipped safely instead of breaking world creation. @@ -48,11 +50,13 @@ safely instead of breaking world creation. installed mod. These are covered underground deposits, not exposed vanilla lakes. **Solid Cover** controls the roof thickness, while **Solid Shell** prevents a deposit from opening into a cave at its sides or underside. -- **Biomes & World Materials** controls broad biome regions and what their - surfaces, underground water, snow, and ice are made from. **Augment** mixes - new biomes into the existing source; **Replace** creates a complete provider - style. Namespace scope protects other biome mods unless a pack deliberately - opts them in. +- **Biomes** lists installed and referenced biomes and their placement rules. + **Replace in new terrain with...** is an exact, one-to-one choice and does + not rewrite existing chunks. Palette settings still offer **Augment** for a + provider mix or **Replace** for a complete provider style. +- **Ore Sources** groups interchangeable ores by exact block tags. Balanced + shares one placement budget across eligible outputs; Single picks one output; + Custom lets you choose weights. Keep Original leaves every rule independent. **World Materials** applies across an entire dimension. **Aquifer Fluid** changes the normal below-sea-level fluid. Minecraft 1.14.4 exposes only that diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index c6c9267f..46f75b22 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -6,12 +6,20 @@ Provider mods may contribute through Forge IMC, a packaged resource at present malformed override leaves that provider inactive instead of silently falling back. -Provider schema 4 supports `profile_defaults`, `rocks`, `ores`, +Provider schema 5 supports `profile_defaults`, `rocks`, `ores`, `fluid_deposits`, `geomes`, `biome_rules`, `terrain_dimensions`, and `templates`, plus `biome_palettes` and `dimension_materials`. Each file requires a matching `provider_modid`, a positive `provider_revision`, and at least one -contribution. Legacy schemas 1-3 remain accepted; schema 3 introduced fluid -deposits and schema 4 introduces biome and world-material controls. +contribution. Legacy schemas 1-4 remain accepted; schema 3 introduced fluid +deposits, schema 4 added biome and world-material controls, and schema 5 adds +optional `material` and `placement_channel` IDs for exact ore grouping. + +Use the same exact loaded block tag, such as `forge:ores/sulfur`, for ores that +are interchangeable. OreSpawn never infers equivalence from similar block or +mod names. When two ordinary MMD providers for the same tag are loaded, a new +world starts Balanced with one placement budget per channel. An absent mod +adds no phantom output or placement rule, and old worlds keep their saved +choices. Enrichment and dimension-specific providers are kept separate. An ore-only provider does not need rocks, geomes, or terrain dimensions. Give each ore explicit host blocks or tags and OreSpawn will leave vanilla terrain, diff --git a/docs/README.md b/docs/README.md index 9fe8ad40..56a50056 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,9 @@ shaded or embeddable engine artifact. Choose the guide that matches what you are doing: +If you are integrating a mod, start with the developer guide; the API and +provider guides give the exact Java and JSON contracts. + - [Player and server guide](PLAYER_GUIDE.md) - [Developer quick start and complete integration map](DEVELOPER_GUIDE.md) - [Configuration field reference](CONFIGURATION.md) @@ -18,7 +21,6 @@ Choose the guide that matches what you are doing: - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) - [Versioning and release conventions](VERSIONS.md) -- [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. The provider and migration guides include OS3-compatible ranged quantities and diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 8ce7bd9c..72bcaaac 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -51,8 +51,8 @@ Examples: | Minecraft | Loader | Target | Example full OreSpawn version | | --- | --- | ---: | --- | -| 1.13.2 | Forge | `113021` | `4.0.6.113021` | -| 1.14.4 | Forge | `114041` | `4.0.16.114041` | +| 1.13.2 | Forge | `113021` | `4.0.16.113021` | +| 1.14.4 | Forge | `114041` | `4.1.0.114041` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | | 26.1.2 | Forge | `2601021` | `4.0.6.2601021` | @@ -157,6 +157,11 @@ lifecycle portion of 4.0.16 are not applicable; it adopts the shared 4.0.16 identity while retaining ordinary benchmark auto-stop. A branch may therefore legitimately skip functional version numbers. +The 1.14.4 line now advances to 4.1.0 for material-group ore policies, +the loaded-mod directory, and exact new-terrain biome replacements. It keeps +the `114041` target suffix, API major 1, and the target's existing registry +identities while provider/global/world schemas advance to 5/8/7. + This provides three useful guarantees: 1. A functional version is not used to describe two unrelated change sets. diff --git a/docs/examples/examplemod-orespawn.json b/docs/examples/examplemod-orespawn.json index d850d2ef..6a35e013 100644 --- a/docs/examples/examplemod-orespawn.json +++ b/docs/examples/examplemod-orespawn.json @@ -1,5 +1,5 @@ { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "rocks": { diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json index cabcfee1..6710b185 100644 --- a/docs/examples/orespawn-global.json +++ b/docs/examples/orespawn-global.json @@ -1,5 +1,5 @@ { - "schema_version": 6, + "schema_version": 8, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -32,6 +32,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json index 93f51e30..6169d622 100644 --- a/docs/examples/orespawn-world.json +++ b/docs/examples/orespawn-world.json @@ -1,5 +1,5 @@ { - "schema_version": 5, + "schema_version": 7, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -70,6 +70,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json index e20e5774..49022e13 100644 --- a/docs/schemas/orespawn-global.schema.json +++ b/docs/schemas/orespawn-global.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 6 } } + "properties": { "schema_version": { "const": 8 } } } ] } diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json index 83f67f7b..1416edfa 100644 --- a/docs/schemas/orespawn-provider.schema.json +++ b/docs/schemas/orespawn-provider.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["schema_version", "provider_modid", "provider_revision"], "properties": { - "schema_version": { "enum": [1, 2, 3, 4] }, + "schema_version": { "enum": [1, 2, 3, 4, 5] }, "provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" }, "provider_revision": { "type": "integer", "minimum": 1 }, "rocks": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/rock" } }, diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json index f23b7970..497cc4a0 100644 --- a/docs/schemas/orespawn-world.schema.json +++ b/docs/schemas/orespawn-world.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 5 } } + "properties": { "schema_version": { "const": 7 } } } ] } diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json index 6d99c56c..c76a11e9 100644 --- a/docs/schemas/profile-common.schema.json +++ b/docs/schemas/profile-common.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://mcmoddev.com/orespawn/schemas/profile-common.schema.json", "type": "object", - "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "fluid_deposits"], + "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_material_groups", "ore_source_policies", "fluid_deposits"], "properties": { "geology_mode": { "enum": ["geome", "legacy"] }, "place_fluid_deposits": { "type": "boolean" }, @@ -19,6 +19,8 @@ "biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } }, "dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } }, "ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } }, + "ore_material_groups": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "$ref": "#/$defs/oreMaterialGroup" } }, + "ore_source_policies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/oreSourcePolicy" } }, "fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fluidDeposit" } }, "worldgen_aliases": { "type": "object", "additionalProperties": { "$ref": "#/$defs/registryId" } }, "providers": { "type": "object" }, @@ -145,6 +147,7 @@ "minimum": 0.0, "maximum": 1.0 }, + "placement_channel": { "$ref": "#/$defs/registryId" }, "spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "node_size": { "type": "integer", "minimum": 1, "maximum": 32 }, @@ -181,6 +184,7 @@ "required": ["enabled"], "properties": { "block": { "$ref": "#/$defs/registryId" }, + "material": { "$ref": "#/$defs/registryId" }, "enabled": { "type": "boolean" }, "source_mod": { "type": "string" }, "native_generation": { "type": "boolean" }, @@ -217,6 +221,56 @@ ], "additionalProperties": true }, + "oreSourceCandidate": { + "type": "object", + "required": ["source_id", "owner", "registry_id", "material", "domain", "placement_channel", "loaded", "external"], + "properties": { + "source_id": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "owner_name": { "type": "string" }, + "owner_version": { "type": "string" }, + "registry_id": { "$ref": "#/$defs/registryId" }, + "metadata": { "type": "integer", "minimum": 0 }, + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "placement_channel": { "$ref": "#/$defs/registryId" }, + "block_tags": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "loaded": { "type": "boolean" }, + "placement_active": { "type": "boolean" }, + "external": { "type": "boolean" }, + "enrichment": { "type": "boolean" }, + "review_required": { "type": "boolean" }, + "material_declared": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreSourcePolicy": { + "type": "object", + "required": ["material", "domain", "mode", "output_mode", "outputs", "placement_sources", "candidates"], + "properties": { + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "mode": { "enum": ["consolidated", "keep_separate"] }, + "output_mode": { "enum": ["balanced", "single", "custom"] }, + "outputs": { "type": "object", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } }, + "placement_sources": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "type": "string", "minLength": 1 } }, + "candidates": { "type": "array", "items": { "$ref": "#/$defs/oreSourceCandidate" } }, + "review_required": { "type": "boolean" }, + "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] }, + "dormant": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreMaterialGroup": { + "type": "object", + "required": ["display_name", "block_tag_entries"], + "properties": { + "display_name": { "type": "string", "minLength": 1, "maxLength": 64 }, + "block_tag_entries": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "ore_dictionary_entries": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": false + }, "fluidDepositDimension": { "type": "object", "required": ["enabled", "min_y", "max_y", "frequency", "min_radius", "max_radius", "min_vertical_radius", "max_vertical_radius", "max_lobes", "min_solid_cover"], diff --git a/gradle.properties b/gradle.properties index 3e7b3975..e2e92b43 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ mcp_version=20190829.143755 mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.16.114041 +mod_version=4.1.0.114041 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 new file mode 100644 index 00000000..d71d5acc --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,61 @@ +# Build scripts + +`build.gradle` keeps the Forge 1.14.4 target, Java toolchains, repositories, +dependencies and resource setup together. The scripts below hold the longer +checks and release tasks. They are applied in dependency order, and share only +the small `oreSpawnBuild` helper map declared by the root build. + +| Script | What belongs there | +| --- | --- | +| `verification/support.gradle` | Runtime-log checks and ordinary development-launch guards. | +| `verification/client-and-benchmark.gradle` | Isolated client and surface-test mods and fresh/reload development runs. | +| `verification/ore-sources.gradle` | Dummy future-provider mods, their manual client profile and fresh/reload checks. | +| `verification/native-ores.gradle` | Managed Nether quartz with tag and block hosts, reloads and new-terrain-only edits. | +| `verification/packaged-forge.gradle` | Finished-jar tests in disposable official Forge installations. | +| `verification/performance.gradle` | Matched vanilla, Cyano and Sky benchmarks and completed-world hash comparisons. | +| `release/artifacts.gradle` | Reobfuscated jars, artifact audits, documentation parity and checksums. | +| `release/publishing.gradle` | Maven publication, coordinates and credential checks. | +| `ide/eclipse.gradle` | Buildship setup, generated launches and production-classpath verification. | + +Put a new check next to the work it verifies. Test mods belong in their own +source trees; do not add them to normal runs, production classpaths or release +jars. After changing launch or resource setup, run `genEclipseRuns` and +`verifyEclipseProductionClasspath` as well as the command-line build. + +The packaged checks require explicit `packagedForgeServerRuntime` and +`packagedForgeClientRuntime` properties pointing to disposable official Forge +installations. They are opt-in; an ordinary `check` does not launch a packaged +game. Server invocations keep the final argument as literal `nogui`. + +For a 4.0.16-versus-4.1 comparison, pass `benchmarkOreSpawnJar` with the +finished 4.0.16 jar, then run the same performance task without it. Add +`benchmarkWorldHash=true` to both runs before `verifyWorldgenParity`. + +Use Java 17 to run Gradle and the pinned Java 8 toolchain to compile and run +Minecraft. A warm-cache offline build is useful for normal development, but +does not replace a fresh-cache bootstrap test. + +Run `nativeOreIntegrationTest` with `packagedForgeServerRuntime` to check +managed quartz in a real Nether. Separate build-owned worlds exercise Forge +28's shipped netherrack tag and explicit block hosts. Each world is reloaded, +then given a different depth range to check that existing chunks stay unchanged. + +## Trying shared ores in the editor + +Run `genEclipseRuns verifyEclipseProductionClasspath`, refresh the Eclipse +project, then select **runOreSourcesClient**. You can also start it with +`gradlew runOreSourcesClient`. It uses `run-ore-sources`, separate from the +normal client's worlds and configuration, and keeps your test worlds between +launches. + +The profile loads two dummy providers named Future Base Minerals Test and +Future Electric Advantage Test. They stand in for future MMD ports, not real +releases. Their shared Sulfur group uses blue, yellow and green wool as test +ore blocks. Open OreSpawn's world settings, then **Rocks & Ores → Ore Sources**, +to try the output modes, weights and placement choices. One Electric Advantage +output has no active rule of its own; consolidated modes can still select it +using the group's placement rule. + +These dummy providers appear only in this named client and the automated +ore-source checks. They do not enter ordinary Eclipse launches or release jars. +The manual profile has no automatic world creation or shutdown probe. diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..0aedaa2f --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,392 @@ +def surfaceIntegrationClasses = project.ext.oreSpawnBuild.surfaceIntegrationClasses +def prepareEclipseResources = project.ext.oreSpawnBuild.prepareEclipseResources + +// Use Eclipse's generated arguments so the Gradle launch finds Forge 28's assets and native libraries. +project.ext.oreSpawnBuild.oreSourcesClientLaunch = { + def xml = new groovy.xml.XmlParser(false, false).parse(file('runOreSourcesClient.launch')) + def attribute = { String key -> xml.stringAttribute.find { it.@key == key }.@value } + Map environment = [:] + xml.mapAttribute.find { + it.@key == 'org.eclipse.debug.core.environmentVariables' + }.mapEntry.each { environment[it.@key.toString()] = it.@value.toString() } + List archives = [] + xml.listAttribute.find { + it.@key == 'org.eclipse.jdt.launching.CLASSPATH' + }.listEntry.each { entry -> + def runtime = new groovy.xml.XmlParser(false, false).parseText(entry.@value.toString()) + if (runtime.@externalArchive) archives.add(file(runtime.@externalArchive.toString())) + } + [mainClass: attribute('org.eclipse.jdt.launching.MAIN_TYPE'), + arguments: org.apache.tools.ant.types.Commandline.translateCommandline( + attribute('org.eclipse.jdt.launching.PROGRAM_ARGUMENTS')) as List, + environment: environment, archives: archives] +} + +task syncForge28EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { + group = 'ide' + dependsOn 'prepareOreSourcesClient' + doLast { + String mainClass = 'net.minecraftforge.userdev.LaunchTesting' + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String ordinaryModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}${mod_id}%%${mainOutput}" + String fixtureOutput = surfaceIntegrationClasses.absolutePath + String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath + String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}${mod_id}%%${mainOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureOutput}${File.pathSeparator}surfaceprobe%%${fixtureResources}" + def environmentEntries = { String launchTarget -> + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + } + ['Client', 'Server', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + if (text.contains('')) { + String launchTarget = [Client: 'fmluserdevclient', Server: 'fmluserdevserver', + Data: 'fmluserdevdata'][runName] + text = text.replace( + '', + '\r\n' + + environmentEntries(launchTarget) + + " \r\n" + + '') + } + text = text.replace( + '', + "") + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('key="target"')) { + String launchTarget = [Client: 'fmluserdevclient', Server: 'fmluserdevserver', + Data: 'fmluserdevdata'][runName] + text = text.replace(' ', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') + } + ['Fresh', 'Reload'].each { String phase -> + File launch = file("runSurfaceIntegration${phase}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + text = text.replace( + '', + "") + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('key="target"')) { + text = text.replace(' + def entry = new Node(null, 'runtimeClasspathEntry', [ + externalArchive: output.absolutePath, type: '2', path: '5']) + classpath.appendNode('listEntry', [value: groovy.xml.XmlUtil.serialize(entry)]) + } + file('runOreSourcesClient.launch').setText( + groovy.xml.XmlUtil.serialize(manualLaunch).replace('\r\n', '\n'), 'UTF-8') + } +} + +task verifyForge28EclipseLaunchIsolation { + group = 'verification' + doLast { + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> + File launch = file(launchName) + String launchText = launch.getText('UTF-8') + if (launchText.contains('Mineralogy-') || + !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") + } + } + } +} + +syncForge28EclipseLaunches.finalizedBy verifyForge28EclipseLaunchIsolation + +tasks.matching { it.name == 'genEclipseRuns' }.all { + finalizedBy syncForge28EclipseLaunches +} + +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true +// Let Gradle expand mods.toml first; otherwise Eclipse overwrites bin/main's version with ${version}. + file.whenMerged { classpath -> + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + } + } + synchronizationTasks 'isolateEclipseProductionRuns' +} +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + [ + 'eclipse.preferences.version' : '1', + 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir' : '', + 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings' : 'true' + ].each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by OreSpawn Buildship configuration.') + } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn tasks.named('genEclipseRuns') + dependsOn syncForge28EclipseLaunches + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + doLast { + [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' + ].each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) + && !launch.delete()) { + throw new GradleException("Could not remove obsolete launch ${name}") + } + } + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + String contents = launch.getText('UTF-8') + contents = contents.replace( + 'key="MC_VERSION" value="${MC_VERSION}"', + "key=\"MC_VERSION\" value=\"${minecraft_version}\"") + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipseClasspath') + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + dependsOn 'verifyOreSourcesClientLaunch' + dependsOn 'eclipseResourceRebuildTest' + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties buildshipPreferences = new Properties() + prefs.withInputStream { buildshipPreferences.load(it) } + String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath + if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException( + "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") + } + Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration + .resolvedArtifacts + .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } + .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } + .toSet() + if (!legacyLwjglArtifacts.isEmpty()) { + throw new GradleException( + "Forge 1.14 Eclipse classpath contains sealed LWJGL 2 artifacts: ${legacyLwjglArtifacts}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) throw new GradleException('Eclipse .classpath was not generated') + String classpathText = eclipseClasspath.getText('UTF-8') + if (!classpathText.contains('path="build/resources/main"') + || classpathText.contains('path="src/main/resources"') + || classpathText.contains('path="src/generated/resources"')) { + throw new GradleException('Eclipse must consume only Gradle-processed production resources') + } + [ + 'META-INF/mods.toml', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File processedMetadata = file("${buildDir}/resources/main/META-INF/mods.toml") + File eclipseMetadata = file('bin/main/META-INF/mods.toml') + if (!java.util.Arrays.equals(processedMetadata.bytes, eclipseMetadata.bytes) + || eclipseMetadata.getText('UTF-8').contains('${version}') + || !eclipseMetadata.getText('UTF-8').contains("version=\"${project.version}\"")) { + throw new GradleException('Eclipse output contains unexpanded or stale Forge mod metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', + 'oreSourceMineralsTest', 'oreSourceAdvantageTest', 'worldHashTest', + 'nativeOreIntegrationTest', 'nativeoreprobe', 'native-ore-fixture', + 'surfaceprobe', 'clientprobe', 'worldhashprobe', 'oresource-minerals', + 'oresource-advantage', 'junit-', 'opentest4j-', + 'Mineralogy-1.14.4-5.0.1.jar', 'C:\\Users\\John' + ] + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String expectedModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}${mod_id}%%${mainOutput}" + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Missing ${name}") + String contents = launch.getText('UTF-8') + List leaked = forbidden.findAll { contents.contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${name} exposes test/local content: ${leaked}") + } + if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${name} is not a production-only OreSpawn launch") + } + if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { + throw new GradleException("${name} lacks Forge 28 merged output discovery") + } + } + } +} + +// A Java rebuild copies resources from the declared source folders again. +// Check that step too: copying processed files into bin/main once is not enough. +tasks.register('eclipseResourceRebuildTest') { + group = 'verification' + description = 'Checks an Eclipse resource rebuild retains expanded Forge metadata and bundled guides.' + dependsOn tasks.named('eclipseClasspath'), tasks.named('processResources') + doLast { + def xml = new groovy.xml.XmlParser(false, false).parse(file('.classpath')) + List resourceFolders = xml.classpathentry.findAll { entry -> + entry.@kind == 'src' && entry.@output == 'bin/main' + && entry.@path != 'src/main/java' + }.collect { it.@path.toString() } + File rebuilt = file("${buildDir}/eclipse-resource-rebuild-test") + project.sync { + resourceFolders.each { from file(it) } + into rebuilt + } + File metadata = new File(rebuilt, 'META-INF/mods.toml') + File processed = file("${buildDir}/resources/main/META-INF/mods.toml") + if (!metadata.isFile() || metadata.getText('UTF-8').contains('${version}') + || !java.util.Arrays.equals(metadata.bytes, processed.bytes)) { + throw new GradleException('Eclipse rebuild copied unexpanded or stale Forge mod metadata') + } + if (resourceFolders != ['build/resources/main']) { + throw new GradleException("Eclipse must rebuild from processed resources only: ${resourceFolders}") + } + ['README.md', 'VERSIONS.md'].each { name -> + if (!new File(rebuilt, "META-INF/orespawn/docs/${name}").isFile()) { + throw new GradleException("Eclipse rebuild lost the bundled guide ${name}") + } + } + } +} + +tasks.named('check') { dependsOn tasks.named('eclipseResourceRebuildTest') } + +tasks.register('verifyOreSourcesClientLaunch') { + group = 'verification' + description = 'Checks the manual ore-source profile stays separate from production and automated probes.' + dependsOn tasks.named('isolateEclipseProductionRuns') + doLast { + File launch = file('runOreSourcesClient.launch') + def xml = new groovy.xml.XmlParser(false, false).parse(launch) + String workingDirectory = xml.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value + if (workingDirectory != file('run-ore-sources').absolutePath) { + throw new GradleException('Manual ore-source client must use run-ore-sources') + } + String modClasses = xml.mapAttribute.find { + it.@key == 'org.eclipse.debug.core.environmentVariables' + }.mapEntry.find { it.@key == 'MOD_CLASSES' }.@value + def shared = project.ext.oreSpawnBuild + if (modClasses != shared.oreSourcesClientModClasses(file('bin/main'))) { + throw new GradleException('Manual ore-source client is missing its isolated dummy providers') + } + String contents = launch.getText('UTF-8') + ['bin/test', 'src/test', 'junit-', 'clientprobe', 'surfaceprobe', + 'oresourceprobe.phase', 'clientprobe.enabled'].each { forbidden -> + if (contents.contains(forbidden)) { + throw new GradleException("Manual ore-source client leaked ${forbidden}") + } + } + shared.oreSourceFixtureOutputs.values().each { File output -> + if (!new File(output, 'META-INF/mods.toml').isFile() + || !shared.oreSourcesClientLaunch().archives.contains(output)) { + throw new GradleException("Manual ore-source client is missing fixture output ${output}") + } + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' + doLast { + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('gradle') { include '**/*.gradle' }.files) + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + String gradleSource = commandSources.findAll { it.name.endsWith('.gradle') } + .collect { it.getText('UTF-8') }.join('\n') + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') + || !gradleSource.contains('join(File.pathSeparator)') + || !gradleSource.contains('${File.pathSeparator}')) { + throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') + } + } +} + +tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } +tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..4bcb4a80 --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,390 @@ +import groovy.json.JsonSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest + +// Build the jars we publish, then inspect their contents and checksums. The +// test mods and their worlds must never make it into these files. +import java.util.zip.ZipFile + +def shared = project.ext.oreSpawnBuild +def expectedMavenGroup = shared.mavenGroup +def expectedMavenArtifact = shared.mavenArtifact +def expectedMavenCoordinate = shared.mavenCoordinate +def archiveTextSuffixes = shared.archiveTextSuffixes +def archiveTextPatterns = shared.archiveTextPatterns +def normalizeArchiveLineEndings = shared.normalizeArchiveLineEndings +def bundledDocumentationDirectory = shared.bundledDocumentationDirectory +def prepareBundledDocumentation = shared.prepareBundledDocumentation +def prepareEclipseResources = shared.prepareEclipseResources +def packagedSurfaceProbeJar = renamer.classes(tasks.named('surfaceIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') +} + +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + manifest { + attributes([ + 'Specification-Title' : 'OreSpawn', + 'Specification-Vendor' : 'SkyBlade1978', + 'Specification-Version' : '1', + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, + 'Implementation-Vendor' : 'SkyBlade1978', + 'OreSpawn-API-Version' : '1', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : expectedMavenCoordinate, + 'Built-On-Java' : '8', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}" + ]) + } +} + +def releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null + accessTransformers = true + output = layout.buildDirectory.file( + "libs/${base.archivesName.get()}-${project.version}.jar") +} + +tasks.named('sourcesJar', Jar) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-sources', + 'Implementation-Version': project.version + ]) + } +} +tasks.named('javadocJar', Jar) { + filteringCharset = 'UTF-8' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-javadoc', + 'Implementation-Version': project.version + ]) + } +} +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { artifacts.clear() } + artifacts { add(configurationName, releaseJar) } +} +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.1.0.114041' + || project.mod_group != expectedMavenGroup + || project.minecraft_version != '1.14.4' + || project.forge_version != '28.2.26' + || project.mapping_channel != 'snapshot' + || project.mapping_version != '20190719-1.14.3') { + throw new GradleException('Unexpected OreSpawn 1.14.4 release identity') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '8' || project.gradle_java_version != '17' + || project.java_toolchain_version != '8.0.502+7') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.1.0.114041.jar', + 'OreSpawn-4.1.0.114041-sources.jar', + 'OreSpawn-4.1.0.114041-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.1.0.114041')) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') + .getText('UTF-8').contains('API_VERSION = 1')) { + throw new GradleException('OreSpawn API major must remain 1') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 8') + || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 7')) { + throw new GradleException('Global/world schemas must remain 6/5') + } + def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(schema.properties.schema_version.enum as List).contains(5)) { + throw new GradleException('Provider schema must remain version 4') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') } + .sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes + byte[] candidate = new File(root, relative).bytes + if (!java.util.Arrays.equals(tracked, candidate)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 20 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 20 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, + expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), + expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().output.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") + } + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + // Convert interpolated GStrings to Strings before comparing them with filesystem names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException( + "${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'src/oreSourceMineralsTest/', 'src/oreSourceAdvantageTest/', 'src/worldHashTest/', + 'agent-notes/', 'surfaceprobe', 'clientprobe', 'worldhashprobe', + 'nativeoreprobe', 'native-ore-fixture', 'nativeOreIntegrationTest', + 'oresource-minerals', 'oresource-advantage', 'ci-fixtures/', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.14.4-5.0.1.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[1.14.4]"')) { + throw new GradleException('Packaged Forge metadata is incorrect') + } + String transformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.gen.ChunkGenerator field_222542_c', + 'public-f net.minecraft.world.gen.NoiseChunkGenerator field_222560_g', + 'public net.minecraft.world.biome.Biome field_201874_aj' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged SRG access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") + } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 52) { + throw new GradleException("${entry.name} uses class major ${major}, expected 52") + } + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> + if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> + if (zip.getEntry('index.html') == null + || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { + throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file(outputFile) + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required') + } + File prepared = file(preparedReleaseDir.get()) + // Convert interpolated GStrings to Strings before comparing them with filesystem names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List actualNames = jars.collect { it.name.toString() }.sort() + if (actualNames != expected) { + throw new GradleException( + "Prepared release jars ${actualNames} do not match ${expected}") + } + if (jars.any { it.length() == 0L }) { + throw new GradleException('Prepared release contains an empty jar') + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + List actual = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" + }.sort() + if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { + throw new GradleException('Prepared release checksums do not match') + } + } +} + + +project.ext.oreSpawnBuild.packagedSurfaceProbeJar = packagedSurfaceProbeJar +project.ext.oreSpawnBuild.releaseJar = releaseJar +project.ext.oreSpawnBuild.expectedReleaseFiles = expectedReleaseFiles +project.ext.oreSpawnBuild.preparedReleaseDir = preparedReleaseDir +project.ext.oreSpawnBuild.documentationFiles = documentationFiles +project.ext.oreSpawnBuild.assertDocumentationTree = assertDocumentationTree diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..d911b18e --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,96 @@ +import groovy.xml.XmlSlurper + +// Publishing stays separate from assembling jars, so local verification never +// needs credentials and cannot upload anything by accident. + +def shared = project.ext.oreSpawnBuild +def expectedMavenGroup = shared.mavenGroup +def expectedMavenArtifact = shared.mavenArtifact +def releaseJar = shared.releaseJar +def expectedReleaseFiles = shared.expectedReleaseFiles +def preparedReleaseDir = shared.preparedReleaseDir + +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = expectedMavenGroup + artifactId = expectedMavenArtifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } + } else { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} +tasks.register('verifyMavenCoordinates') { + group = 'verification' + description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + if (!pomFile.isFile()) { + throw new GradleException("Generated Maven POM does not exist: ${pomFile}") + } + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, + project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + + "found ${actual.join(':')}") + } + } +} +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} diff --git a/gradle/verification/client-and-benchmark.gradle b/gradle/verification/client-and-benchmark.gradle new file mode 100644 index 00000000..899c13bc --- /dev/null +++ b/gradle/verification/client-and-benchmark.gradle @@ -0,0 +1,152 @@ +// Keep development probes separate from the mod people install. The server +// runs here exercise new terrain and then reload the exact same save. +def java8Launcher = project.ext.oreSpawnBuild.java8Launcher +def assertRuntimeLogsClean = project.ext.oreSpawnBuild.assertRuntimeLogsClean + +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + options.encoding = 'UTF-8' +} +tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn tasks.named('compileClientIntegrationTestMod') + archiveFileName = 'clientprobe.jar' + destinationDirectory = file("${buildDir}/client-integration-fixture") + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} +def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') +} + +def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") +task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/biomeIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = surfaceIntegrationClasses + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.encoding = 'UTF-8' +} + +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' +} + +def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") +task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { + doLast { + delete surfaceIntegrationRunDirectory + surfaceIntegrationRunDirectory.mkdirs() + copy { + from surfaceIntegrationTestModJar.archiveFile + into new File(surfaceIntegrationRunDirectory, 'mods') + } + new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { + dependsOn prepareSurfaceIntegrationTest +} + +def createSurfaceProcess = { String phase, Object dependency -> + task("surfaceIntegration${phase}Process", type: Exec, dependsOn: dependency) { + group = 'verification' + dependsOn { + JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec + runTask.taskDependencies.getDependencies(runTask) + } + doFirst { + JavaExec runTask = tasks.getByName("runSurfaceIntegration${phase}") as JavaExec + File classpathJar = file("${buildDir}/surface-integration-fixture/${phase.toLowerCase()}-classpath.jar") + classpathJar.parentFile.mkdirs() + java.util.jar.Manifest manifest = new java.util.jar.Manifest() + manifest.mainAttributes.put(java.util.jar.Attributes.Name.MANIFEST_VERSION, '1.0') + manifest.mainAttributes.put(java.util.jar.Attributes.Name.CLASS_PATH, + runTask.classpath.files.collect { it.toURI().toASCIIString() }.join(' ')) + classpathJar.withOutputStream { output -> + new java.util.jar.JarOutputStream(output, manifest).close() + } + List arguments = [] + arguments.addAll(runTask.allJvmArgs) + int classpathFlag = arguments.lastIndexOf('-cp') + if (classpathFlag < 0) { + classpathFlag = arguments.lastIndexOf('-classpath') + } + if (classpathFlag >= 0 && classpathFlag + 1 < arguments.size()) { + arguments.remove(classpathFlag + 1) + arguments.remove(classpathFlag) + } + arguments.add("-Dsurfaceprobe.integrationPhase=${phase.toLowerCase()}") + arguments.add('-cp') + arguments.add(classpathJar.absolutePath) + arguments.add(runTask.mainClass.orNull ?: 'net.minecraftforge.userdev.LaunchTesting') + arguments.addAll(runTask.args) + // ForgeGradle defaults to the project directory. Keep test worlds in this disposable directory. + workingDir surfaceIntegrationRunDirectory + environment runTask.environment + // Forge 28 and grossjava9hacks need Java 8; only Gradle and ForgeGradle run on Java 17. + File javaExecutable = java8Launcher.get().executablePath.asFile + // Pass arguments directly so this also works on Linux, where cmd.exe is unavailable. + commandLine(([javaExecutable.absolutePath] + arguments) as List) + } + } +} + +def surfaceIntegrationFreshProcess = createSurfaceProcess('Fresh', prepareSurfaceIntegrationTest) +surfaceIntegrationFreshProcess.doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") + } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration fresh phase', [] as Set) +} +def surfaceIntegrationReloadProcess = createSurfaceProcess('Reload', surfaceIntegrationFreshProcess) +surfaceIntegrationReloadProcess.doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration reload phase', [] as Set) +} + +task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { + group = 'verification' + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("Surface integration reload was not verified: ${marker}") + } + logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +check.dependsOn surfaceIntegrationTest + + +project.ext.oreSpawnBuild.packagedClientProbeJar = packagedClientProbeJar +project.ext.oreSpawnBuild.surfaceIntegrationClasses = surfaceIntegrationClasses diff --git a/gradle/verification/native-ores.gradle b/gradle/verification/native-ores.gradle new file mode 100644 index 00000000..7f6ea204 --- /dev/null +++ b/gradle/verification/native-ores.gradle @@ -0,0 +1,110 @@ +// A real Nether checks the shipped host tag and explicit block hosts. +// The fixture is compiled separately and never joins a production classpath. +def shared = project.ext.oreSpawnBuild +def classes = layout.buildDirectory.dir('native-ore-fixture/classes') +def compile = tasks.register('compileNativeOreProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/nativeOreIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = classes + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + options.encoding = 'UTF-8' +} +def jar = tasks.register('nativeOreProbeJar', Jar) { + dependsOn compile + archiveFileName = 'nativeoreprobe.jar' + destinationDirectory = layout.buildDirectory.dir('native-ore-fixture') + from classes + from 'src/nativeOreIntegrationTest/resources' +} +def packagedJar = renamer.classes(jar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('native-ore-fixture/nativeoreprobe-reobf.jar') +} +def serverRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def finalTasks = [] +['Default', 'Explicit'].each { scenario -> + File runDirectory = file("${buildDir}/native-ore-${scenario.toLowerCase()}-run") + def prepare = tasks.register("prepareNativeOre${scenario}") { + dependsOn shared.releaseJar, packagedJar + doLast { + // This directory is build-owned; no manual saves are used here. + delete runDirectory + runDirectory.mkdirs() + copy { from shared.releaseJar; from packagedJar; into new File(runDirectory, 'mods') } + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText('''\ +level-name=native-ore-world +level-seed=4192643144957839458 +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + } + } + def previous = prepare + ['Fresh', 'Reload', 'Edited'].each { phase -> + def dependency = previous + previous = tasks.register("nativeOre${scenario}${phase}", Exec) { + group = 'verification' + description = "Checks managed quartz with ${scenario.toLowerCase()} hosts (${phase.toLowerCase()})." + dependsOn dependency + doFirst { + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + File launcher = new File(file(serverRuntime.get()), 'forge-1.14.4-28.2.26.jar') + if (!launcher.isFile()) throw new GradleException("Missing official Forge launcher: ${launcher}") + workingDir runDirectory + commandLine shared.java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', "-Dnativeoreprobe.phase=${phase.toLowerCase()}", + "-Dnativeoreprobe.host=${scenario.toLowerCase()}", '-jar', launcher.absolutePath, 'nogui' + } + doLast { + Properties result = new Properties() + File marker = new File(runDirectory, 'native-ore-world/nativeoreprobe.properties') + if (!marker.isFile()) throw new GradleException("Quartz audit is missing: ${marker}") + marker.withInputStream { result.load(it) } + if (result.getProperty("${phase.toLowerCase()}_verified") != 'true') { + throw new GradleException("Quartz ${phase.toLowerCase()} was not verified") + } + shared.assertRuntimeLogsClean(runDirectory, "Native quartz ${scenario} ${phase}", [] as Set) + if (new File(runDirectory, 'logs/latest.log').getText('UTF-8') + .contains("Ignoring invalid placement rule for OreSpawn-managed ore 'minecraft:nether_quartz_ore'")) { + throw new GradleException('Managed quartz was rejected during startup') + } + copy { from new File(runDirectory, 'logs/latest.log'); into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.log" } } + copy { from marker; into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.properties" } } + } + } + } + finalTasks.add(previous) +} +tasks.register('nativeOreIntegrationTest') { + group = 'verification' + description = 'Checks quartz defaults, explicit hosts, exact-save reload and new-terrain-only edits.' + dependsOn finalTasks + doLast { + ['fresh', 'reload', 'edited'].each { phase -> + Properties tagged = new Properties() + Properties explicit = new Properties() + file("${buildDir}/native-ore-default-run/audit-logs/${phase}.properties") + .withInputStream { tagged.load(it) } + file("${buildDir}/native-ore-explicit-run/audit-logs/${phase}.properties") + .withInputStream { explicit.load(it) } + ['quartz', 'blocks_sha256', 'biomes_sha256', 'new_terrain_quartz'].each { key -> + if (tagged.getProperty(key) != explicit.getProperty(key)) { + throw new GradleException("Tag and explicit quartz hosts differ for ${phase}: ${key}") + } + } + } + logger.lifecycle('Tag and explicit quartz hosts produce identical terrain, fresh/reload/edited') + } +} diff --git a/gradle/verification/ore-sources.gradle b/gradle/verification/ore-sources.gradle new file mode 100644 index 00000000..ffa5c1d7 --- /dev/null +++ b/gradle/verification/ore-sources.gradle @@ -0,0 +1,211 @@ +// These dummy mods stand in for future MMD providers. +// Keep them outside main so they cannot enter OreSpawn or its published API jar. +def shared = project.ext.oreSpawnBuild +def fixtureClasses = [:] +def fixtureJars = [:] +def fixtureOutputs = [:] +def prepareFixtureOutputs = [] +['Minerals': 'oreSourceMineralsTest', 'Advantage': 'oreSourceAdvantageTest'].each { label, folder -> + File classes = file("${buildDir}/ore-source-fixture/${label.toLowerCase()}-classes") + def compile = tasks.register("compileOreSource${label}Fixture", JavaCompile) { + dependsOn tasks.named('classes') + source fileTree("src/${folder}/java") + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = classes + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + options.encoding = 'UTF-8' + } + def jar = tasks.register("oreSource${label}FixtureJar", Jar) { + dependsOn compile + archiveFileName = "oresource-${label.toLowerCase()}.jar" + destinationDirectory = file("${buildDir}/ore-source-fixture") + from classes + from "src/${folder}/resources" + } + fixtureClasses[label] = classes + File clientOutput = file("${buildDir}/ore-source-fixture/${label.toLowerCase()}-mod") + prepareFixtureOutputs.add(tasks.register("prepareOreSource${label}Client", Sync) { + dependsOn compile + from classes + from "src/${folder}/resources" + into clientOutput + }) + fixtureOutputs[label] = clientOutput + fixtureJars[label] = renamer.classes(jar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file("ore-source-fixture/oresource-${label.toLowerCase()}-reobf.jar") + } +} + +// Keep earlier manual test worlds and settings when preparing this launch. +File clientDirectory = file('run-ore-sources') +def clientModClasses = { File mainOutput -> + List entries = ["${mod_id}%%${mainOutput}", "${mod_id}%%${mainOutput}"] + ['Minerals': 'baseminerals', 'Advantage': 'electricadvantage'].each { label, id -> + // Forge 28 needs the same merged output for resource and class discovery. + entries.add("${id}%%${fixtureOutputs[label]}") + entries.add("${id}%%${fixtureOutputs[label]}") + } + entries.join(File.pathSeparator) +} +shared.oreSourceFixtureOutputs = fixtureOutputs +shared.oreSourcesClientModClasses = clientModClasses + +tasks.register('prepareOreSourcesClient') { + group = 'ide' + description = 'Prepares two dummy ore providers without clearing the manual test directory.' + dependsOn prepareFixtureOutputs + doLast { new File(clientDirectory, 'mods').mkdirs() } +} + +tasks.matching { it.name == 'runOreSourcesClient' }.all { JavaExec runTask -> + group = 'forgegradle runs' + description = 'Opens a separate client with simulated Base Minerals and Electric Advantage ores.' + dependsOn tasks.named('prepareOreSourcesClient') + dependsOn tasks.named('verifyOreSourcesClientLaunch') + classpath += files(fixtureOutputs.values()) + javaLauncher = shared.java8Launcher + workingDir clientDirectory + environment 'MOD_CLASSES', clientModClasses(file("${buildDir}/forge-run-mod-classes/main")) + doFirst { + def launch = shared.oreSourcesClientLaunch() + mainClass = launch.mainClass + args = launch.arguments + classpath += files(launch.archives) + environment launch.environment + environment 'MOD_CLASSES', clientModClasses(file("${buildDir}/forge-run-mod-classes/main")) + workingDir clientDirectory + File crashes = new File(clientDirectory, 'crash-reports') + runTask.ext.priorCrashes = crashes.isDirectory() + ? fileTree(crashes).files.collect { it.absolutePath } as Set : [] as Set + } + doLast { + shared.assertRuntimeLogsClean(clientDirectory, 'ore sources manual client', runTask.ext.priorCrashes) + } +} + +def serverRuntime = providers.gradleProperty('packagedForgeServerRuntime') +['One': ['Minerals'], 'Both': ['Minerals', 'Advantage']].each { scenario, labels -> + File runDirectory = file("${buildDir}/ore-source-${scenario.toLowerCase()}-run") + def prepare = tasks.register("prepareOreSource${scenario}") { + dependsOn shared.releaseJar + labels.each { dependsOn fixtureJars[it] } + doLast { + delete runDirectory + runDirectory.mkdirs() + copy { + from shared.releaseJar + labels.each { from fixtureJars[it] } + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=ore-source-world +level-seed=49110 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + def fresh = tasks.register("oreSource${scenario}Fresh", Exec) { + group = 'verification' + dependsOn prepare + doFirst { + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + File launcher = new File(file(serverRuntime.get()), 'forge-1.14.4-28.2.26.jar') + if (!launcher.isFile()) throw new GradleException("Missing official Forge launcher: ${launcher}") + workingDir runDirectory + commandLine shared.java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Doresourceprobe.phase=fresh', + '-jar', launcher.absolutePath, 'nogui' + } + doLast { + File marker = new File(runDirectory, 'ore-source-world/oresourceprobe.properties') + if (!marker.isFile()) throw new GradleException("Fresh ore-source marker is missing: ${marker}") + shared.assertRuntimeLogsClean(runDirectory, "${scenario} ore-source fresh", [] as Set) + } + } + def reload = tasks.register("oreSource${scenario}Reload", Exec) { + group = 'verification' + dependsOn fresh + doFirst { + File launcher = new File(file(serverRuntime.get()), 'forge-1.14.4-28.2.26.jar') + workingDir runDirectory + commandLine shared.java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Doresourceprobe.phase=reload', + '-jar', launcher.absolutePath, 'nogui' + } + doLast { + File marker = new File(runDirectory, 'ore-source-world/oresourceprobe.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("${scenario} ore-source reload did not verify") + } + shared.assertRuntimeLogsClean(runDirectory, "${scenario} ore-source reload", [] as Set) + } + } + tasks.register("oreSource${scenario}IntegrationTest") { + group = 'verification' + dependsOn reload + } +} + +File bothRun = file("${buildDir}/ore-source-both-run") +File advantageJar = new File(bothRun, 'mods/oresource-advantage-reobf.jar') +File parkedJar = new File(bothRun, 'oresource-advantage-reobf.jar') +def missing = tasks.register('oreSourceBothMissing', Exec) { + group = 'verification' + dependsOn tasks.named('oreSourceBothReload') + doFirst { + java.nio.file.Files.move(advantageJar.toPath(), parkedJar.toPath()) + File launcher = new File(file(serverRuntime.get()), 'forge-1.14.4-28.2.26.jar') + workingDir bothRun + commandLine shared.java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Doresourceprobe.phase=missing', + '-jar', launcher.absolutePath, 'nogui' + } + doLast { + Properties result = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { result.load(it) } + if (result.getProperty('missing_verified') != 'true') { + throw new GradleException('Missing-provider reload did not verify') + } + shared.assertRuntimeLogsClean(bothRun, 'Both ore-source provider missing', [] as Set, + ['This world was saved with mod electricadvantage which appears to be missing']) + } +} +tasks.register('oreSourceBothRestored', Exec) { + group = 'verification' + dependsOn missing + doFirst { + java.nio.file.Files.move(parkedJar.toPath(), advantageJar.toPath()) + File launcher = new File(file(serverRuntime.get()), 'forge-1.14.4-28.2.26.jar') + workingDir bothRun + commandLine shared.java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Doresourceprobe.phase=restored', + '-jar', launcher.absolutePath, 'nogui' + } + doLast { + Properties result = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { result.load(it) } + if (result.getProperty('restored_verified') != 'true') { + throw new GradleException('Restored-provider reload did not verify') + } + shared.assertRuntimeLogsClean(bothRun, 'Both ore-source provider restored', [] as Set) + } +} + +tasks.register('oreSourceFixtureIntegrationTest') { + group = 'verification' + description = 'Checks one and two future MMD providers, then removes and restores one on the same save.' + dependsOn tasks.named('oreSourceOneIntegrationTest'), tasks.named('oreSourceBothRestored') +} diff --git a/gradle/verification/packaged-forge.gradle b/gradle/verification/packaged-forge.gradle new file mode 100644 index 00000000..7656bcfa --- /dev/null +++ b/gradle/verification/packaged-forge.gradle @@ -0,0 +1,224 @@ +// These checks launch the finished jar with an official Forge installation. +// A development run can pass while the jar users receive still fails. +def shared = project.ext.oreSpawnBuild +def java8Launcher = shared.java8Launcher +def assertRuntimeLogsClean = shared.assertRuntimeLogsClean +def releaseJar = shared.releaseJar +def packagedSurfaceProbeJar = shared.packagedSurfaceProbeJar +def packagedClientProbeJar = shared.packagedClientProbeJar +def documentationFiles = shared.documentationFiles +def assertDocumentationTree = shared.assertDocumentationTree + +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') + +def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> + if (!configuredPath.isPresent()) { + throw new GradleException("Pass -P${propertyName}=") + } + File runtime = file(configuredPath.get()) + if (!runtime.isDirectory()) { + throw new GradleException("${propertyName} does not name a directory: ${runtime}") + } + runtime +} + +def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") +tasks.register('preparePackagedSurfaceIntegration') { + dependsOn releaseJar + dependsOn packagedSurfaceProbeJar + doLast { + delete packagedSurfaceRunDirectory + packagedSurfaceRunDirectory.mkdirs() + copy { + from releaseJar + from packagedSurfaceProbeJar + into new File(packagedSurfaceRunDirectory, 'mods') + } + new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} +def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedSurfaceIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File launcher = new File(runtime, 'forge-1.14.4-28.2.26.jar') + File vanillaServer = new File(runtime, 'minecraft_server.1.14.4.jar') + File libraries = new File(runtime, 'libraries') + [launcher, vanillaServer, libraries].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + workingDir packagedSurfaceRunDirectory + commandLine java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', + '-jar', launcher.absolutePath, 'nogui' + } + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface fresh', [] as Set) + } +} +def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { + group = 'verification' + dependsOn packagedSurfaceFresh + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File launcher = new File(runtime, 'forge-1.14.4-28.2.26.jar') + workingDir packagedSurfaceRunDirectory + commandLine java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', + '-jar', launcher.absolutePath, 'nogui' + } + doLast { + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface reload', [] as Set) + } +} +tasks.register('packagedSurfaceIntegrationTest') { + group = 'verification' + dependsOn packagedSurfaceReload + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty('reload_verified') != 'true') { + throw new GradleException('Packaged surface reload was not verified') + } + } +} + +def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") +tasks.register('preparePackagedClientIntegration') { + dependsOn releaseJar + dependsOn packagedClientProbeJar + doLast { + delete packagedClientRunDirectory + packagedClientRunDirectory.mkdirs() + copy { + from releaseJar + from packagedClientProbeJar + into new File(packagedClientRunDirectory, 'mods') + } + new File(packagedClientRunDirectory, 'options.txt').setText( + 'fullscreen:false\nlang:en_us\n', 'UTF-8') + } +} +def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedClientIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, + 'packagedForgeClientRuntime') + File forgeJsonFile = new File(runtime, + 'versions/1.14.4-forge-28.2.26/1.14.4-forge-28.2.26.json') + File baseJsonFile = new File(runtime, 'versions/1.14.4/1.14.4.json') + File baseJar = new File(runtime, 'versions/1.14.4/1.14.4.jar') + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), new File(runtime, 'natives/forge-28.2.26')].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } + } + if (!allowed) return + String relative = (String) ((Map) ((Map) library.downloads).artifact).path + File artifact = new File(runtime, "libraries/${relative}") + if (!artifact.isFile()) { + throw new GradleException("Missing official client library: ${artifact}") + } + List coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + classpathByModule.put(module, artifact) + } + } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + + List gameArguments = [] + gameArguments.addAll((List) ((Map) forgeJson.arguments).game) + gameArguments.addAll([ + '--username', 'OreSpawnValidation', + '--version', (String) forgeJson.id, + '--gameDir', packagedClientRunDirectory.absolutePath, + '--assetsDir', new File(runtime, 'assets').absolutePath, + '--assetIndex', (String) ((Map) baseJson.assetIndex).id, + '--uuid', '00000000-0000-0000-0000-000000000001', + '--accessToken', 'validation-token', + '--userType', 'legacy', + '--versionType', 'release', + '--width', '854', '--height', '480' + ]) + workingDir packagedClientRunDirectory + commandLine java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', + "-Djava.library.path=${new File(runtime, 'natives/forge-28.2.26').absolutePath}", + '-Dminecraft.launcher.brand=orespawn-validation', + '-Dminecraft.launcher.version=1', + '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass + args gameArguments + } +} +tasks.register('packagedClientIntegrationTest') { + group = 'verification' + dependsOn packagedClientProcess + doLast { + File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException('Packaged client completion marker is missing') + } + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Packaged client failed ${key}: ${values}") + } + } + assertDocumentationTree(new File(packagedClientRunDirectory, + 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') + assertRuntimeLogsClean(packagedClientRunDirectory, + 'packaged client', [] as Set) + } +} + +tasks.register('packagedRuntimeIntegrationTest') { + group = 'verification' + description = 'Runs the exact reobfuscated jars in official Forge 28 server and client runtimes.' + dependsOn tasks.named('packagedSurfaceIntegrationTest') + dependsOn tasks.named('packagedClientIntegrationTest') +} diff --git a/gradle/verification/performance.gradle b/gradle/verification/performance.gradle new file mode 100644 index 00000000..f3fb69cb --- /dev/null +++ b/gradle/verification/performance.gradle @@ -0,0 +1,157 @@ +// Each mode gets a fresh world with the same seed and Java runtime. +// Measure separate 81-chunk areas after a short warm-up. +def benchmarkRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def baselineJar = providers.gradleProperty('benchmarkOreSpawnJar') +def repetitions = providers.gradleProperty('benchmarkRepetitions').orElse('3') +def withWorldHash = providers.gradleProperty('benchmarkWorldHash') + .map { it.toBoolean() }.orElse(false).get() +def runLabel = baselineJar.isPresent() ? 'baseline-' : '' +def benchmarkModes = ['vanilla', 'cyano', 'sky'] +def previousRun = null +// Either sibling may be available on a target; keep the benchmark usable when +// only Mineralogy has a finished jar for this Minecraft version. + +def hashClasses = file("${buildDir}/world-hash-fixture/classes") +def compileHash = tasks.register('compileWorldHashProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/worldHashTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = hashClasses + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + options.encoding = 'UTF-8' +} +def hashJar = tasks.register('worldHashProbeJar', Jar) { + dependsOn compileHash + archiveFileName = 'worldhashprobe.jar' + destinationDirectory = file("${buildDir}/world-hash-fixture") + from hashClasses + from 'src/worldHashTest/resources' +} +def packagedHashJar = renamer.classes(hashJar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('world-hash-fixture/worldhashprobe-reobf.jar') +} + +benchmarkModes.each { String mode -> + File runDirectory = file("${buildDir}/worldgen-benchmark-${runLabel}${mode}") + def priorRun = previousRun + def prepare = tasks.register("prepareWorldgenBenchmark${mode.capitalize()}") { + if (!baselineJar.isPresent()) dependsOn project.ext.oreSpawnBuild.releaseJar + if (withWorldHash) dependsOn packagedHashJar + doLast { + delete runDirectory + runDirectory.mkdirs() + copy { + if (baselineJar.isPresent()) { + File oreSpawn = file(baselineJar.get()) + if (!oreSpawn.isFile()) throw new GradleException("Missing baseline jar: ${oreSpawn}") + from oreSpawn + } else { + from project.ext.oreSpawnBuild.releaseJar + } + ['packagedBaseMetalsJar', 'packagedMineralogyJar'].each { property -> + if (providers.gradleProperty(property).isPresent()) { + File sibling = file(providers.gradleProperty(property).get()) + if (!sibling.isFile()) throw new GradleException("Missing ${property}: ${sibling}") + from sibling + } + } + if (withWorldHash) from packagedHashJar + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=benchmark-world +level-seed=-4965128775892001975 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + def run = tasks.register("worldgenBenchmark${mode.capitalize()}", Exec) { + group = 'verification' + dependsOn prepare + if (priorRun != null) dependsOn priorRun + doFirst { + if (!benchmarkRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + File launcher = new File(file(benchmarkRuntime.get()), 'forge-1.14.4-28.2.26.jar') + if (!launcher.isFile()) throw new GradleException("Missing official Forge launcher: ${launcher}") + workingDir runDirectory + commandLine project.ext.oreSpawnBuild.java8Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', "-Dorespawn.worldgenBenchmarkMode=${mode}", + '-Dorespawn.worldgenBenchmarkStopServer=true', + "-Dorespawn.worldgenBenchmarkRepetitions=${repetitions.get()}", + '-jar', launcher.absolutePath, 'nogui' + } + doLast { + File log = new File(runDirectory, 'logs/latest.log') + if (!log.isFile() || !log.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { + throw new GradleException("No completed ${mode} benchmark summary") + } + if (withWorldHash && !new File(runDirectory, + 'benchmark-world/worldhashprobe.properties').isFile()) { + throw new GradleException("No completed ${mode} world hash") + } + project.ext.oreSpawnBuild.assertRuntimeLogsClean(runDirectory, + "${mode} packaged benchmark", [] as Set) + } + } + previousRun = run +} + +tasks.register('packagedWorldgenPerformanceTest') { + group = 'verification' + description = 'Compares vanilla, Cyano and Sky worldgen on matching fresh worlds.' + dependsOn previousRun + doLast { + Map medians = [:] + benchmarkModes.each { String mode -> + File log = file("${buildDir}/worldgen-benchmark-${runLabel}${mode}/logs/latest.log") + def match = log.text =~ /ORESPAWN_BENCHMARK summary mode=${mode} .*median_ms_per_chunk=([0-9.]+)/ + if (!match.find()) throw new GradleException("Missing ${mode} median") + medians[mode] = Double.parseDouble(match.group(1)) + } + ['vanilla', 'cyano'].each { String control -> + double overhead = ((medians.sky / medians[control]) - 1.0D) * 100.0D + logger.lifecycle('Sky worldgen versus {}: {}% ({} versus {} ms/chunk)', + control, String.format(java.util.Locale.ROOT, '%.2f', overhead), + medians.sky, medians[control]) + if (overhead > 25.0D) throw new GradleException( + "Sky worldgen exceeds the 25% ceiling versus ${control}") + if (overhead > 10.0D) logger.warn( + 'Sky worldgen exceeds the 10% investigation threshold versus {}', control) + } + } +} + +tasks.register('verifyWorldgenParity') { + group = 'verification' + description = 'Compares completed chunk and biome hashes from separate 4.0.16 and 4.1 benchmark runs.' + doLast { + benchmarkModes.each { String mode -> + File oldMarker = file("${buildDir}/worldgen-benchmark-baseline-${mode}/benchmark-world/worldhashprobe.properties") + File newMarker = file("${buildDir}/worldgen-benchmark-${mode}/benchmark-world/worldhashprobe.properties") + if (!oldMarker.isFile() || !newMarker.isFile()) { + throw new GradleException("Run both world-hash benchmarks before comparing ${mode}") + } + Properties oldHashes = new Properties() + Properties newHashes = new Properties() + oldMarker.withInputStream { oldHashes.load(it) } + newMarker.withInputStream { newHashes.load(it) } + if (oldHashes.getProperty('chunks') != '81' || newHashes.getProperty('chunks') != '81' + || oldHashes.getProperty('blocks_sha256') != newHashes.getProperty('blocks_sha256') + || oldHashes.getProperty('biomes_sha256') != newHashes.getProperty('biomes_sha256')) { + throw new GradleException("${mode} completed-world hashes differ from 4.0.16") + } + logger.lifecycle('{} completed-world blocks and biomes match 4.0.16', mode) + } + } +} diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..6befa68e --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,126 @@ +// A Forge process is not green merely because it returns exit code zero. The +// loader can log a worldgen/linkage failure and still shut down normally. +def acceptedForge28LogNoise = [ + ~/FML appears to be missing any signature data/, + ~/Found multiple arguments for option fml\.mcVersion/, + ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, + ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, + ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / +] + +def runtimeCrashSnapshot = { File runDirectory -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (!crashDirectory.isDirectory()) return [] as Set + return fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() }.collect { it.absolutePath } as Set +} + +def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes, + List expectedErrors = [] -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (crashDirectory.isDirectory()) { + def crashes = fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } + if (!crashes.isEmpty()) { + throw new GradleException("${context} produced crash report ${crashes.first()}") + } + } + + File logsDirectory = new File(runDirectory, 'logs') + if (!logsDirectory.isDirectory()) return + def failures = [] + [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] + .findAll { it.isFile() }.each { File log -> + int lineNumber = 0 + log.eachLine('UTF-8') { String line -> + lineNumber++ + boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ + boolean knownNoise = acceptedForge28LogNoise.any { line =~ it } || + expectedErrors.any { line.contains(it) } + boolean fatalText = line.contains('Encountered an unexpected exception') || + line.contains('Exception stopping the server') || + line.contains('Migration audit failed') || + line.contains('java.lang.Error:') || + line.contains('NoSuchMethodError') || + line.contains('NoClassDefFoundError') || + line.contains('ExceptionInInitializerError') || + line.contains('Tried to assign a mutable BlockPos') || + line.contains('causing cascading worldgen lag') + if ((unexpectedSeverity && !knownNoise) || fatalText) { + failures.add("${log.name}:${lineNumber}: ${line}") + } + } + } + if (!failures.isEmpty()) { + throw new GradleException("${context} logged unexpected errors:\n" + + failures.take(20).join('\n')) + } +} + +task runtimeLogScannerTest { + group = 'verification' + description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' + doLast { + File probe = file("${buildDir}/runtime-log-scanner-test") + delete probe + File logs = new File(probe, 'logs'); logs.mkdirs() + new File(logs, 'latest.log').setText( + '[main/ERROR] [FML]: FML appears to be missing any signature data\n' + + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' + + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) + new File(logs, 'latest.log').setText( + '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + boolean rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') + new File(logs, 'latest.log').setText( + '[Server thread/DEBUG] [FML]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') + new File(logs, 'latest.log').setText( + '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + delete probe + } +} + +check.dependsOn runtimeLogScannerTest + +task verifyMineralogyOracleIsolation { + group = 'verification' + description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' + doLast { + configurations.each { configuration -> + if (configuration.canBeResolved && + configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { + throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") + } + } + } +} + +check.dependsOn verifyMineralogyOracleIsolation + +['runClient', 'runServer', 'runData'].each { String taskName -> + tasks.matching { it.name == taskName }.all { JavaExec runTask -> + doFirst { + new File(runTask.workingDir, 'mods').mkdirs() + runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + } + doLast { + assertRuntimeLogsClean(runTask.workingDir, taskName, + runTask.ext.oreSpawnCrashSnapshot as Set) + } + } +} + + +project.ext.oreSpawnBuild.assertRuntimeLogsClean = assertRuntimeLogsClean diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java new file mode 100644 index 00000000..75cf6dc1 --- /dev/null +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/client/BiomeEditorProbeBridge.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.orespawn.client; + +import com.google.gson.JsonObject; + +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Runs the saved-world biome edit through the same session used by the GUI. */ +public final class BiomeEditorProbeBridge { + private BiomeEditorProbeBridge() { } + + public static JsonObject replace(JsonObject root, String dimension, + String source, String target) { + WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + GeologyEditorSession session = new GeologyEditorSession(profile); + if (session.biomeDirectory().palettes(dimension).isEmpty()) { + throw new IllegalStateException("The fixture's biome palette is missing"); + } + session.replaceBiome(dimension, source, target); + JsonObject saved = session.profile().rootCopy(); + GeologyEditorSession reopened = new GeologyEditorSession( + WorldGeologyProfile.fromJson(saved, profile)); + if (!target.equals(reopened.biomeReplacements(dimension).get(source))) { + throw new IllegalStateException("Biome replacement did not survive the editor round trip"); + } + boolean replaced = reopened.biomeDirectory().entries(dimension, true).stream() + .anyMatch(entry -> source.equals(entry.id) + && entry.status == BiomeDirectoryModel.Status.USER_REPLACED); + if (!replaced) throw new IllegalStateException("Biome directory did not show the replacement"); + return saved; + } +} diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java index 8219c474..b81bbdc5 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -22,6 +22,7 @@ import com.google.gson.JsonParser; import zone.moddev.mc.orespawn.api.BiomePlacementMode; +import zone.moddev.mc.orespawn.client.BiomeEditorProbeBridge; import zone.moddev.mc.orespawn.api.BiomeRegionSize; import zone.moddev.mc.orespawn.api.BiomeReplacementScope; import zone.moddev.mc.orespawn.api.GeologyFamily; @@ -82,7 +83,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -/** Independent, test-only provider surface fixture. */ +/** Test mod for provider surfaces and materials, excluded from production builds. */ @Mod(SurfaceProbeTestMod.MODID) public final class SurfaceProbeTestMod { static final String MODID = "surfaceprobe"; @@ -145,6 +146,8 @@ public final class SurfaceProbeTestMod { new ResourceLocation("minecraft", "nether"), BIOME_A, BIOME_B))); private static final int MINIMUM_CHUNK = 63; private static final int MAXIMUM_CHUNK = 65; + private static final int OVERRIDE_MIN_CHUNK = 68; + private static final int OVERRIDE_MAX_CHUNK = 70; private static final int FLUID_PROBE_MIN_CHUNK_X = 60; private static final int FLUID_PROBE_MAX_CHUNK_X = 62; private static final int EXPECTED_COLUMNS = 9 * 16 * 16; @@ -361,7 +364,10 @@ private void auditGeneratedSurfaces(FMLServerStartedEvent event) { results.put("open", auditDimension(requireLevel(event, OPEN), false)); results.put("roofed", auditDimension(requireLevel(event, ROOFED), true)); SpringResult spring = auditProviderRockSpring(overworld, phase); + int[] override = verifyBiomeOverride(event.getServer(), phase, results.get("open")); Properties current = properties(overworld.getSeed(), results, spring); + current.setProperty("override.biome_a", Integer.toString(override[0])); + current.setProperty("override.biome_b", Integer.toString(override[1])); if (previous == null) { writeMarker(marker, current); } else { @@ -382,6 +388,54 @@ private void auditGeneratedSurfaces(FMLServerStartedEvent event) { event.getServer().initiateShutdown(false); } + private static int[] verifyBiomeOverride(MinecraftServer server, String phase, + AuditResult original) { + ServerWorld end = server.getWorld(OPEN); + if ("fresh".equals(phase)) { + Path profile = worldRoot(server).resolve("serverconfig").resolve("orespawn-worldgen.json"); + JsonObject edited = BiomeEditorProbeBridge.replace( + WorldGeologyProfileManager.activeProfile().rootCopy(), OPEN_ID.toString(), + BIOME_A.toString(), BIOME_B.toString()); + try (BufferedWriter writer = Files.newBufferedWriter(profile)) { + new GsonBuilder().setPrettyPrinting().create().toJson(edited, writer); + } catch (IOException exception) { + throw new IllegalStateException("Could not save the biome replacement", exception); + } + if (!WorldGeologyProfileManager.reloadActiveProfile()) { + throw new IllegalStateException("Could not activate the biome replacement"); + } + int[] unchanged = countBiomes(end, MINIMUM_CHUNK, MAXIMUM_CHUNK); + if (unchanged[0] != original.biomeA() || unchanged[1] != original.biomeB()) { + throw new IllegalStateException("Biome replacement rewrote existing terrain"); + } + } + int[] generated = countBiomes(end, OVERRIDE_MIN_CHUNK, OVERRIDE_MAX_CHUNK); + if (generated[0] != 0 || generated[1] != EXPECTED_COLUMNS) { + throw new IllegalStateException("Biome replacement missed new terrain: a=" + + generated[0] + ", b=" + generated[1]); + } + return generated; + } + + private static int[] countBiomes(ServerWorld world, int minimumChunk, int maximumChunk) { + int biomeA = 0; + int biomeB = 0; + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int chunkZ = minimumChunk; chunkZ <= maximumChunk; chunkZ++) { + for (int chunkX = minimumChunk; chunkX <= maximumChunk; chunkX++) { + world.getChunk(chunkX, chunkZ); + for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) { + ResourceLocation id = biomeId(world, world.getBiome(cursor.setPos( + (chunkX << 4) + x, 64, (chunkZ << 4) + z))); + if (BIOME_A.equals(id)) biomeA++; + else if (BIOME_B.equals(id)) biomeB++; + else throw new IllegalStateException("Unexpected fixture biome " + id); + } + } + } + return new int[] { biomeA, biomeB }; + } + private static void verifyOrePatternRegistry() { for (String name : Arrays.asList("default", "vein", "normal_cloud", "precision", "clusters", "underfluids")) { diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java index 7d6d765e..83ecb411 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java @@ -139,8 +139,10 @@ private void handleClientTick() { break; case 4: if (minecraft.currentScreen instanceof OreSpawnScreen && editorFrames >= 2) { - validateCaptions((OreSpawnScreen) minecraft.currentScreen); - ((OreSpawnScreen) minecraft.currentScreen).onClose(); + OreSpawnScreen editor = (OreSpawnScreen) minecraft.currentScreen; + validateCaptions(editor); + if (editor instanceof GeologyMaterialsScreen) validateOreSourcesRoute(minecraft, editor); + editor.onClose(); nextState(3); } break; @@ -202,16 +204,58 @@ private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) { private static void validateCaptions(OreSpawnScreen screen) { for (Widget widget : screen.qualificationButtons()) { + if (widget instanceof CogButton || widget instanceof CompactScrollList) continue; String caption = TextFormatting.getTextWithoutFormattingCodes(widget.getMessage()); if (caption == null || caption.trim().isEmpty() || caption.contains("options.generic_value") || caption.startsWith("button.orespawn.") || caption.startsWith("option.orespawn.")) { - throw new IllegalStateException("Invalid client caption: " + widget.getMessage()); + throw new IllegalStateException("Invalid client caption on " + + screen.getClass().getSimpleName() + '/' + widget.getClass().getSimpleName() + + ": " + widget.getMessage()); } } } + private static void validateOreSourcesRoute(Minecraft minecraft, OreSpawnScreen materials) { + Button ores = buttonWithCaption(materials, + net.minecraft.client.resources.I18n.format("tab.orespawn.ores")); + if (ores == null) throw new IllegalStateException("Missing ORES tab"); + ores.onPress(); + Button sources = buttonWithCaption(materials, + net.minecraft.client.resources.I18n.format("button.orespawn.ore_sources")); + if (sources == null) { + throw new IllegalStateException("Missing Ore Sources route from ORES"); + } + sources.onPress(); + if (!(minecraft.currentScreen instanceof OreSourceListScreen)) { + throw new IllegalStateException("Ore Sources route did not open"); + } + OreSourceListScreen opened = (OreSourceListScreen) minecraft.currentScreen; + Button addGroup = buttonWithCaption(opened, "+"); + if (addGroup == null) throw new IllegalStateException("Missing Add Group control"); + addGroup.onPress(); + int lists = 0; + for (Widget widget : opened.qualificationButtons()) { + if (widget instanceof CompactScrollList) lists++; + } + if (lists != 2) throw new IllegalStateException("Ore Sources must show both compact lists: " + lists); + opened.onClose(); + if (minecraft.currentScreen != materials) { + throw new IllegalStateException("Ore Sources did not return to ORES"); + } + } + + private static Button buttonWithCaption(OreSpawnScreen screen, String caption) { + for (Widget widget : screen.qualificationButtons()) { + if (widget instanceof Button && caption.equals( + TextFormatting.getTextWithoutFormattingCodes(widget.getMessage()))) { + return (Button) widget; + } + } + return null; + } + private void validateLongEditorRoundTrip(Minecraft minecraft, Screen parent) { JsonObject root = WorldGeologyProfile.recommended(true).rootCopy(); JsonObject ores = new JsonObject(); @@ -279,9 +323,8 @@ private void validateLongEditorRoundTrip(Minecraft minecraft, Screen parent) { deposit.add("dimensions", fluidDimensions); deposits.add("example:long_editor_deposit", deposit); root.add("fluid_deposits", deposits); - // Keep the synthetic profile in the editor's canonical shape so this - // assertion is about preservation of the eight long text fields rather - // than the session adding an unrelated optional empty section. + // Use a complete profile so this checks the eight long text fields, + // not the editor adding an optional empty section. root.add("geomes", new JsonObject()); GeologyEditorSession session = new GeologyEditorSession( @@ -326,9 +369,8 @@ private static void pressDone(OreSpawnScreen screen) { } private static void stopIntegratedServer(Minecraft minecraft) { - // Match Forge 28's target-native disconnect path. func_213231_b clears the - // integrated-server state as well as the client world; loadWorld(null) only - // swaps the client world on this target and would leave reload stuck. + // Follow GuiIngameMenu's disconnect path: loadWorld(null) saves and stops the server. + // Open the replacement screen in the same tick so EntityRenderer never has neither a world nor a screen. if (minecraft.world != null) minecraft.world.sendQuittingDisconnectingPacket(); minecraft.func_213231_b(new MainMenuScreen()); } diff --git a/src/main/java/com/mojang/serialization/Codec.java b/src/main/java/com/mojang/serialization/Codec.java index d55b3bbe..e9d09b9c 100644 --- a/src/main/java/com/mojang/serialization/Codec.java +++ b/src/main/java/com/mojang/serialization/Codec.java @@ -6,9 +6,8 @@ import com.google.gson.JsonElement; /** - * Small 1.14 compatibility surface for OreSpawn's codec-backed public pattern - * contract. Minecraft 1.14 predates Mojang's serialization package, so this - * target supplies only the JSON decode operation used by OreSpawn profiles. + * Supplies the JSON decoding needed by OreSpawn's public pattern API. + * Minecraft 1.14 has no Mojang serialization package, so this is only the subset used by profiles. */ public abstract class Codec { public abstract DataResult parse(JsonOps operations, JsonElement input); diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index a28531d5..beaaa78d 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -60,6 +60,7 @@ public OreSpawn() { MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onChunkLoad); MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onWorldTick); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerAboutToStart); + MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStarting); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onWorldLoad); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStopped); MinecraftForge.EVENT_BUS.addListener(OreRetrogenManager::onChunkLoad); diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawnConfig.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawnConfig.java index 3811d092..fc94eafa 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawnConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawnConfig.java @@ -6,7 +6,7 @@ import net.minecraftforge.fml.ModLoadingContext; import net.minecraftforge.fml.config.ModConfig; -/** Small bootstrap fallback; the JSON profile is the authoritative worldgen configuration. */ +/** Fallback settings used during startup; the JSON profile controls world generation. */ public final class OreSpawnConfig { private static final Common COMMON; private static final ForgeConfigSpec SPEC; diff --git a/src/main/java/zone/moddev/mc/orespawn/api/BiomeRegionSize.java b/src/main/java/zone/moddev/mc/orespawn/api/BiomeRegionSize.java index fb2b14c4..1476edd7 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/BiomeRegionSize.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/BiomeRegionSize.java @@ -2,7 +2,7 @@ import java.util.Locale; -/** Stable presets for broad biome-overlay regions, expressed in blocks. */ +/** Named sizes for biome-overlay regions, measured in blocks. */ public enum BiomeRegionSize { TINY(128), SMALL(256), diff --git a/src/main/java/zone/moddev/mc/orespawn/api/CompiledOrePattern.java b/src/main/java/zone/moddev/mc/orespawn/api/CompiledOrePattern.java index 5805de01..7a40a679 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/CompiledOrePattern.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/CompiledOrePattern.java @@ -1,9 +1,9 @@ package zone.moddev.mc.orespawn.api; /** - * Immutable, pre-decoded ore pattern used during chunk generation. - * Implementations must be thread-safe and must not allocate or access registries, - * configuration files, tags, or logging from {@link #place(OrePlacementContext)}. + * An immutable ore pattern decoded before chunk generation starts. + * Implementations must be thread-safe. {@link #place(OrePlacementContext)} must not allocate, + * access registries, read configuration files or tags, or write to logs. */ @FunctionalInterface public interface CompiledOrePattern { diff --git a/src/main/java/zone/moddev/mc/orespawn/api/GeologyFamily.java b/src/main/java/zone/moddev/mc/orespawn/api/GeologyFamily.java index e2cb7793..6136b3df 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/GeologyFamily.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/GeologyFamily.java @@ -1,6 +1,6 @@ package zone.moddev.mc.orespawn.api; -/** Geological families understood by OreSpawn's declarative engines. */ +/** Rock families used by OreSpawn's geology settings. */ public enum GeologyFamily { SEDIMENTARY("sedimentary"), METAMORPHIC("metamorphic"), diff --git a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java index f029d69f..239ac57f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java @@ -3,8 +3,7 @@ /** Reusable, read-only sampler for the active world's baked geology. */ public interface GeologySampler { /** - * Classifies one column. The returned column reuses that biome/geome - * classification for all subsequent Y queries. + * Classifies a column once, then reuses its biome and geome for every height query. */ GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY); } diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java new file mode 100644 index 00000000..fca9790b --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java @@ -0,0 +1,43 @@ +package zone.moddev.mc.orespawn.api; + +import java.util.Optional; + +import net.minecraft.util.ResourceLocation; + +/** + * Extended placement context supplied by OreSpawn 4.1 and later. + * + *

These identities are stable for the current chunk during normal generation and supported retrogen. + * Combine them with the pattern's own stable definition ID and salt; do not rely on invocation order + * or the mutable state of {@link #random()}.

+ * + *

The separate interface keeps implementations compiled against {@link OrePlacementContext} + * binary compatible.

+ */ +public interface OreGenerationContext extends OrePlacementContext { + /** + * Attempts placement with the same stable identity for every slice of one deposit. + * Implementations compiled before this overload retain their original placement behaviour. + */ + default boolean tryPlace(int x, int y, int z, long outputIdentity) { + return tryPlace(x, y, z); + } + + /** Stable seed of the world being generated. */ + long worldSeed(); + + /** Stable namespaced identity of the dimension being generated. */ + ResourceLocation dimension(); + + /** X coordinate of the current chunk, not its minimum block coordinate. */ + int chunkX(); + + /** Z coordinate of the current chunk, not its minimum block coordinate. */ + int chunkZ(); + + /** + * Returns the geology sampler if the dimension has an OreSpawn geology configuration. + * Do not retain the sampler or world objects after the placement call. + */ + Optional geologySampler(); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java b/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java index bdca97bb..2c4f822b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java @@ -11,9 +11,8 @@ import net.minecraftforge.registries.ForgeRegistryEntry; /** - * Forge-registered ore pattern type. Its codec is evaluated once while a - * geology profile is baked; only the resulting compiled pattern reaches the - * generation loop. + * A Forge-registered ore pattern. Its codec runs once when the geology profile is prepared; + * chunk generation uses only the resulting compiled pattern. */ public final class OrePatternType extends ForgeRegistryEntry { private final Codec codec; diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java index e41d89fe..798d670d 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java @@ -19,10 +19,9 @@ public interface OrePlacementContext { int nodeSize(); /** - * Returns whether this attempt may inspect or replace the position. During initial - * generation this includes Minecraft's already-loaded writable worldgen region, so - * deposits can cross chunk borders. Retrogen deliberately limits it to the chunk - * being updated. + * Returns whether this attempt may inspect or replace the position. + * Initial generation can use Minecraft's loaded, writable region to cross chunk borders. + * Retrogen is limited to the chunk being updated. */ boolean inside(int x, int y, int z); boolean isFluid(int x, int y, int z, Fluid fluid); diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java index 96bfca32..ce13afd7 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java @@ -21,8 +21,7 @@ private OreSpawnApi() { } /** - * Enqueues a provider through Forge IMC. Call this during - * {@code InterModEnqueueEvent}. + * Submits a provider through Forge IMC. Call during {@code InterModEnqueueEvent}. */ public static boolean enqueue(WorldgenProvider provider) { if (provider == null) { diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnBiomes.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnBiomes.java index 6f11a7b0..40f6a02a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnBiomes.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnBiomes.java @@ -11,16 +11,14 @@ import net.minecraftforge.fml.RegistryObject; /** - * Small registration helpers for provider mods which want to define biomes - * without taking a compile-time dependency on a separate biome framework. + * Registers provider biomes without requiring a separate biome framework. */ public final class OreSpawnBiomes { private OreSpawnBiomes() { } /** - * Registers a biome copied from an existing biome, then applies provider - * changes to the copied builder. + * Copies an existing biome, applies the provider's changes, then registers the copy. */ public static RegistryObject copyAndRegister(DeferredRegister register, String name, Supplier source, Consumer edit) { @@ -47,9 +45,8 @@ public static RegistryObject copyAndRegister(DeferredRegister regi } /** - * Registers a biome from a fresh builder. The provider must set all required - * climate, effects, spawning, and generation fields before the builder is - * built. + * Registers a biome from a fresh builder. + * Set its required climate, effects, spawning and generation fields before building it. */ public static RegistryObject blankAndRegister(DeferredRegister register, String name, Consumer configure) { @@ -62,7 +59,7 @@ public static RegistryObject blankAndRegister(DeferredRegister reg }); } - /** Concrete 1.14 biome used behind the unchanged public helper contract. */ + /** Lets the registration helpers build a concrete Minecraft 1.14 biome. */ private static final class ProviderBiome extends Biome { ProviderBiome(Biome.Builder builder) { super(builder); diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnOreIntegration.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnOreIntegration.java index f58aff59..e14d8054 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnOreIntegration.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnOreIntegration.java @@ -8,8 +8,7 @@ /** * Compatibility facade for the initial ore-provider status API. * - * @deprecated Use {@link OreSpawnApi}. This class remains available so - * existing provider mods do not need an immediate source change. + * @deprecated Use {@link OreSpawnApi}. Kept for existing provider mods. */ @Deprecated public final class OreSpawnOreIntegration { @@ -40,7 +39,7 @@ public static void markFeatureReady() { WorldgenIntegrationManager.markFeatureReady(); } - /** Merge all provider contributions while retaining the historical method name. */ + /** Merges provider definitions. Kept for mods using the original integration API. */ public static boolean mergeProviderOres(JsonObject target) { return WorldgenIntegrationManager.mergeProviderDefinitions(target); } diff --git a/src/main/java/zone/moddev/mc/orespawn/api/ProviderStatus.java b/src/main/java/zone/moddev/mc/orespawn/api/ProviderStatus.java index 546d07f2..8e5a66c9 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/ProviderStatus.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/ProviderStatus.java @@ -1,6 +1,6 @@ package zone.moddev.mc.orespawn.api; -/** Lifecycle state of a OreSpawn world-generation provider. */ +/** Lifecycle state of an OreSpawn world-generation provider. */ public enum ProviderStatus { PENDING, ACTIVE, diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java index 366ca40e..eb933cae 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java @@ -11,6 +11,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.function.Consumer; @@ -20,9 +21,9 @@ import net.minecraft.util.ResourceLocation; /** - * Immutable declarative world-generation contribution submitted to OreSpawn. - * All registry objects are represented by IDs and resolved only when OreSpawn - * freezes and bakes the active provider set. + * Immutable world-generation settings submitted by a provider mod. + * Blocks and other registry entries are stored as IDs, then resolved when OreSpawn prepares + * the active providers for generation. */ public final class WorldgenProvider { private final String modId; @@ -47,7 +48,7 @@ public int revision() { return revision; } - /** Returns a defensive JSON representation matching provider schema 4. */ + /** Returns a defensive JSON representation matching provider schema 5. */ public JsonObject toJson() { return JsonCopies.copy(definition); } @@ -209,7 +210,7 @@ public WorldgenProvider build() { requireOwned(dimensionMaterials.keySet(), "dimension materials"); requireOwned(templates.keySet(), "template"); JsonObject root = new JsonObject(); - root.addProperty("schema_version", 4); + root.addProperty("schema_version", 5); root.addProperty("provider_modid", modId); root.addProperty("provider_revision", revision); root.add("rocks", object(rocks)); @@ -347,6 +348,7 @@ public RockDefinition build() { public static final class OreDefinition implements JsonDefinition { private final ResourceLocation id; private final ResourceLocation block; + private final ResourceLocation material; private final boolean enabled; private final boolean nativeGeneration; private final ResourceLocation deepOutput; @@ -360,6 +362,7 @@ public static final class OreDefinition implements JsonDefinition { private OreDefinition(Builder builder) { id = builder.id; block = builder.block; + material = builder.material; enabled = builder.enabled; nativeGeneration = builder.nativeGeneration; deepOutput = builder.deepOutput; @@ -375,6 +378,8 @@ private OreDefinition(Builder builder) { public static Builder builder(ResourceLocation id, ResourceLocation block) { return new Builder(id, block); } public ResourceLocation id() { return id; } public ResourceLocation block() { return block; } + /** Material shared by interchangeable ore outputs. */ + public Optional material() { return Optional.ofNullable(material); } public boolean enabled() { return enabled; } public List outputs() { return outputs; } public boolean suppressVanilla() { return suppressVanilla; } @@ -386,6 +391,7 @@ private OreDefinition(Builder builder) { public JsonObject toJson() { JsonObject json = new JsonObject(); json.addProperty("block", block.toString()); + if (material != null) json.addProperty("material", material.toString()); json.addProperty("enabled", enabled); json.addProperty("native_generation", nativeGeneration); json.addProperty("suppress_vanilla", suppressVanilla); @@ -411,6 +417,7 @@ public JsonObject toJson() { public static final class Builder { private final ResourceLocation id; private final ResourceLocation block; + private ResourceLocation material; private boolean enabled = true; private boolean nativeGeneration; private ResourceLocation deepOutput; @@ -427,6 +434,7 @@ private Builder(ResourceLocation id, ResourceLocation block) { this.block = Objects.requireNonNull(block, "block"); } public Builder enabled(boolean value) { enabled = value; return this; } + public Builder material(ResourceLocation value) { material = Objects.requireNonNull(value); return this; } public Builder nativeGeneration(boolean value) { nativeGeneration = value; return this; } public Builder suppressVanilla(boolean value) { suppressVanilla = value; return this; } public Builder retrogen(boolean value) { retrogen = value; return this; } @@ -518,6 +526,7 @@ public static final class OreDimensionDefinition implements JsonDefinition { private final int maxQuantity; private final OrePattern pattern; private final ResourceLocation patternType; + private final ResourceLocation placementChannel; private final JsonObject patternSettings; private final OreHeightDistribution heightDistribution; private final double discardChanceOnAirExposure; @@ -545,6 +554,9 @@ private OreDimensionDefinition(Builder builder) { maxQuantity = builder.maxQuantity; pattern = builder.pattern; patternType = builder.patternType; + placementChannel = builder.placementChannel != null ? builder.placementChannel + : builder.patternType != null ? builder.patternType + : new ResourceLocation("orespawn", "standard"); patternSettings = JsonCopies.copy(builder.patternSettings); heightDistribution = builder.heightDistribution; discardChanceOnAirExposure = builder.discardChanceOnAirExposure; @@ -570,12 +582,14 @@ private OreDimensionDefinition(Builder builder) { public int minY() { return minY; } public int maxY() { return maxY; } public double attempts() { return frequency; } - /** Compatibility value for consumers that only understand a fixed quantity. */ + /** Fixed quantity used by older consumers that do not support a range. */ public int quantity() { return (minQuantity + maxQuantity + 1) / 2; } public int minQuantity() { return minQuantity; } public int maxQuantity() { return maxQuantity; } public OrePattern pattern() { return pattern; } public ResourceLocation patternType() { return patternType; } + /** Placement frequency, shape and host budget are selected per channel. */ + public ResourceLocation placementChannel() { return placementChannel; } public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); } public OreHeightDistribution heightDistribution() { return heightDistribution; } public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; } @@ -614,6 +628,7 @@ public JsonObject toJson() { configuredPattern.add("settings", JsonCopies.copy(patternSettings)); json.add("pattern", configuredPattern); } + json.addProperty("placement_channel", placementChannel.toString()); json.addProperty("height_distribution", heightDistribution.configName()); json.addProperty("discard_chance_on_air_exposure", discardChanceOnAirExposure); json.addProperty("spread", spread); @@ -642,6 +657,7 @@ public static final class Builder { private int maxQuantity = 8; private OrePattern pattern = OrePattern.VEIN; private ResourceLocation patternType; + private ResourceLocation placementChannel; private JsonObject patternSettings = new JsonObject(); private OreHeightDistribution heightDistribution = OreHeightDistribution.UNIFORM; private double discardChanceOnAirExposure; @@ -678,6 +694,10 @@ public Builder pattern(ResourceLocation type, JsonObject settings) { patternSettings = JsonCopies.copy(Objects.requireNonNull(settings, "settings")); return this; } + public Builder placementChannel(ResourceLocation value) { + placementChannel = Objects.requireNonNull(value); + return this; + } public Builder heightDistribution(OreHeightDistribution value) { heightDistribution = Objects.requireNonNull(value); return this; } public Builder discardChanceOnAirExposure(double value) { discardChanceOnAirExposure = value; return this; } public Builder spread(int horizontal, int vertical) { spread = horizontal; verticalSpread = vertical; return this; } @@ -1342,8 +1362,8 @@ private static JsonArray ids(Collection values) { } /** - * A provider-owned set of biomes which can augment or replace an existing - * dimension biome source without depending on a particular biome framework. + * A provider's biome palette, which supplements or replaces the biomes chosen by a dimension. + * No separate biome framework is required. */ public static final class BiomePaletteDefinition implements JsonDefinition { private final ResourceLocation id; diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java new file mode 100644 index 00000000..a9abc5b7 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.Objects; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** An add-on's registered entry in OreSpawn's Mods directory. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtension { + private final ResourceLocation id; + private final String buttonTranslationKey; + private final WorldSettingsScreenFactory screenFactory; + + WorldSettingsExtension(ResourceLocation id, String buttonTranslationKey, + WorldSettingsScreenFactory screenFactory) { + this.id = Objects.requireNonNull(id, "id"); + this.buttonTranslationKey = Objects.requireNonNull(buttonTranslationKey, + "buttonTranslationKey"); + this.screenFactory = Objects.requireNonNull(screenFactory, "screenFactory"); + } + + public ResourceLocation id() { return id; } + public String buttonTranslationKey() { return buttonTranslationKey; } + + /** Creates the extension screen with OreSpawn's Mods directory as its parent. */ + public Screen createScreen(Screen parent) { + return screenFactory.create(Objects.requireNonNull(parent, "parent")); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java new file mode 100644 index 00000000..58796db3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java @@ -0,0 +1,78 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Client-only registry for add-on screens in OreSpawn's Mods directory. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtensionRegistry { + private static final Pattern MOD_ID = Pattern.compile("^[a-z][a-z0-9_.-]{1,63}$"); + private static final Map EXTENSIONS = + new LinkedHashMap<>(); + + private WorldSettingsExtensionRegistry() { + } + + /** + * Registers an installed mod's configuration screen in OreSpawn. + * Each mod may register one screen. Call during client initialization. + * + * @throws IllegalArgumentException if the mod identifier is not canonical + * @throws IllegalStateException if the mod already registered a screen + */ + public static synchronized WorldSettingsExtension registerConfigScreen(String modId, + WorldSettingsScreenFactory screenFactory) { + validateModId(modId); + return registerOwned(modId, new ResourceLocation(modId, "configuration"), + "button.orespawn.mod.configure", screenFactory); + } + + /** + * Registers an add-on screen using the original translated-label overload. + * The identifier's namespace owns the entry. Call during client initialization. + * + * @throws IllegalStateException if the identifier is already registered + */ + public static synchronized WorldSettingsExtension register(ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(id, "id"); + if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) { + throw new IllegalArgumentException("buttonTranslationKey cannot be blank"); + } + String modId = id.getNamespace(); + validateModId(modId); + return registerOwned(modId, id, buttonTranslationKey, screenFactory); + } + + private static WorldSettingsExtension registerOwned(String modId, ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(screenFactory, "screenFactory"); + if (EXTENSIONS.containsKey(modId)) { + throw new IllegalStateException("Duplicate OreSpawn configuration screen for mod: " + modId); + } + WorldSettingsExtension extension = new WorldSettingsExtension(id, buttonTranslationKey, + screenFactory); + EXTENSIONS.put(modId, extension); + return extension; + } + + private static void validateModId(String modId) { + if (modId == null || !MOD_ID.matcher(modId).matches()) { + throw new IllegalArgumentException("modId must be a canonical lower-case mod identifier"); + } + } + + /** Returns a read-only snapshot in registration order. */ + public static synchronized List extensions() { + return Collections.unmodifiableList(new ArrayList<>(EXTENSIONS.values())); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java new file mode 100644 index 00000000..28fe449b --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java @@ -0,0 +1,12 @@ +package zone.moddev.mc.orespawn.api.client; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */ +@FunctionalInterface +@OnlyIn(Dist.CLIENT) +public interface WorldSettingsScreenFactory { + Screen create(Screen parent); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java new file mode 100644 index 00000000..19043534 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -0,0 +1,341 @@ +package zone.moddev.mc.orespawn.client; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; +import zone.moddev.mc.orespawn.util.JsonCopies; + +import net.minecraft.util.ResourceLocation; +import net.minecraft.world.biome.Biome; +import net.minecraftforge.common.BiomeDictionary; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.registries.ForgeRegistries; + +/** A read-only snapshot of installed biomes and their placement rules. */ +final class BiomeDirectoryModel { + private static final String OVERWORLD = "minecraft:overworld"; + private static final String NETHER = "minecraft:the_nether"; + private static final String END = "minecraft:the_end"; + private static final Method BIOME_NAME = findBiomeNameMethod(); + + private BiomeDirectoryModel() { } + + static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults, + Iterable dimensions) { + List loaded = new ArrayList<>(); + Map mods = new LinkedHashMap<>(); + for (net.minecraftforge.fml.loading.moddiscovery.ModInfo mod : ModList.get().getMods()) { + mods.put(mod.getModId(), new ModInfo(mod.getModId(), mod.getDisplayName(), + mod.getVersion().toString())); + } + mods.putIfAbsent("minecraft", new ModInfo("minecraft", "Minecraft", "1.14.4")); + for (Entry entry : ForgeRegistries.BIOMES.getEntries()) { + ModInfo owner = mods.get(entry.getKey().getNamespace()); + loaded.add(new LoadedBiome(entry.getKey().toString(), biomeName(entry.getKey(), entry.getValue()), + owner == null ? entry.getKey().getNamespace() : owner.name, + owner == null ? "?" : owner.version, routineDimension(entry.getValue()))); + } + return assemble(profile, defaults.biomePalettesCopy(), + defaults.activeProviderIds(), loaded, dimensions); + } + + private static String biomeName(ResourceLocation id, Biome biome) { + if (BIOME_NAME == null) return friendly(id.toString()); + try { + return (String) BIOME_NAME.invoke(biome); + } catch (IllegalAccessException | InvocationTargetException exception) { + return friendly(id.toString()); + } + } + + private static Method findBiomeNameMethod() { + for (String name : new String[] { "getBiomeName", "func_185359_l" }) { + try { + Method method = Biome.class.getDeclaredMethod(name); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException ignored) { + // Try both the development name and the packaged runtime's SRG name. + } + } + return null; + } + + static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, + Set activeProviders, List loadedBiomes, + Iterable requestedDimensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedBiome biome : loadedBiomes) loaded.put(biome.id, biome); + Set dimensions = new LinkedHashSet<>(); + for (String dimension : requestedDimensions) dimensions.add(dimension); + JsonObject profilePalettes = object(profile, "biome_palettes"); + List palettes = new ArrayList<>(); + int order = 0; + for (Entry entry : profilePalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", "minecraft:overworld"); + dimensions.add(dimension); + palettes.add(new Palette(entry.getKey(), dimension, + string(palette, "source_provider", owner(entry.getKey())), + order++, bool(palette, "enabled", true), + BiomeReplacementRules.isOverridePalette(entry.getKey()), + palette.has("source_provider"), palette)); + } + + Map> providerBiomes = providerBiomes(providerPalettes); + Map> declaredDimensions = declaredDimensions(palettes, providerBiomes); + List entries = new ArrayList<>(); + for (String dimension : dimensions) { + Map> placements = placements(palettes, dimension); + Map replacements = BiomeReplacementRules.read(profile, dimension); + Set ids = new LinkedHashSet<>(); + for (LoadedBiome biome : loaded.values()) { + Set declared = declaredDimensions.getOrDefault(biome.id, Collections.emptySet()); + if (declared.isEmpty() ? dimension.equals(biome.routineDimension) + : declared.contains(dimension)) ids.add(biome.id); + } + ids.addAll(placements.keySet()); + ids.addAll(providerBiomes.getOrDefault(dimension, Collections.emptySet())); + ids.addAll(replacements.keySet()); + ids.addAll(replacements.values()); + for (String id : ids) { + LoadedBiome biome = loaded.get(id); + List rules = placements.getOrDefault(id, Collections.emptyList()); + boolean managed = providerBiomes.getOrDefault(dimension, Collections.emptySet()).contains(id); + boolean enabled = false; + boolean missingProvider = false; + for (Placement placement : rules) enabled |= placement.enabled; + for (Placement placement : rules) missingProvider |= placement.providerOwned + && !activeProviders.contains(placement.owner); + Status status; + if (biome == null || missingProvider) status = Status.MISSING; + else if (replacements.containsKey(id)) status = Status.USER_REPLACED; + else if (rules.size() > 1) status = Status.LAYERED; + else if (!rules.isEmpty() && !enabled) status = Status.DISABLED; + else if (managed || !rules.isEmpty()) status = Status.PROVIDER_MANAGED; + else status = Status.UNMANAGED; + entries.add(new BiomeEntry(dimension, id, + biome == null ? friendly(id) : biome.name, + biome == null ? owner(id) : biome.ownerName, + biome == null ? "?" : biome.ownerVersion, + status, rules, replacements.get(id), managed)); + } + } + Comparator entryOrder = Comparator + .comparingInt((BiomeEntry entry) -> entry.status.priority) + .thenComparing(entry -> entry.name.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.id) + .thenComparing(entry -> entry.dimension); + entries.sort(entryOrder); + palettes.sort(Comparator.comparingInt(palette -> palette.order)); + return new Snapshot(entries, palettes, dimensions, activeProviders); + } + + static String routineDimension(Biome biome) { + if (BiomeDictionary.hasType(biome, BiomeDictionary.Type.NETHER)) return NETHER; + if (BiomeDictionary.hasType(biome, BiomeDictionary.Type.END)) return END; + return OVERWORLD; + } + + private static Map> declaredDimensions(List palettes, + Map> providerBiomes) { + Map> result = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (palette.override) continue; + for (String id : JsonCopies.keys(object(palette.definition, "biomes"))) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(palette.dimension); + } + } + for (Entry> dimension : providerBiomes.entrySet()) { + for (String id : dimension.getValue()) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(dimension.getKey()); + } + } + return result; + } + + private static Map> providerBiomes(JsonObject providerPalettes) { + Map> result = new LinkedHashMap<>(); + for (Entry entry : providerPalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", ""); + Set values = result.computeIfAbsent(dimension, ignored -> new LinkedHashSet<>()); + for (Entry biome : object(palette, "biomes").entrySet()) { + values.add(biome.getKey()); + } + } + return result; + } + + private static Map> placements(List palettes, String dimension) { + Map> result = new LinkedHashMap<>(); + Map lastSurface = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (!dimension.equals(palette.dimension) || palette.override) continue; + for (Entry entry : object(palette.definition, "biomes").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject placement = entry.getValue().getAsJsonObject(); + List list = result.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()); + boolean hasSurface = placement.has("surface") && placement.get("surface").isJsonObject() + && !placement.getAsJsonObject("surface").entrySet().isEmpty(); + list.add(new Placement(palette.id, palette.owner, palette.order, + palette.enabled && bool(placement, "enabled", true), hasSurface, false, + palette.providerOwned, placement)); + if (palette.enabled && bool(placement, "enabled", true) && hasSurface) { + lastSurface.put(entry.getKey(), list.size() - 1); + } + } + } + for (Entry> entry : result.entrySet()) { + Integer effective = lastSurface.get(entry.getKey()); + if (effective == null) continue; + List updated = new ArrayList<>(); + for (int i = 0; i < entry.getValue().size(); i++) { + Placement value = entry.getValue().get(i); + updated.add(value.withEffectiveSurface(i == effective)); + } + entry.setValue(updated); + } + return result; + } + + private static String owner(String id) { + try { return new ResourceLocation(id).getNamespace(); } + catch (RuntimeException ignored) { return "unknown"; } + } + + private static String friendly(String id) { + String value; + try { value = new ResourceLocation(id).getPath(); } + catch (RuntimeException ignored) { value = id; } + StringBuilder result = new StringBuilder(); + for (String part : value.replace('/', '_').split("_")) { + if (part.isEmpty()) continue; + if (result.length() > 0) result.append(' '); + result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return result.length() == 0 ? id : result.toString(); + } + + private static JsonObject object(JsonObject root, String key) { + return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject(); + } + private static String string(JsonObject root, String key, String fallback) { + return root.has(key) ? root.get(key).getAsString() : fallback; + } + private static boolean bool(JsonObject root, String key, boolean fallback) { + return root.has(key) ? root.get(key).getAsBoolean() : fallback; + } + + enum Status { + USER_REPLACED(0), LAYERED(1), DISABLED(2), MISSING(3), PROVIDER_MANAGED(4), UNMANAGED(5); + final int priority; + Status(int priority) { this.priority = priority; } + } + + static final class Snapshot { + final List entries; + final List palettes; + final Set dimensions; + final Set activeProviders; + Snapshot(List entries, List palettes, Set dimensions, + Set activeProviders) { + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + this.palettes = Collections.unmodifiableList(new ArrayList<>(palettes)); + this.dimensions = Collections.unmodifiableSet(new LinkedHashSet<>(dimensions)); + this.activeProviders = Collections.unmodifiableSet(new LinkedHashSet<>(activeProviders)); + } + List entries(String dimension, boolean showAll) { + List result = new ArrayList<>(); + for (BiomeEntry entry : entries) { + if (dimension.equals(entry.dimension) && (showAll || entry.status != Status.UNMANAGED)) result.add(entry); + } + return Collections.unmodifiableList(result); + } + List palettes(String dimension) { + List result = new ArrayList<>(); + for (Palette palette : palettes) if (dimension.equals(palette.dimension)) result.add(palette); + return Collections.unmodifiableList(result); + } + } + + static final class LoadedBiome { + final String id, name, ownerName, ownerVersion, routineDimension; + LoadedBiome(String id, String name, String ownerName, String ownerVersion) { + this(id, name, ownerName, ownerVersion, OVERWORLD); + } + LoadedBiome(String id, String name, String ownerName, String ownerVersion, + String routineDimension) { + this.id = id; this.name = name; this.ownerName = ownerName; this.ownerVersion = ownerVersion; + this.routineDimension = routineDimension; + } + } + + static final class BiomeEntry { + final String dimension, id, name, ownerName, ownerVersion; + final Status status; + final List placements; + final String replacementTarget; + final boolean providerDeclared; + BiomeEntry(String dimension, String id, String name, String ownerName, String ownerVersion, + Status status, List placements, String replacementTarget, + boolean providerDeclared) { + this.dimension = dimension; this.id = id; this.name = name; + this.ownerName = ownerName; this.ownerVersion = ownerVersion; this.status = status; + this.placements = Collections.unmodifiableList(new ArrayList<>(placements)); + this.replacementTarget = replacementTarget; this.providerDeclared = providerDeclared; + } + } + + static final class Palette { + final String id, dimension, owner; + final int order; + final boolean enabled, override, providerOwned; + final JsonObject definition; + Palette(String id, String dimension, String owner, int order, boolean enabled, + boolean override, boolean providerOwned, JsonObject definition) { + this.id = id; this.dimension = dimension; this.owner = owner; this.order = order; + this.enabled = enabled; this.override = override; this.providerOwned = providerOwned; + this.definition = JsonCopies.copy(definition); + } + } + + static final class Placement { + final String paletteId, owner; + final int order; + final boolean enabled, hasSurface, effectiveSurface, providerOwned; + final JsonObject definition; + Placement(String paletteId, String owner, int order, boolean enabled, + boolean hasSurface, boolean effectiveSurface, boolean providerOwned, + JsonObject definition) { + this.paletteId = paletteId; this.owner = owner; this.order = order; + this.enabled = enabled; this.hasSurface = hasSurface; + this.effectiveSurface = effectiveSurface; this.providerOwned = providerOwned; + this.definition = JsonCopies.copy(definition); + } + Placement withEffectiveSurface(boolean value) { + return new Placement(paletteId, owner, order, enabled, hasSurface, value, + providerOwned, definition); + } + } + + private static final class ModInfo { + final String id, name, version; + ModInfo(String id, String name, String version) { this.id = id; this.name = name; this.version = version; } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java index 974715fe..b2956e51 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java @@ -1,19 +1,28 @@ package zone.moddev.mc.orespawn.client; +import java.util.ArrayList; import java.util.List; + +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Palette; + +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screen.Screen; import net.minecraft.util.text.StringTextComponent; import net.minecraft.util.text.TranslationTextComponent; +/** Lists every palette in effective profile order for one dimension. */ final class BiomePaletteScreen extends OreSpawnScreen { private final Screen parent; private final GeologyEditorSession session; private final String dimension; - private int page; + private CompactScrollList list; + private int scroll; + private String armedPalette; + private int resetScope; BiomePaletteScreen(Screen parent, GeologyEditorSession session, String dimension) { - super(new TranslationTextComponent("screen.orespawn.biome_palette")); + super(new TranslationTextComponent("screen.orespawn.biome_palettes")); this.parent = parent; this.session = session; this.dimension = dimension; @@ -21,56 +30,92 @@ final class BiomePaletteScreen extends OreSpawnScreen { @Override protected void init() { - int contentWidth = Math.min(390, Math.max(280, width - 24)); + OreSpawnScreenLayout.beginHelp(this); + int contentWidth = Math.min(520, Math.max(300, width - 20)); int left = (width - contentWidth) / 2; - int removeWidth = 70; - int listTop = 48; - int controlsY = height - 52; - List ids = session.biomePlacementIds(dimension); - int pageSize = Math.max(1, (controlsY - listTop) / 24); - int pageCount = Math.max(1, (ids.size() + pageSize - 1) / pageSize); - page = Math.max(0, Math.min(page, pageCount - 1)); - int start = page * pageSize; - for (int i = 0; i < pageSize && start + i < ids.size(); i++) { - String id = ids.get(start + i); - int y = listTop + i * 24; - addButton(OreSpawnScreenLayout.button(this, font, left, y, - contentWidth - removeWidth - 5, 20, new StringTextComponent(id), - button -> minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, - dimension, id)))); - addButton(new Button(left + contentWidth - removeWidth, y, removeWidth, 20, - new TranslationTextComponent("button.orespawn.remove"), - button -> { session.removeBiomePlacement(dimension, id); rebuildWidgets(); })); - } - Button previous = addButton(new Button(left, controlsY, 45, 20, - new StringTextComponent("<"), button -> { page--; rebuildWidgets(); })); - Button next = addButton(new Button(left + 50, controlsY, 45, 20, - new StringTextComponent(">"), button -> { page++; rebuildWidgets(); })); - previous.active = page > 0; - next.active = page + 1 < pageCount; - addButton(OreSpawnScreenLayout.button(this, font, - left + contentWidth - 150, controlsY, 150, 20, - new TranslationTextComponent("button.orespawn.add_biome"), - button -> minecraft.displayGuiScreen(new BiomePickerScreen(this, session, id -> { - session.addBiomePlacement(dimension, id); - minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, dimension, id)); - })))); + List palettes = session.biomeDirectory().palettes(dimension); + int listTop = 38; + int listHeight = Math.max(32, height - listTop - 62); + list = addButton(new CompactScrollList(this, left, listTop, contentWidth, listHeight) { + @Override protected int size() { return palettes.size(); } + @Override protected String rowText(int index) { + Palette palette = palettes.get(index); + return (palette.enabled ? "[x] " : "[ ] ") + (palette.order + 1) + ". " + palette.id; + } + @Override protected boolean rowEnabled(int index) { return !palettes.get(index).override; } + @Override protected int rowColor(int index) { + return palettes.get(index).override ? 0x55FFFF : palettes.get(index).enabled ? 0xFFFFFF : 0x888888; + } + @Override protected void onRowPressed(int index) { + Palette palette = palettes.get(index); + if (!palette.override) minecraft.displayGuiScreen(new BiomePaletteSettingsScreen( + BiomePaletteScreen.this, session, palette.id)); + } + @Override protected int rowActionWidth(int index) { return 18; } + @Override protected void drawRowAction(Minecraft minecraft, int index, int x, int y, + int width, int height, boolean hovered) { + drawCenteredString(font, armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "!" : "R", x + width / 2, y + 4, hovered ? 0xFFFFFF : 0xAAAAAA); + } + @Override protected void onRowActionPressed(int index) { resetPalette(palettes.get(index)); } + @Override protected List rowTooltip(int index) { + Palette palette = palettes.get(index); + List lines = new ArrayList<>(); + lines.add(palette.id); + lines.add(net.minecraft.client.resources.I18n.format( + "label.orespawn.biome.palette_owner_order", palette.owner, palette.order + 1)); + if (palette.override) lines.add(net.minecraft.client.resources.I18n.format( + "tooltip.orespawn.biome.override_palette")); + return lines; + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList(net.minecraft.client.resources.I18n.format( + armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "button.orespawn.biome.confirm_reset_palette" + : "button.orespawn.biome.reset_palette")); + } + }); + list.setFirstIndex(scroll); + int half = (contentWidth - 4) / 2; + int resetY = height - 52; + addButton(OreSpawnScreenLayout.button(this, font, left, resetY, half, 20, + new TranslationTextComponent(resetScope == 1 ? "button.orespawn.biome.confirm_reset_dimension" + : "button.orespawn.biome.reset_dimension"), button -> resetDimension())); + addButton(OreSpawnScreenLayout.button(this, font, left + half + 4, resetY, half, 20, + new TranslationTextComponent(resetScope == 2 ? "button.orespawn.biome.confirm_reset_all" + : "button.orespawn.biome.reset_all"), button -> resetAll())); addButton(new Button(width / 2 - 75, height - 28, 150, 20, DialogTexts.GUI_DONE, button -> onClose())); } - private void rebuildWidgets() { - buttons.clear(); children.clear(); - init(); + private void resetPalette(Palette palette) { + resetScope = 0; + if (!palette.id.equals(armedPalette)) { armedPalette = palette.id; rebuild(); return; } + session.resetBiomePalette(palette.id); armedPalette = null; rebuild(); + } + + private void resetDimension() { + armedPalette = null; + if (resetScope != 1) { resetScope = 1; rebuild(); return; } + session.resetBiomeDimension(dimension); resetScope = 0; rebuild(); } + private void resetAll() { + armedPalette = null; + if (resetScope != 2) { resetScope = 2; rebuild(); return; } + session.resetAllBiomeManagement(); resetScope = 0; rebuild(); + } + + private void rebuild() { if (list != null) scroll = list.firstIndex(); buttons.clear(); children.clear(); init(); } + @Override public void onClose() { minecraft.displayGuiScreen(parent); } @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 12, 0xFFFFFF); - drawCenteredString(font, new StringTextComponent(dimension), width / 2, 28, 0xCCCCCC); + drawCenteredString(font, title, width / 2, 9, 0xFFFFFF); + drawCenteredString(font, new StringTextComponent(dimension), width / 2, 23, 0xAAAAAA); super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java new file mode 100644 index 00000000..ef45f334 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java @@ -0,0 +1,163 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.text.TranslationTextComponent; + +/** Edits the complete set of controls already supported by one biome palette. */ +final class BiomePaletteSettingsScreen extends OreSpawnScreen { + private final Screen parent; + private final GeologyEditorSession session; + private final String paletteId; + private TextFieldWidget coverage; + private TextFieldWidget fallback; + private TextFieldWidget included; + private TextFieldWidget excluded; + + BiomePaletteSettingsScreen(Screen parent, GeologyEditorSession session, String paletteId) { + super(new TranslationTextComponent("screen.orespawn.biome_palette_settings")); + this.parent = parent; + this.session = session; + this.paletteId = paletteId; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + JsonObject palette = session.biomePaletteById(paletteId); + if (palette == null) { onClose(); return; } + int contentWidth = Math.min(430, Math.max(300, width - 20)); + int left = (width - contentWidth) / 2; + int half = (contentWidth - 4) / 2; + int y = 38; + addButton(CycleButton.onOffBuilder(bool(palette, "enabled", true)).create( + left, y, contentWidth, 20, new TranslationTextComponent("option.orespawn.enabled"), + (button, value) -> palette.addProperty("enabled", value))); + y += 24; + addButton(CycleButton.builder(this::modeName) + .withValues(Arrays.asList("augment", "replace")) + .withInitialValue(string(palette, "mode", "augment")) + .create(left, y, half, 20, new TranslationTextComponent("option.orespawn.biome_mode"), + (button, value) -> palette.addProperty("mode", value))); + addButton(CycleButton.builder(this::scopeName) + .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) + .withInitialValue(string(palette, "scope", "minecraft_only")) + .create(left + half + 4, y, half, 20, + new TranslationTextComponent("option.orespawn.biome_scope"), + (button, value) -> palette.addProperty("scope", value))); + y += 24; + addButton(CycleButton.builder(this::regionName) + .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) + .withInitialValue(string(palette, "region_size", "average")) + .create(left, y, contentWidth, 20, + new TranslationTextComponent("option.orespawn.biome_region_size"), + (button, value) -> palette.addProperty("region_size", value))); + y += 24; + coverage = field(left, y, half, decimal(palette, "coverage", 1.0D), + "option.orespawn.biome.coverage"); + fallback = field(left + half + 4, y, half, decimal(palette, "fallback_weight", 1.0D), + "option.orespawn.biome.fallback_weight"); + y += 24; + included = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + new TranslationTextComponent("option.orespawn.biome.include_namespaces"))); + included.setValue(join(palette.get("include_namespaces"))); + y += 24; + excluded = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + new TranslationTextComponent("option.orespawn.biome.exclude_namespaces"))); + excluded.setValue(join(palette.get("exclude_namespaces"))); + y += 24; + Button info = addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, + contentWidth, 20, new TranslationTextComponent("label.orespawn.biome.palette_entries", + session.biomePlacementIdsForPalette(paletteId).size()), button -> { }, + "tooltip.orespawn.biome.palette_entries_read_only")); + info.active = false; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private TextFieldWidget field(int x, int y, int width, double value, String label) { + TextFieldWidget field = addButton(new TextFieldWidget(font, x, y, width, 20, + new TranslationTextComponent(label))); + field.setValue(Double.toString(value)); + return field; + } + + private void save() { + JsonObject palette = session.biomePaletteById(paletteId); + if (palette == null) return; + putDecimal(palette, "coverage", coverage, 0.0D, 1.0D); + putDecimal(palette, "fallback_weight", fallback, 0.0D, 1000.0D); + palette.add("include_namespaces", namespaces(included)); + palette.add("exclude_namespaces", namespaces(excluded)); + } + + private static void putDecimal(JsonObject root, String key, TextFieldWidget field, + double min, double max) { + try { + double value = Double.parseDouble(field.getValue().trim()); + if (Double.isFinite(value)) root.addProperty(key, Math.max(min, Math.min(max, value))); + } catch (RuntimeException ignored) { } + } + + private static JsonArray namespaces(TextFieldWidget field) { + Set values = new LinkedHashSet<>(); + if (field != null) for (String raw : field.getValue().split(",")) { + String value = raw.trim().toLowerCase(java.util.Locale.ROOT); + if (value.matches("[a-z][a-z0-9_.-]{0,63}")) values.add(value); + } + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String join(JsonElement element) { + if (element == null || !element.isJsonArray()) return ""; + StringBuilder result = new StringBuilder(); + for (JsonElement value : element.getAsJsonArray()) { + if (result.length() > 0) result.append(", "); + result.append(value.getAsString()); + } + return result.toString(); + } + + private ITextComponent modeName(String value) { + return new TranslationTextComponent("value.orespawn.biome_mode." + value); + } + private ITextComponent scopeName(String value) { + return new TranslationTextComponent("value.orespawn.biome_scope." + value); + } + private ITextComponent regionName(String value) { + return new TranslationTextComponent("value.orespawn.preset." + value); + } + + @Override public void onClose() { save(); minecraft.displayGuiScreen(parent); } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, new StringTextComponent(paletteId), width / 2, 22, 0xAAAAAA); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } + + private static String string(JsonObject root, String key, String fallback) { + return root.has(key) ? root.get(key).getAsString() : fallback; + } + private static boolean bool(JsonObject root, String key, boolean fallback) { + return root.has(key) ? root.get(key).getAsBoolean() : fallback; + } + private static double decimal(JsonObject root, String key, double fallback) { + return root.has(key) ? root.get(key).getAsDouble() : fallback; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java index fba241a6..25b81403 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java @@ -15,6 +15,7 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } private final Screen parent; private final GeologyEditorSession session; private final String dimension; + private final String paletteId; private final String biomeId; private Tab tab = Tab.PLACEMENT; private TextFieldWidget weight; @@ -26,10 +27,21 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } BiomePlacementScreen(Screen parent, GeologyEditorSession session, String dimension, String biomeId) { + this(parent, session, dimension, paletteId(session, dimension), biomeId); + } + + private static String paletteId(GeologyEditorSession session, String dimension) { + if (session.biomePaletteId(dimension) == null) session.biomePalette(dimension, true); + return session.biomePaletteId(dimension); + } + + BiomePlacementScreen(Screen parent, GeologyEditorSession session, + String dimension, String paletteId, String biomeId) { super(new TranslationTextComponent("screen.orespawn.biome_placement")); this.parent = parent; this.session = session; this.dimension = dimension; + this.paletteId = paletteId; this.biomeId = biomeId; } @@ -48,7 +60,7 @@ protected void init() { selected -> { saveFields(); tab = value; rebuildWidgets(); })); button.active = value != tab; } - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); OreSpawnScreenLayout.explain(this, addButton(CycleButton.onOffBuilder( bool(placement, "enabled", true)) .create(left, 64, contentWidth, 20, @@ -62,7 +74,7 @@ protected void init() { half, 20, DialogTexts.GUI_DONE, button -> { saveFields(); onClose(); })); addButton(new Button(left + half + 5, OreSpawnScreenLayout.footerY(height), half, 20, new TranslationTextComponent("button.orespawn.remove"), button -> { - session.removeBiomePlacement(dimension, biomeId); + session.removeBiomePlacementByPalette(paletteId, biomeId); onClose(); })); } @@ -152,7 +164,7 @@ private void label(int x, int y, int labelWidth, String key) { } private void saveFields() { - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); if (weight != null) putDouble(placement, "weight", weight, 0.0D, 1000.0D); if (minTemperature != null) putDouble(placement, "min_temperature", minTemperature, -2.0D, 2.0D); if (maxTemperature != null) putDouble(placement, "max_temperature", maxTemperature, -2.0D, 2.0D); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java new file mode 100644 index 00000000..f672ecb8 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java @@ -0,0 +1,153 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.util.ResourceLocation; + +/** Stores one-to-one biome replacements in a palette applied after all other palettes. */ +final class BiomeReplacementRules { + static final String PREFIX = "orespawn:ui/biome_overrides/"; + + private BiomeReplacementRules() { } + + static String paletteId(String dimension) { + return PREFIX + dimension.toLowerCase(java.util.Locale.ROOT) + .replace(':', '_').replaceAll("[^a-z0-9_./-]", "_"); + } + + static boolean isOverridePalette(String id) { + return id != null && id.startsWith(PREFIX); + } + + static Map read(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes == null) return Collections.emptyMap(); + JsonElement value = palettes.get(paletteId(dimension)); + if (value == null || !value.isJsonObject()) return Collections.emptyMap(); + JsonObject palette = value.getAsJsonObject(); + Map result = new LinkedHashMap<>(); + JsonObject biomes = object(palette, "biomes", false); + if (biomes == null) return Collections.emptyMap(); + for (Entry target : biomes.entrySet()) { + if (!target.getValue().isJsonObject()) continue; + JsonElement sources = target.getValue().getAsJsonObject().get("similar_biomes"); + if (sources == null || !sources.isJsonArray()) continue; + for (JsonElement source : sources.getAsJsonArray()) { + try { result.put(new ResourceLocation(source.getAsString()).toString(), + new ResourceLocation(target.getKey()).toString()); } + catch (RuntimeException ignored) { } + } + } + return Collections.unmodifiableMap(result); + } + + static void set(JsonObject root, String dimension, String source, String target) { + String normalizedSource = new ResourceLocation(source).toString(); + String normalizedTarget = new ResourceLocation(target).toString(); + if (normalizedSource.equals(normalizedTarget)) { + throw new IllegalArgumentException("A biome cannot replace itself"); + } + Map mappings = new LinkedHashMap<>(read(root, dimension)); + String terminal = terminal(mappings, normalizedTarget, normalizedSource); + for (Entry entry : new ArrayList<>(mappings.entrySet())) { + if (normalizedSource.equals(entry.getValue())) mappings.put(entry.getKey(), terminal); + } + mappings.put(normalizedSource, terminal); + flatten(mappings); + write(root, dimension, mappings); + } + + static void remove(JsonObject root, String dimension, String source) { + Map mappings = new LinkedHashMap<>(read(root, dimension)); + mappings.remove(source); + write(root, dimension, mappings); + } + + static void clearDimension(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes != null) palettes.remove(paletteId(dimension)); + } + + private static String terminal(Map mappings, String start, String rejected) { + Set seen = new LinkedHashSet<>(); + String current = start; + while (mappings.containsKey(current)) { + if (!seen.add(current) || rejected.equals(current)) { + throw new IllegalArgumentException("Biome replacement cycle"); + } + current = mappings.get(current); + } + if (rejected.equals(current)) throw new IllegalArgumentException("Biome replacement cycle"); + return current; + } + + private static void flatten(Map mappings) { + for (String source : new ArrayList<>(mappings.keySet())) { + mappings.put(source, terminal(mappings, mappings.get(source), source)); + } + } + + private static void write(JsonObject root, String dimension, Map mappings) { + JsonObject palettes = object(root, "biome_palettes", true); + String id = paletteId(dimension); + if (mappings.isEmpty()) { + palettes.remove(id); + return; + } + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", dimension); + palette.addProperty("enabled", true); + palette.addProperty("mode", "replace"); + palette.addProperty("scope", "all"); + palette.addProperty("region_size", "tiny"); + palette.addProperty("coverage", 1.0D); + palette.addProperty("fallback_weight", 0.0D); + palette.add("include_namespaces", new JsonArray()); + palette.add("exclude_namespaces", new JsonArray()); + JsonObject biomes = new JsonObject(); + Map> byTarget = new LinkedHashMap<>(); + List sources = new ArrayList<>(mappings.keySet()); + Collections.sort(sources); + for (String source : sources) { + byTarget.computeIfAbsent(mappings.get(source), ignored -> new ArrayList<>()).add(source); + } + List targets = new ArrayList<>(byTarget.keySet()); + Collections.sort(targets); + for (String target : targets) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray similar = new JsonArray(); + for (String source : byTarget.get(target)) similar.add(new JsonPrimitive(source)); + placement.add("similar_biomes", similar); + placement.add("required_similar_biomes", new JsonArray()); + placement.addProperty("min_temperature", -2.0D); + placement.addProperty("max_temperature", 2.0D); + placement.addProperty("min_downfall", 0.0D); + placement.addProperty("max_downfall", 1.0D); + biomes.add(target, placement); + } + palette.add("biomes", biomes); + palettes.add(id, palette); + } + + private static JsonObject object(JsonObject root, String key, boolean create) { + if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key); + if (!create) return null; + JsonObject value = new JsonObject(); + root.add(key, value); + return value; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java index 031f1757..24aafc2f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java @@ -1,22 +1,44 @@ package zone.moddev.mc.orespawn.client; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; -import com.google.gson.JsonObject; + +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.BiomeEntry; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Placement; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Snapshot; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Status; + import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.resources.I18n; import net.minecraft.util.text.ITextComponent; import net.minecraft.util.text.StringTextComponent; import net.minecraft.util.text.TranslationTextComponent; -/** Dimension-level entry point for biome placement and world materials. */ +/** Responsive directory for biome placement, exact replacements, and diagnostics. */ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { + static final int TWO_PANE_MINIMUM = 400; + private static final int CONTENT_TOP = 48; private final Screen parent; private final GeologyEditorSession session; private String dimension; + private String selectedId; + private boolean showAll; + private boolean compactDetail; + private boolean resetArmed; + private String message; + private int biomeScroll; + private int ruleScroll; + private CompactScrollList biomeList; + private CompactScrollList ruleList; + private int detailX; + private int detailWidth; + private int detailRuleTop; + private int detailMessageTop; + private List detailMessageLines = java.util.Collections.emptyList(); BiomeWorldMaterialsScreen(Screen parent, GeologyEditorSession session) { - super(new TranslationTextComponent("screen.orespawn.biomes_world_materials")); + super(new TranslationTextComponent("screen.orespawn.biomes")); this.parent = parent; this.session = session; this.dimension = session.availableDimensionIds().get(0); @@ -25,126 +47,285 @@ final class BiomeWorldMaterialsScreen extends OreSpawnScreen { @Override protected void init() { OreSpawnScreenLayout.beginHelp(this); - int contentWidth = Math.min(390, Math.max(280, width - 24)); - int left = (width - contentWidth) / 2; - int half = (contentWidth - 5) / 2; - List dimensions = session.availableDimensionIds(); + Snapshot snapshot = session.biomeDirectory(); + List dimensions = new ArrayList<>(snapshot.dimensions); + if (dimensions.isEmpty()) dimensions.add("minecraft:overworld"); if (!dimensions.contains(dimension)) dimension = dimensions.get(0); - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::dimensionName) - .withValues(dimensions).withInitialValue(dimension) - .create(left, 34, contentWidth, 20, - new TranslationTextComponent("option.orespawn.dimension"), - (button, value) -> { dimension = value; rebuildWidgets(); })), - "tooltip.orespawn.biome.dimension"); - - JsonObject palette = session.biomePalette(dimension, false); - boolean active = palette != null && bool(palette, "enabled", false); - String mode = palette == null ? "augment" : string(palette, "mode", "augment"); - String scope = palette == null ? "minecraft_only" : string(palette, "scope", "minecraft_only"); - String size = palette == null ? "average" : string(palette, "region_size", "average"); - int y = 64; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.onOffBuilder(active) - .create(left, y, contentWidth, 20, - new TranslationTextComponent("option.orespawn.biome_palette"), - (button, value) -> setPaletteEnabled(value))), - "tooltip.orespawn.biome.palette_enabled"); - y += 26; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::modeName) - .withValues(Arrays.asList("augment", "replace")).withInitialValue(mode) - .create(left, y, half, 20, - new TranslationTextComponent("option.orespawn.biome_mode"), - (button, value) -> setPalette("mode", value))), - "tooltip.orespawn.biome.mode"); - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::scopeName) - .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) - .withInitialValue(scope) - .create(left + half + 5, y, half, 20, - new TranslationTextComponent("option.orespawn.biome_scope"), - (button, value) -> setPalette("scope", value))), - "tooltip.orespawn.biome.scope"); - y += 26; - OreSpawnScreenLayout.explain(this, addButton(CycleButton.builder(this::regionName) - .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) - .withInitialValue(size) - .create(left, y, contentWidth, 20, - new TranslationTextComponent("option.orespawn.biome_region_size"), - (button, value) -> setPalette("region_size", value))), - "tooltip.orespawn.biome.region_size"); - y += 30; - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, half, 20, - new TranslationTextComponent("button.orespawn.biome_palette_count", - session.biomePlacementIds(dimension).size()), - button -> minecraft.displayGuiScreen(new BiomePaletteScreen(this, session, dimension)), - "tooltip.orespawn.biome.entries")); - addButton(OreSpawnScreenLayout.explainedButton(this, font, left + half + 5, y, half, 20, - new TranslationTextComponent("button.orespawn.dimension_materials"), - button -> minecraft.displayGuiScreen(new DimensionMaterialsScreen(this, session, dimension)), + List entries = snapshot.entries(dimension, showAll); + if (selectedId == null || find(entries, selectedId) == null) { + selectedId = entries.isEmpty() ? null : entries.get(0).id; + } + boolean compact = width < TWO_PANE_MINIMUM; + if (compact && compactDetail && selectedId != null) initDetail(snapshot, true); + else initDirectory(snapshot, entries, compact); + } + + private void initDirectory(Snapshot snapshot, List entries, boolean compact) { + int contentWidth = contentWidth(width); + int left = (width - contentWidth) / 2; + int leftWidth = compact ? contentWidth : leftPaneWidth(width); + int headerWidth = Math.max(76, Math.min(leftWidth - 74, 150)); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, 24, headerWidth, 20, + dimensionSelectorLabel(dimension), button -> cycleDimension(snapshot), + "tooltip.orespawn.biome.dimension")); + addButton(OreSpawnScreenLayout.button(this, font, left + leftWidth - 68, 24, 68, 20, + new TranslationTextComponent(showAll ? "button.orespawn.biome.hide_routine" + : "button.orespawn.show_all"), button -> { + showAll = !showAll; selectedId = null; biomeScroll = 0; rebuild(); + })); + int listHeight = listHeight(height); + biomeList = addButton(new CompactScrollList(this, left, CONTENT_TOP, leftWidth, listHeight) { + @Override protected int size() { return entries.size(); } + @Override protected String rowText(int index) { + BiomeEntry entry = entries.get(index); + return statusMarker(entry.status) + ' ' + entry.name; + } + @Override protected int rowColor(int index) { return statusColor(entries.get(index).status); } + @Override protected boolean rowSelected(int index) { return entries.get(index).id.equals(selectedId); } + @Override protected void onRowPressed(int index) { + selectedId = entries.get(index).id; + if (compact) compactDetail = true; + ruleScroll = 0; resetArmed = false; clearMessage(); rebuild(); + } + @Override protected List rowTooltip(int index) { return biomeTooltip(entries.get(index)); } + }); + biomeList.setFirstIndex(biomeScroll); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, + materialsButtonY(height), leftWidth, 20, + new TranslationTextComponent("button.orespawn.dimension_materials_named", + dimensionName(dimension)), + button -> minecraft.displayGuiScreen( + new DimensionMaterialsScreen(this, session, dimension)), "tooltip.orespawn.biome.dimension_materials")); - y += 26; - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, contentWidth, 20, - new TranslationTextComponent("button.orespawn.geome_influences"), - button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)), - "tooltip.orespawn.biome.geome_influences")); - addButton(new Button(width / 2 - 75, OreSpawnScreenLayout.footerY(height), - 150, 20, DialogTexts.GUI_DONE, button -> onClose())); - } - - private void setPaletteEnabled(boolean enabled) { - JsonObject palette = session.biomePalette(dimension, true); - if (enabled && session.biomePlacementIds(dimension).isEmpty()) { - minecraft.displayGuiScreen(new BiomePickerScreen(this, session, - id -> { - session.addBiomePlacement(dimension, id); - minecraft.displayGuiScreen(new BiomePlacementScreen(this, session, dimension, id)); + if (!compact) initDetail(snapshot, false); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private void initDetail(Snapshot snapshot, boolean compact) { + BiomeEntry selected = find(snapshot.entries(dimension, true), selectedId); + if (selected == null) return; + int contentWidth = contentWidth(width); + int contentLeft = (width - contentWidth) / 2; + int leftWidth = compact ? 0 : leftPaneWidth(width); + int x = compact ? contentLeft : contentLeft + leftWidth + 10; + int paneWidth = compact ? contentWidth : contentWidth - leftWidth - 10; + detailX = x; + detailWidth = paneWidth; + int y = detailTop(compact); + if (compact) { + addButton(OreSpawnScreenLayout.button(this, font, contentLeft, 8, 48, 20, + new TranslationTextComponent("button.orespawn.back"), button -> { + captureScroll(); compactDetail = false; rebuild(); })); - return; } - palette.addProperty("enabled", enabled); + ITextComponent replacement = selected.replacementTarget == null + ? new TranslationTextComponent("button.orespawn.biome.replace_new_terrain") + : new TranslationTextComponent("button.orespawn.biome.replace_with", selected.replacementTarget); + addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 35, + paneWidth, 20, replacement, button -> chooseReplacement(selected), + "tooltip.orespawn.biome.no_retrogen")); + if (selected.replacementTarget != null) { + addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 59, + paneWidth, 20, + new TranslationTextComponent("button.orespawn.biome.leave_original"), + button -> { session.leaveBiomeOriginal(dimension, selected.id); clearMessage(); rebuild(); }, + "tooltip.orespawn.biome.no_retrogen")); + } + detailMessageLines = wrapMessage(message, paneWidth - 6); + detailMessageTop = detailMessageTop(compact, selected.replacementTarget != null); + int ruleTop = detailRuleTop(compact, selected.replacementTarget != null, + detailMessageLines.size()); + detailRuleTop = ruleTop; + int controlsTop = controlsTop(height, ruleTop); + int ruleHeight = Math.max(16, controlsTop - ruleTop - 4); + List placements = selected.placements; + ruleList = addButton(new CompactScrollList(this, x, ruleTop, paneWidth, ruleHeight) { + @Override protected int size() { return placements.size(); } + @Override protected String rowText(int index) { + Placement rule = placements.get(index); + return (rule.enabled ? "[x] " : "[ ] ") + rule.paletteId + " #" + (rule.order + 1) + + (rule.effectiveSurface ? " *" : ""); + } + @Override protected int rowColor(int index) { return placements.get(index).enabled ? 0xFFFFFF : 0x888888; } + @Override protected void onRowPressed(int index) { + Placement rule = placements.get(index); + minecraft.displayGuiScreen(new BiomePlacementScreen(BiomeWorldMaterialsScreen.this, + session, dimension, rule.paletteId, selected.id)); + } + @Override protected List rowTooltip(int index) { return placementTooltip(placements.get(index)); } + }); + ruleList.setFirstIndex(ruleScroll); + int half = (paneWidth - 4) / 2; + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop, half, 20, + new TranslationTextComponent("button.orespawn.biome.palettes", snapshot.palettes(dimension).size()), + button -> minecraft.displayGuiScreen(new BiomePaletteScreen(this, session, dimension)))); + addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop, half, 20, + new TranslationTextComponent(resetArmed ? "button.orespawn.biome.confirm_reset" + : "button.orespawn.biome.reset_selected"), button -> resetSelected(selected))); + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop + 24, paneWidth, 20, + new TranslationTextComponent("button.orespawn.geome_influences"), + button -> minecraft.displayGuiScreen(new GeomeBiomeScreen(this, session)))); } - private void setPalette(String key, String value) { - session.biomePalette(dimension, true).addProperty(key, value); + private void chooseReplacement(BiomeEntry selected) { + captureScroll(); + minecraft.displayGuiScreen(new BiomePickerScreen(this, session, target -> { + try { + session.replaceBiome(dimension, selected.id, target); + clearMessage(); + } catch (IllegalArgumentException failure) { + message = failure.getMessage(); + } + minecraft.displayGuiScreen(this); + })); } - private ITextComponent dimensionName(String value) { - return new StringTextComponent(value); + private void resetSelected(BiomeEntry selected) { + if (!resetArmed) { resetArmed = true; rebuild(); return; } + session.resetBiome(dimension, selected.id); + resetArmed = false; clearMessage(); rebuild(); } - private ITextComponent modeName(String value) { - return new TranslationTextComponent("value.orespawn.biome_mode." + value); + private void cycleDimension(Snapshot snapshot) { + dimension = nextDimension(snapshot.dimensions, dimension); + selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; clearMessage(); rebuild(); } - private ITextComponent scopeName(String value) { - return new TranslationTextComponent("value.orespawn.biome_scope." + value); + private void clearMessage() { + message = null; } - private ITextComponent regionName(String value) { - return new TranslationTextComponent("value.orespawn.preset." + value); + private List wrapMessage(String text, int maximumWidth) { + if (text == null || text.isEmpty()) return java.util.Collections.emptyList(); + List wrapped = font.listFormattedStringToWidth(text, Math.max(40, maximumWidth)); + if (wrapped.size() <= 2) return wrapped; + List result = new ArrayList<>(wrapped.subList(0, 2)); + result.set(1, OreSpawnScreenLayout.fit(font, + new StringTextComponent(result.get(1) + "..."), Math.max(40, maximumWidth))); + return result; } - private void rebuildWidgets() { - buttons.clear(); children.clear(); - init(); + static String nextDimension(Iterable available, String current) { + List dimensions = new ArrayList<>(); + for (String candidate : available) dimensions.add(candidate); + if (dimensions.isEmpty()) return "minecraft:overworld"; + int index = dimensions.indexOf(current); + return dimensions.get((Math.max(-1, index) + 1) % dimensions.size()); } - @Override - public void onClose() { - minecraft.displayGuiScreen(parent); + private static BiomeEntry find(List entries, String id) { + if (id != null) for (BiomeEntry entry : entries) if (id.equals(entry.id)) return entry; + return null; } + static ITextComponent dimensionName(String dimension) { + switch (dimension) { + case "minecraft:overworld": return new TranslationTextComponent("value.orespawn.dimension.overworld"); + case "minecraft:the_nether": return new TranslationTextComponent("value.orespawn.dimension.the_nether"); + case "minecraft:the_end": return new TranslationTextComponent("value.orespawn.dimension.the_end"); + default: return new StringTextComponent(dimension); + } + } + + private static ITextComponent dimensionSelectorLabel(String dimension) { + return new StringTextComponent("< " + dimensionName(dimension).getFormattedText() + " >"); + } + + private static String statusMarker(Status status) { + switch (status) { + case USER_REPLACED: return "R"; + case LAYERED: return "!"; + case DISABLED: return "-"; + case MISSING: return "?"; + case PROVIDER_MANAGED: return "+"; + default: return " "; + } + } + + private static int statusColor(Status status) { + switch (status) { + case MISSING: return 0xFF5555; + case USER_REPLACED: return 0x55FFFF; + case LAYERED: return 0xFFFF55; + case DISABLED: return 0xAAAAAA; + case PROVIDER_MANAGED: return 0x55FF55; + default: return 0xFFFFFF; + } + } + + private static List biomeTooltip(BiomeEntry entry) { + List lines = new ArrayList<>(); + lines.add(entry.name + " (" + entry.id + ")"); + lines.add(entry.ownerName + " " + entry.ownerVersion); + lines.add(I18n.format("value.orespawn.biome.status." + entry.status.name().toLowerCase(java.util.Locale.ROOT))); + lines.add(I18n.format("label.orespawn.biome.rule_count", entry.placements.size())); + if (entry.replacementTarget != null) lines.add(I18n.format("label.orespawn.biome.replaced_by", entry.replacementTarget)); + return lines; + } + + private static List placementTooltip(Placement rule) { + List lines = new ArrayList<>(); + lines.add(rule.paletteId); + lines.add(I18n.format("label.orespawn.biome.palette_owner_order", rule.owner, rule.order + 1)); + if (rule.hasSurface) lines.add(I18n.format(rule.effectiveSurface + ? "label.orespawn.biome.surface_effective" : "label.orespawn.biome.surface_overridden")); + return lines; + } + + private void captureScroll() { + if (biomeList != null) biomeScroll = biomeList.firstIndex(); + if (ruleList != null) ruleScroll = ruleList.firstIndex(); + } + + private void rebuild() { captureScroll(); buttons.clear(); children.clear(); init(); } + + @Override public void onClose() { minecraft.displayGuiScreen(parent); } + @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 14, 0xFFFFFF); + drawCenteredString(font, title, width / 2, 9, 0xFFFFFF); + BiomeEntry selected = find(session.biomeDirectory().entries(dimension, true), selectedId); + if (selected != null && (width >= TWO_PANE_MINIMUM || compactDetail)) { + int contentWidth = contentWidth(width); + int x = width < TWO_PANE_MINIMUM ? (width - contentWidth) / 2 + : (width - contentWidth) / 2 + leftPaneWidth(width) + 10; + int y = detailTop(width < TWO_PANE_MINIMUM); + drawString(font, new StringTextComponent(selected.name), x + 3, y, 0xFFFFFF); + drawString(font, new StringTextComponent(selected.id), x + 3, y + 11, 0xAAAAAA); + drawString(font, new TranslationTextComponent("value.orespawn.biome.status." + + selected.status.name().toLowerCase(java.util.Locale.ROOT)), x + 3, y + 22, + statusColor(selected.status)); + for (int line = 0; line < detailMessageLines.size(); line++) { + drawString(font, new StringTextComponent(detailMessageLines.get(line)), + detailX + 3, detailMessageTop + (line * 10), 0xFF5555); + } + drawString(font, new TranslationTextComponent("label.orespawn.biome.placement_rules"), + detailX + 3, detailRuleTop - 10, 0xCCCCCC); + } super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } - private static String string(JsonObject root, String key, String fallback) { - return root.has(key) ? root.get(key).getAsString() : fallback; + static int contentWidth(int width) { return Math.min(520, Math.max(300, width - 20)); } + static int leftPaneWidth(int width) { return Math.max(132, (contentWidth(width) * 43) / 100); } + static int listHeight(int height) { return Math.max(32, materialsButtonY(height) - CONTENT_TOP - 4); } + static int materialsButtonY(int height) { return height - 52; } + static int detailTop(boolean compact) { return compact ? 34 : 24; } + static int detailRuleTop(boolean compact, boolean hasReplacement) { + return detailTop(compact) + (hasReplacement ? 95 : 71); + } + + static int detailMessageTop(boolean compact, boolean hasReplacement) { + return detailRuleTop(compact, hasReplacement) - 12; + } + + static int detailRuleTop(boolean compact, boolean hasReplacement, int messageLines) { + return detailRuleTop(compact, hasReplacement) + (Math.max(0, Math.min(2, messageLines)) * 10); } - private static boolean bool(JsonObject root, String key, boolean fallback) { - return root.has(key) ? root.get(key).getAsBoolean() : fallback; + static int controlsTop(int height, int ruleTop) { + return Math.max(ruleTop + 34, height - 76); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/Button.java b/src/main/java/zone/moddev/mc/orespawn/client/Button.java index e078e761..632af2f2 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/Button.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/Button.java @@ -2,7 +2,7 @@ import net.minecraft.util.text.ITextComponent; -/** 1.14 string-widget bridge used by the target-local editor screens. */ +/** Adds callbacks and tooltips to Minecraft 1.14's button. */ class Button extends net.minecraft.client.gui.widget.button.Button { private final Tooltip tooltip; diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java new file mode 100644 index 00000000..8b3f8572 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java @@ -0,0 +1,26 @@ +package zone.moddev.mc.orespawn.client; + + +/** A small cog button for opening settings. */ +final class CogButton extends Button { + CogButton(int x, int y, IPressable onPress, Tooltip tooltip) { + super(x, y, 22, 20, "", onPress, tooltip); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + super.render(mouseX, mouseY, partialTick); + if (!visible) return; + drawCog(x + 11, y + 10, active); + } + + static void drawCog(int centerX, int centerY, boolean active) { + int color = active ? 0xFFE0E0E0 : 0xFF777777; + fill(centerX - 4, centerY - 4, centerX + 5, centerY + 5, color); + fill(centerX - 1, centerY - 6, centerX + 2, centerY - 4, color); + fill(centerX - 1, centerY + 5, centerX + 2, centerY + 7, color); + fill(centerX - 6, centerY - 1, centerX - 4, centerY + 2, color); + fill(centerX + 5, centerY - 1, centerX + 7, centerY + 2, color); + fill(centerX - 2, centerY - 2, centerX + 3, centerY + 3, 0xFF303030); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java new file mode 100644 index 00000000..17d07069 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java @@ -0,0 +1,290 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.Collections; +import java.util.List; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; + +/** A compact text list with independent input and scrolling for each pane. */ +abstract class CompactScrollList extends Button { + static final int DEFAULT_ROW_HEIGHT = 16; + private static final int SCROLLBAR_WIDTH = 10; + private static final int ARROW_HEIGHT = 9; + private static final int MIN_THUMB_HEIGHT = 8; + + private final OreSpawnScreen owner; + private final int rowHeight; + private int firstIndex; + private boolean dragging; + private int dragOffset; + private int hoveredIndex = -1; + private boolean hoveredAction; + private boolean pointerInside; + + CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height) { + this(owner, x, y, width, height, DEFAULT_ROW_HEIGHT); + } + + CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height, int rowHeight) { + super(x, y, width, height, "", null); + this.owner = owner; + this.rowHeight = Math.max(12, rowHeight); + } + + protected abstract int size(); + + protected abstract String rowText(int index); + + protected void onRowPressed(int index) { } + + protected boolean rowEnabled(int index) { + return true; + } + + protected boolean rowSelected(int index) { + return false; + } + + protected int rowColor(int index) { + return rowEnabled(index) ? 0xFFFFFF : 0x777777; + } + + protected List rowTooltip(int index) { + return Collections.singletonList(rowText(index)); + } + + protected int rowActionWidth(int index) { + return 0; + } + + protected void drawRowAction(Minecraft minecraft, int index, int x, int y, + int width, int height, boolean hovered) { } + + protected void onRowActionPressed(int index) { } + + protected List rowActionTooltip(int index) { + return rowTooltip(index); + } + + @Override + public void render(int mouseX, int mouseY, float partialTicks) { + if (!visible) return; + Minecraft minecraft = Minecraft.getInstance(); + clampFirstIndex(); + pointerInside = contains(mouseX, mouseY); + + fill(x, y, x + width, y + height, 0x76000000); + hoveredIndex = rowAt(mouseX, mouseY); + hoveredAction = hoveredIndex >= 0 && isInRowAction(hoveredIndex, mouseX); + FontRenderer font = minecraft.fontRenderer; + int rows = visibleRows(); + int contentRight = contentRight(); + for (int row = 0; row < rows; row++) { + int index = firstIndex + row; + if (index >= size()) break; + int rowY = y + (row * rowHeight); + boolean hovered = index == hoveredIndex; + if (rowSelected(index)) { + fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0xA052648C); + } else if (hovered) { + fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0x80404040); + } + int actionWidth = Math.max(0, rowActionWidth(index)); + int textWidth = Math.max(0, contentRight - x - actionWidth - 8); + String text = fit(font, rowText(index), textWidth); + font.drawStringWithShadow(text, x + 4, + rowY + Math.max(2, (rowHeight - 8) / 2), rowColor(index)); + if (actionWidth > 0) { + int actionX = contentRight - actionWidth; + drawRowAction(minecraft, index, actionX, rowY, + actionWidth, rowHeight - 1, hovered && mouseX >= actionX); + } + } + drawScrollbar(minecraft, mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button != 0 || !visible || !active || !contains((int) mouseX, (int) mouseY)) return false; + int x = (int) mouseX; + int y = (int) mouseY; + if (x >= contentRight()) { + pressScrollbar(y); + return true; + } + int index = rowAt(x, y); + if (index < 0) return true; + if (isInRowAction(index, x)) onRowActionPressed(index); + else if (rowEnabled(index)) onRowPressed(index); + return true; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + dragging = false; + return button == 0 && contains((int) mouseX, (int) mouseY); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, + double deltaX, double deltaY) { + if (!dragging || button != 0) return false; + dragTo((int) mouseY - dragOffset); + return true; + } + + void renderTooltip(int mouseX, int mouseY) { + if (!visible || hoveredIndex < 0 || !contains(mouseX, mouseY)) return; + List lines = hoveredAction ? rowActionTooltip(hoveredIndex) + : rowTooltip(hoveredIndex); + if (lines != null && !lines.isEmpty()) owner.renderStringTooltip(lines, mouseX, mouseY); + } + + boolean mouseWheel(int mouseX, int mouseY, int delta) { + if (!visible || delta == 0 || !contains(mouseX, mouseY)) return false; + scrollBy(delta > 0 ? -3 : 3); + return true; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + if (!visible || !active || !contains((int) mouseX, (int) mouseY) || delta == 0.0D) return false; + scrollBy(delta > 0.0D ? -3 : 3); + return true; + } + + void scrollBy(int rows) { + setFirstIndex(firstIndex + rows); + } + + int firstIndex() { + return firstIndex; + } + + void setFirstIndex(int index) { + firstIndex = Math.max(0, Math.min(index, maxFirst(size(), visibleRows()))); + } + + void ensureVisible(int index) { + if (index < firstIndex) setFirstIndex(index); + else if (index >= firstIndex + visibleRows()) setFirstIndex(index - visibleRows() + 1); + } + + int visibleRows() { + return visibleRows(height, rowHeight); + } + + int rowY(int index) { + return index < firstIndex || index >= firstIndex + visibleRows() + ? -1 : y + ((index - firstIndex) * rowHeight); + } + + int contentRight() { + return x + width - SCROLLBAR_WIDTH; + } + + private boolean contains(int mouseX, int mouseY) { + return mouseX >= x && mouseX < x + width + && mouseY >= y && mouseY < y + height; + } + + private int rowAt(int mouseX, int mouseY) { + if (!contains(mouseX, mouseY) || mouseX >= contentRight()) return -1; + int index = firstIndex + ((mouseY - y) / rowHeight); + return index >= 0 && index < size() ? index : -1; + } + + private boolean isInRowAction(int index, int mouseX) { + int actionWidth = Math.max(0, rowActionWidth(index)); + return actionWidth > 0 && mouseX >= contentRight() - actionWidth; + } + + private void pressScrollbar(int mouseY) { + if (mouseY < y + ARROW_HEIGHT) { + scrollBy(-1); + return; + } + if (mouseY >= y + height - ARROW_HEIGHT) { + scrollBy(1); + return; + } + int thumbY = thumbY(); + int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight()); + if (mouseY >= thumbY && mouseY < thumbY + thumbHeight) { + dragging = true; + dragOffset = mouseY - thumbY; + } else { + scrollBy(mouseY < thumbY ? -visibleRows() : visibleRows()); + } + } + + private void dragTo(int thumbTop) { + int maximum = maxFirst(size(), visibleRows()); + int track = trackHeight(); + int thumb = thumbHeight(size(), visibleRows(), track); + int travel = Math.max(1, track - thumb); + int relative = Math.max(0, Math.min(travel, thumbTop - (y + ARROW_HEIGHT))); + setFirstIndex(firstFromThumb(relative, travel, maximum)); + } + + private int trackHeight() { + return Math.max(1, height - (ARROW_HEIGHT * 2)); + } + + private int thumbY() { + int maximum = maxFirst(size(), visibleRows()); + int track = trackHeight(); + int thumb = thumbHeight(size(), visibleRows(), track); + int travel = Math.max(0, track - thumb); + return y + ARROW_HEIGHT + (maximum == 0 ? 0 + : Math.round((float) firstIndex * travel / maximum)); + } + + private void drawScrollbar(Minecraft minecraft, int mouseX, int mouseY) { + int left = contentRight(); + int right = x + width; + fill(left, y, right, y + height, 0xB0202020); + FontRenderer font = minecraft.fontRenderer; + drawCenteredString(font, "^", left + (SCROLLBAR_WIDTH / 2), y, 0xDDDDDD); + drawCenteredString(font, "v", left + (SCROLLBAR_WIDTH / 2), + y + height - ARROW_HEIGHT, 0xDDDDDD); + int thumbY = thumbY(); + int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight()); + fill(left + 1, thumbY, right - 1, thumbY + thumbHeight, + mouseX >= left && mouseX < right && mouseY >= thumbY + && mouseY < thumbY + thumbHeight ? 0xFFE0E0E0 : 0xFF909090); + } + + private void clampFirstIndex() { + setFirstIndex(firstIndex); + } + + private static String fit(FontRenderer font, String text, int width) { + if (width <= 0) return ""; + if (font.getStringWidth(text) <= width) return text; + String suffix = "..."; + return font.trimStringToWidth(text, + Math.max(0, width - font.getStringWidth(suffix))) + suffix; + } + + static int visibleRows(int height, int rowHeight) { + return Math.max(1, Math.max(1, height) / Math.max(1, rowHeight)); + } + + static int maxFirst(int entries, int visibleRows) { + return Math.max(0, Math.max(0, entries) - Math.max(1, visibleRows)); + } + + static int thumbHeight(int entries, int visibleRows, int trackHeight) { + if (entries <= visibleRows) return Math.max(1, trackHeight); + return Math.max(MIN_THUMB_HEIGHT, + Math.min(trackHeight, (trackHeight * Math.max(1, visibleRows)) / Math.max(1, entries))); + } + + static int firstFromThumb(int relative, int travel, int maximum) { + if (travel <= 0 || maximum <= 0) return 0; + return Math.max(0, Math.min(maximum, + Math.round((float) relative * maximum / travel))); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java index 29d8a206..c70b5cb5 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/DimensionMaterialsScreen.java @@ -13,7 +13,8 @@ final class DimensionMaterialsScreen extends OreSpawnScreen { private TextFieldWidget deepY; DimensionMaterialsScreen(Screen parent, GeologyEditorSession session, String dimension) { - super(new TranslationTextComponent("screen.orespawn.dimension_materials")); + super(new TranslationTextComponent("screen.orespawn.dimension_materials", + BiomeWorldMaterialsScreen.dimensionName(dimension))); this.parent = parent; this.session = session; this.dimension = dimension; @@ -73,8 +74,8 @@ private ITextComponent label(String key, String value) { } private void save() { - // Minecraft 1.14.4 exposes one generator fluid. Retain stored deep-aquifer - // fields unchanged so the same provider/profile can still be used by later ports. + // Minecraft 1.14.4 exposes one generator fluid. + // Leave deep-aquifer fields untouched so newer versions can still use this profile. } private void rebuildWidgets() { buttons.clear(); children.clear(); init(); } @@ -83,8 +84,12 @@ private void save() { @Override public void render(int mouseX, int mouseY, float partialTick) { renderBackground(); - drawCenteredString(font, title, width / 2, 12, 0xFFFFFF); - drawCenteredString(font, new StringTextComponent(dimension), width / 2, 30, 0xCCCCCC); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, new StringTextComponent(dimension), width / 2, 23, 0xAAAAAA); + drawCenteredString(font, new StringTextComponent(OreSpawnScreenLayout.fit(font, + new TranslationTextComponent("label.orespawn.dimension_materials.scope", + BiomeWorldMaterialsScreen.dimensionName(dimension)), width - 16)), + width / 2, 38, 0xFFFF55); super.render(mouseX, mouseY, partialTick); OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index 86d1bf8f..30f9642e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -5,7 +5,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -17,6 +19,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import zone.moddev.mc.orespawn.worldgen.OreHeightDistribution; import zone.moddev.mc.orespawn.worldgen.OrePattern; import zone.moddev.mc.orespawn.worldgen.GeomeConfig; @@ -24,15 +27,18 @@ import zone.moddev.mc.orespawn.init.OreSpawnPatterns; import zone.moddev.mc.orespawn.api.OreDimensionSelector; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; import net.minecraft.util.ResourceLocation; import net.minecraft.util.math.BlockPos; import net.minecraft.world.EmptyBlockReader; import net.minecraft.block.Block; -import net.minecraft.block.Blocks; import net.minecraft.block.FlowingFluidBlock; +import net.minecraft.block.Blocks; import net.minecraft.item.Items; import net.minecraftforge.registries.ForgeRegistries; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Mutable client-side copy used until the Create World settings are accepted. */ final class GeologyEditorSession { @@ -57,6 +63,8 @@ enum MaterialTab { private final WorldGeologyProfile originalProfile; private final JsonObject original; private final JsonObject root; + private JsonObject oreSourceOriginal; + private final BiomeProviderDefaultsSnapshot biomeProviderDefaults; private final Set availableDimensionIds = new TreeSet<>(); GeologyEditorSession(WorldGeologyProfile profile) { @@ -64,11 +72,19 @@ enum MaterialTab { } GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions) { + this(profile, dimensions, WorldgenIntegrationManager.biomeProviderDefaults()); + } + + GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions, + BiomeProviderDefaultsSnapshot defaults) { originalProfile = profile; + biomeProviderDefaults = defaults; original = profile.rootCopy(); root = profile.rootCopy(); normalizeRegistrySections(original); normalizeRegistrySections(root); + oreSourceOriginal = profile.rootCopy(); + normalizeRegistrySections(oreSourceOriginal); rememberDimension("minecraft:overworld"); rememberDimension("minecraft:the_nether"); rememberDimension("minecraft:the_end"); @@ -91,6 +107,44 @@ void applyProfile(WorldGeologyProfile profile) { normalizeRegistrySections(root); } + void setManageVanillaOres(boolean enabled) { + WorldGeologyProfile current = profile(); + JsonObject refreshed = current.rootCopy(); + refreshed.addProperty("manage_vanilla_ores", enabled); + applyProfile(WorldGeologyProfile.fromGlobalConfig(refreshed, + current.geologyMode(), current.placeFluidDeposits())); + synchronizeVanillaPlacementSources(enabled); + } + + private void synchronizeVanillaPlacementSources(boolean enabled) { + JsonObject ores = section("ores"); + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + JsonObject placements = objectEntry(policy, "placement_sources"); + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + if (!ores.has(sourceId) || !ores.get(sourceId).isJsonObject() + || !bool(ores.getAsJsonObject(sourceId), "native_generation", false)) continue; + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (enabled && bool(candidate, "loaded", false) + && bool(candidate, "placement_active", false) + && !bool(candidate, "external", false) + && !bool(candidate, "enrichment", false) + && !placements.has(channel)) { + placements.addProperty(channel, sourceId); + } else if (!enabled && placements.has(channel) + && sourceId.equals(string(placements, channel, ""))) { + placements.remove(channel); + } + } + refreshOreSourceStatus(policy); + } + } + JsonObject section(String key) { return object(root, key); } @@ -121,6 +175,13 @@ List availableDimensionIds() { if (validResource(id)) result.add(new ResourceLocation(id).toString()); } } + for (String sectionName : Arrays.asList("biome_palettes", "dimension_materials")) { + for (Entry entry : section(sectionName).entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + String id = string(entry.getValue().getAsJsonObject(), "dimension", ""); + if (validResource(id)) result.add(new ResourceLocation(id).toString()); + } + } List ordered = new ArrayList<>(); for (String vanilla : Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")) { ordered.add(vanilla); @@ -340,6 +401,775 @@ void disableOrRemoveOre(String id) { } } + List oreSourceGroups() { + List result = new ArrayList<>(); + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + String material = string(policy, "material", ""); + String domain = string(policy, "domain", ""); + if (!validResource(material) || !validResource(domain)) continue; + JsonObject groupDefinitions = section("ore_material_groups"); + JsonObject groupDefinition = groupDefinitions.has(material) + && groupDefinitions.get(material).isJsonObject() + ? groupDefinitions.getAsJsonObject(material) : new JsonObject(); + String displayName = string(groupDefinition, "display_name", displayName(material)); + List groupDictionary = stringList(groupDefinition, "block_tag_entries"); + boolean curated = "orespawn:sulfur".equals(material) || "orespawn:aluminum".equals(material); + List candidates = new ArrayList<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String registryId = string(candidate, "registry_id", ""); + if (sourceId.isEmpty() || !validResource(channel) || !validResource(registryId)) continue; + List dictionary = new ArrayList<>(); + if (candidate.has("block_tags") && candidate.get("block_tags").isJsonArray()) { + for (JsonElement name : candidate.getAsJsonArray("block_tags")) { + try { dictionary.add(name.getAsString()); } + catch (RuntimeException ignored) { } + } + } + candidates.add(new OreSourceCandidate(sourceId, + string(candidate, "owner", ""), string(candidate, "owner_name", ""), + string(candidate, "owner_version", ""), registryId, + integer(candidate, "metadata", 0), channel, dictionary, + bool(candidate, "loaded", false), bool(candidate, "placement_active", true), + bool(candidate, "external", false), + bool(candidate, "enrichment", false), + bool(candidate, "review_required", false))); + } + } + Collections.sort(candidates, (left, right) -> { + int owner = left.owner.compareTo(right.owner); + return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId); + }); + JsonObject viewPolicy = JsonCopies.copy(policy); + refreshOreSourceStatus(viewPolicy); + OreSourceGroup group = new OreSourceGroup(entry.getKey(), material, domain, + displayName, groupDictionary, curated, + string(policy, "mode", "keep_separate"), outputMode(policy), + string(viewPolicy, "status", "review_required"), + candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")); + if (!bool(policy, "dormant", false)) result.add(group); + } + Collections.sort(result, (left, right) -> { + int name = left.displayName.compareToIgnoreCase(right.displayName); + if (name != 0) return name; + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return Collections.unmodifiableList(result); + } + + String addOreMaterialGroup() { + JsonObject groups = section("ore_material_groups"); + JsonObject policies = section("ore_source_policies"); + String base = "orespawn:custom/custom_group"; + String id = base; + for (int suffix = 2; groups.has(id) + || policies.has(id + "|minecraft:overworld"); suffix++) id = base + '_' + suffix; + JsonObject definition = new JsonObject(); + definition.addProperty("display_name", "Custom Group"); + definition.add("block_tag_entries", new JsonArray()); + groups.add(id, definition); + String key = id + "|minecraft:overworld"; + JsonObject policy = objectEntry(policies, key); + policy.remove("dormant"); + policy.addProperty("material", id); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + policy.addProperty("status", "separate"); + policy.addProperty("review_required", false); + policy.add("outputs", new JsonObject()); + policy.add("placement_sources", new JsonObject()); + policy.add("candidates", new JsonArray()); + return key; + } + + void renameOreMaterialGroup(String material, String displayName) { + if (!validResource(material)) return; + String value = displayName == null ? "" : displayName.trim(); + if (value.isEmpty()) return; + objectEntry(section("ore_material_groups"), material) + .addProperty("display_name", value.length() > 64 ? value.substring(0, 64) : value); + } + + String blockTagOwner(String alias) { + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + if (stringList(entry.getValue().getAsJsonObject(), "block_tag_entries").contains(alias)) { + return entry.getKey(); + } + } + return null; + } + + boolean vanillaOreManagementRequiredForAliasMove(String material, String alias) { + if (bool(root, "manage_vanilla_ores", false)) return false; + String owner = blockTagOwner(alias); + if (owner == null || owner.equals(material)) return false; + JsonObject ores = section("ores"); + for (OreSourceGroup group : oreSourceGroups()) { + if (!owner.equals(group.material)) continue; + for (OreSourceCandidate candidate : group.candidates) { + if (!candidate.loaded || candidate.external || !candidate.blockTag.contains(alias) + || !ores.has(candidate.sourceId) || !ores.get(candidate.sourceId).isJsonObject()) continue; + if (bool(ores.getAsJsonObject(candidate.sourceId), "native_generation", false)) return true; + } + } + return false; + } + + boolean addOreMaterialAlias(String material, String alias, boolean moveExisting) { + if (!validResource(material) || !validBlockTagName(alias)) return false; + String owner = blockTagOwner(alias); + if (owner != null && !owner.equals(material)) { + if (!moveExisting) return false; + if (vanillaOreManagementRequiredForAliasMove(material, alias)) return false; + removeOreMaterialAlias(owner, alias); + } + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (!names.contains(alias)) names.add(alias); + Collections.sort(names); + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + return true; + } + + void removeOreMaterialAlias(String material, String alias) { + if (!validResource(material)) return; + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (names.remove(alias)) { + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + } + } + + void resetOreMaterialGroup(String material) { + if ("orespawn:sulfur".equals(material)) { + setOreMaterialGroup(material, "Sulfur", + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")); + } else if ("orespawn:aluminum".equals(material)) { + setOreMaterialGroup(material, "Aluminum", + Arrays.asList("forge:ores/aluminum", "forge:ores/aluminium")); + } + rebuildOreSourcePolicies(); + } + + boolean deleteEmptyOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject() + || !stringList(groups.getAsJsonObject(material), "block_tag_entries").isEmpty()) { + return false; + } + groups.remove(material); + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + boolean dissolveOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject()) return false; + List aliases = stringList(groups.getAsJsonObject(material), "block_tag_entries"); + if (aliases.isEmpty()) return false; + groups.remove(material); + for (String alias : aliases) { + String restoredMaterial = inferredMaterialForAlias(alias); + JsonObject definition = objectEntry(groups, restoredMaterial); + if (!definition.has("display_name")) { + definition.addProperty("display_name", displayName(restoredMaterial)); + } + List restoredAliases = stringList(definition, "block_tag_entries"); + if (!restoredAliases.contains(alias)) restoredAliases.add(alias); + Collections.sort(restoredAliases); + definition.add("block_tag_entries", strings(restoredAliases)); + } + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + private void markMaterialPoliciesDormant(String material) { + for (Entry entry : section("ore_source_policies").entrySet()) { + if (entry.getValue().isJsonObject() + && material.equals(string(entry.getValue().getAsJsonObject(), "material", ""))) { + entry.getValue().getAsJsonObject().addProperty("dormant", true); + } + } + } + + boolean oreMaterialGroupsChanged() { + return !sectionCopy(original, "ore_material_groups").equals(sectionCopy(root, "ore_material_groups")); + } + + JsonObject oreMaterialGroupsCopy() { + return sectionCopy(root, "ore_material_groups"); + } + + void resetOreSourcesToDefaults() { + WorldGeologyProfile current = profile(); + JsonObject reset = current.rootCopy(); + reset.remove("ore_material_groups"); + reset.remove("ore_source_policies"); + WorldGeologyProfile defaults = WorldGeologyProfile.fromGlobalConfig(reset, + current.geologyMode(), current.placeFluidDeposits()); + applyProfile(defaults); + oreSourceOriginal = defaults.rootCopy(); + normalizeRegistrySections(oreSourceOriginal); + } + + private void setOreMaterialGroup(String material, String displayName, List aliases) { + JsonObject definition = objectEntry(section("ore_material_groups"), material); + definition.addProperty("display_name", displayName); + definition.add("block_tag_entries", strings(aliases)); + } + + private void rebuildOreSourcePolicies() { + JsonObject policies = section("ore_source_policies"); + Map> previousMembers = new LinkedHashMap<>(); + Map candidates = new LinkedHashMap<>(); + for (Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + Set members = new LinkedHashSet<>(); + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue; + boolean hasCandidates = policy.getAsJsonArray("candidates").size() > 0; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = JsonCopies.copy(element.getAsJsonObject()); + String identity = candidateIdentity(candidate, string(policy, "domain", "")); + members.add(identity); + candidates.put(identity, candidate); + } + previousMembers.put(entry.getKey(), members); + String material = string(policy, "material", ""); + if (!hasCandidates && section("ore_material_groups").has(material)) { + policy.remove("dormant"); + } else { + policy.addProperty("dormant", true); + } + } + Map> regrouped = new LinkedHashMap<>(); + for (JsonObject candidate : candidates.values()) { + String material = string(candidate, "material", ""); + if (!bool(candidate, "material_declared", false)) { + material = materialForAliases(stringList(candidate, "block_tags")); + if (!material.isEmpty()) candidate.addProperty("material", material); + } + String domain = string(candidate, "domain", "minecraft:overworld"); + if (!validResource(material) || !validResource(domain)) continue; + regrouped.computeIfAbsent(material + '|' + domain, ignored -> new ArrayList<>()).add(candidate); + } + for (Entry> entry : regrouped.entrySet()) { + int split = entry.getKey().indexOf('|'); + String material = entry.getKey().substring(0, split); + String domain = entry.getKey().substring(split + 1); + JsonObject policy = policies.has(entry.getKey()) && policies.get(entry.getKey()).isJsonObject() + ? policies.getAsJsonObject(entry.getKey()) : new JsonObject(); + policy.remove("dormant"); + policy.addProperty("material", material); + policy.addProperty("domain", domain); + if (!policy.has("mode")) policy.addProperty("mode", "keep_separate"); + if (!policy.has("output_mode")) policy.addProperty("output_mode", "custom"); + JsonArray array = new JsonArray(); + Set currentMembers = new LinkedHashSet<>(); + for (JsonObject candidate : entry.getValue()) { + array.add(candidate); + currentMembers.add(candidateIdentity(candidate, domain)); + } + policy.add("candidates", array); + reconcileOreSourceOutputs(policy); + reconcileOreSourcePlacements(policy); + Set previous = previousMembers.get(entry.getKey()); + boolean membershipChanged = previous == null || !previous.equals(currentMembers); + if (membershipChanged) { + policy.addProperty("review_required", outputCandidateJson(policy).size() > 1); + } + refreshOreSourceStatus(policy); + policies.add(entry.getKey(), policy); + } + } + + private static void reconcileOreSourceOutputs(JsonObject policy) { + List candidates = outputCandidateJson(policy); + Set candidateIds = allOutputCandidateIds(policy); + JsonObject outputs = objectEntry(policy, "outputs"); + for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) { + if (!candidateIds.contains(sourceId)) outputs.remove(sourceId); + } + String mode = string(policy, "mode", "keep_separate"); + String outputMode = outputMode(policy); + if ("keep_separate".equals(mode) || "balanced".equals(outputMode)) { + for (JsonObject candidate : candidates) { + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty() && bool(candidate, "loaded", false)) { + outputs.addProperty(sourceId, 1.0D); + } + } + } else if ("single".equals(outputMode)) { + String selected = ""; + for (String sourceId : JsonCopies.keys(outputs)) { + if (candidateIds.contains(sourceId)) { + selected = sourceId; + break; + } + } + if (selected.isEmpty()) { + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + } + + private static void reconcileOreSourcePlacements(JsonObject policy) { + Map> retainedChoices = new LinkedHashMap<>(); + Map> loadedChoices = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "external", false) || bool(candidate, "enrichment", false)) continue; + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) { + retainedChoices.computeIfAbsent(channel, ignored -> new LinkedHashSet<>()).add(sourceId); + } + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false)) { + loadedChoices.computeIfAbsent(channel, ignored -> new ArrayList<>()).add(candidate); + } + } + } + JsonObject placements = objectEntry(policy, "placement_sources"); + for (String channel : new ArrayList<>(JsonCopies.keys(placements))) { + String selected = string(placements, channel, ""); + if (!retainedChoices.getOrDefault(channel, Collections.emptySet()).contains(selected)) { + placements.remove(channel); + } + } + for (Entry> entry : loadedChoices.entrySet()) { + if (!placements.has(entry.getKey()) && !entry.getValue().isEmpty()) { + placements.addProperty(entry.getKey(), string(entry.getValue().get(0), "source_id", "")); + } + } + } + + private static Set allOutputCandidateIds(JsonObject policy) { + Set result = new LinkedHashSet<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) result.add(sourceId); + } + } + return result; + } + + private static String candidateIdentity(JsonObject candidate, String fallbackDomain) { + return string(candidate, "source_id", "") + '|' + + string(candidate, "placement_channel", "orespawn:standard") + '|' + + string(candidate, "domain", fallbackDomain); + } + + private String materialForAliases(List aliases) { + Set materials = new LinkedHashSet<>(); + for (String alias : aliases) { + String owner = blockTagOwner(alias); + if (owner != null) materials.add(owner); + else if (validBlockTagName(alias)) { + materials.add("orespawn:" + oreTagMaterial(alias)); + } + } + return materials.size() == 1 ? materials.iterator().next() + : materials.size() > 1 ? provisionalMaterialForAliases(aliases) : ""; + } + + private static String provisionalMaterialForAliases(List aliases) { + Set unique = new LinkedHashSet<>(); + for (String alias : aliases) if (validBlockTagName(alias)) unique.add(alias); + List exact = new ArrayList<>(unique); + Collections.sort(exact); + String token = exact.isEmpty() ? "unknown" : oreTagMaterial(exact.get(0)); + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return "orespawn:review/" + token + '-' + padded.append(value).toString(); + } + + private static boolean isCustomMaterial(String material) { + return material != null && material.startsWith("orespawn:custom/") && validResource(material); + } + + private static String inferredMaterialForAlias(String alias) { + if ("forge:ores/sulfur".equals(alias) || "forge:ores/sulphur".equals(alias)) return "orespawn:sulfur"; + if ("forge:ores/aluminum".equals(alias) || "forge:ores/aluminium".equals(alias)) return "orespawn:aluminum"; + return "orespawn:" + oreTagMaterial(alias); + } + + void setOreSourceMode(String key, boolean consolidated) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate"); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void acceptOreSourcePolicy(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void restoreOreSourceOriginalMode(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject originalPolicies = object(oreSourceOriginal, "ore_source_policies"); + JsonObject originalPolicy = originalPolicies.has(key) && originalPolicies.get(key).isJsonObject() + ? originalPolicies.getAsJsonObject(key) : null; + policy.addProperty("mode", "keep_separate"); + if (originalPolicy != null) { + policy.addProperty("output_mode", outputMode(originalPolicy)); + policy.add("outputs", sectionCopy(originalPolicy, "outputs")); + policy.add("placement_sources", sectionCopy(originalPolicy, "placement_sources")); + } else { + JsonObject outputs = new JsonObject(); + JsonObject placements = new JsonObject(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) + || !bool(candidate, "placement_active", false) + || bool(candidate, "external", false) + || bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + if (!sourceId.isEmpty() && !placements.has(channel)) { + placements.addProperty(channel, sourceId); + } + } + } + policy.addProperty("output_mode", "custom"); + policy.add("outputs", outputs); + policy.add("placement_sources", placements); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutputMode(String key, String outputMode) { + if (!"balanced".equals(outputMode) && !"single".equals(outputMode) + && !"custom".equals(outputMode)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + if (!hasManagedPlacementSource(policy)) return; + policy.addProperty("mode", "consolidated"); + policy.addProperty("output_mode", outputMode); + JsonObject outputs = objectEntry(policy, "outputs"); + if ("balanced".equals(outputMode)) { + outputs.entrySet().clear(); + for (JsonObject candidate : outputCandidateJson(policy)) { + if (bool(candidate, "external", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + } + } else if ("single".equals(outputMode)) { + String selected = preferredSingleOutput(policy, outputs); + if (!loadedOutput(policy, selected)) { + List candidates = outputCandidateJson(policy); + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + private static String preferredSingleOutput(JsonObject policy, JsonObject outputs) { + JsonObject placements = objectEntry(policy, "placement_sources"); + for (Entry entry : placements.entrySet()) { + try { + String sourceId = entry.getValue().getAsString(); + if (outputs.has(sourceId) && loadedOutput(policy, sourceId)) return sourceId; + } catch (RuntimeException ignored) { } + } + return outputs.entrySet().isEmpty() ? "" : outputs.entrySet().iterator().next().getKey(); + } + + void setOreSourcePlacement(String key, String channel, String sourceId) { + if (!validResource(channel)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + objectEntry(policy, "placement_sources").addProperty(channel, sourceId); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutput(String key, String sourceId, boolean enabled, double weight) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject outputs = objectEntry(policy, "outputs"); + String outputMode = outputMode(policy); + if (!enabled) outputs.remove(sourceId); + else if ("single".equals(outputMode)) { + outputs.entrySet().clear(); + outputs.addProperty(sourceId, 1.0D); + } else outputs.addProperty(sourceId, "balanced".equals(outputMode) ? 1.0D + : Math.max(0.001D, Math.min(1000000.0D, weight))); + refreshOreSourceStatus(policy); + } + + private static List outputCandidateJson(JsonObject policy) { + Map unique = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) || bool(candidate, "enrichment", false)) continue; + String identity = string(candidate, "registry_id", "") + '|' + + integer(candidate, "metadata", 0); + JsonObject previous = unique.get(identity); + if (previous == null || (!bool(previous, "placement_active", false) + && bool(candidate, "placement_active", false)) + || (bool(previous, "external", false) && !bool(candidate, "external", false))) { + unique.put(identity, candidate); + } + } + } + return new ArrayList<>(unique.values()); + } + + private static boolean loadedOutput(JsonObject policy, String sourceId) { + if (sourceId == null || sourceId.isEmpty()) return false; + for (JsonObject candidate : outputCandidateJson(policy)) { + if (sourceId.equals(string(candidate, "source_id", ""))) return true; + } + return false; + } + + private static boolean hasManagedPlacementSource(JsonObject policy) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false) + && !bool(candidate, "external", false) + && !bool(candidate, "enrichment", false)) return true; + } + return false; + } + + private static void refreshOreSourceStatus(JsonObject policy) { + Set missing = new HashSet<>(); + Set inactivePlacements = new HashSet<>(); + boolean external = false; + boolean review = bool(policy, "review_required", false); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + if (!bool(candidate, "loaded", false)) missing.add(sourceId); + if (!bool(candidate, "placement_active", false)) inactivePlacements.add(sourceId); + external |= bool(candidate, "external", false) && bool(candidate, "loaded", false); + } + } + Map outputs = decimalMap(policy, "outputs"); + Map placements = stringMap(policy, "placement_sources"); + if ("consolidated".equals(string(policy, "mode", "keep_separate")) + && (outputs.isEmpty() || placements.isEmpty())) { + policy.addProperty("status", "missing_source"); + return; + } + for (String id : outputs.keySet()) { + if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; } + } + for (String id : placements.values()) { + if (missing.contains(id) || inactivePlacements.contains(id)) { + policy.addProperty("status", "missing_source"); return; + } + } + policy.addProperty("status", external ? "external_generation" : review ? "review_required" + : "consolidated".equals(string(policy, "mode", "keep_separate")) + ? "consolidated" : "separate"); + } + + private static Map decimalMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static Map stringMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { result.put(entry.getKey(), entry.getValue().getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + static final class OreSourceGroup { + final String key; + final String material; + final String domain; + final String displayName; + final List blockTagEntries; + final boolean curated; + final String mode; + final String outputMode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + OreSourceGroup(String key, String material, String domain, String displayName, + List blockTagEntries, boolean curated, String mode, String outputMode, String status, + List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.curated = curated; + this.mode = mode; + this.outputMode = outputMode; + this.status = status; + this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates)); + this.outputs = outputs; + this.placements = placements; + } + + List channels() { + Set result = new TreeSet<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.external && candidate.active) result.add(candidate.channel); + } + return Collections.unmodifiableList(new ArrayList<>(result)); + } + + List outputCandidates() { + Map unique = new LinkedHashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (candidate.enrichment) continue; + String identity = candidate.registryId + '|' + candidate.metadata; + OreSourceCandidate previous = unique.get(identity); + if (previous == null || (!previous.active && candidate.active) + || (previous.external && !candidate.external)) unique.put(identity, candidate); + } + return Collections.unmodifiableList(new ArrayList<>(unique.values())); + } + + boolean needsAttention() { + return needsReview() || "missing_source".equals(status) + || outputCandidates().isEmpty(); + } + + boolean needsReview() { + return "review_required".equals(status); + } + + boolean hasManagedPlacementSource() { + for (OreSourceCandidate candidate : candidates) { + if (candidate.loaded && candidate.active && !candidate.external + && !candidate.enrichment) return true; + } + return false; + } + + boolean isRoutineSingleSource() { + return !needsAttention() && blockTagEntries.size() == 1 + && outputCandidates().size() == 1; + } + + boolean isCustom() { + return isCustomMaterial(material); + } + + boolean canDeleteEmpty() { + return isCustom() && blockTagEntries.isEmpty(); + } + + boolean canDissolve() { + return isCustom() && !blockTagEntries.isEmpty(); + } + + boolean isRedundantSeparatePolicy() { + if (!"keep_separate".equals(mode) || candidates.isEmpty()) return false; + Set outputs = new HashSet<>(); + Map> ownersByChannel = new HashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.loaded || candidate.external || candidate.enrichment + || candidate.reviewRequired) return false; + outputs.add(candidate.registryId + '|' + candidate.metadata); + ownersByChannel.computeIfAbsent(candidate.channel, ignored -> new HashSet<>()) + .add(candidate.owner); + } + if (outputs.size() > 1) return false; + for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return false; + return true; + } + } + + static final class OreSourceCandidate { + final String sourceId; + final String owner; + final String ownerName; + final String ownerVersion; + final String registryId; + final int metadata; + final String channel; + final List blockTag; + final boolean loaded; + final boolean active; + final boolean external; + final boolean enrichment; + final boolean reviewRequired; + + OreSourceCandidate(String sourceId, String owner, String ownerName, String ownerVersion, + String registryId, int metadata, String channel, List blockTag, + boolean loaded, boolean active, boolean external, boolean enrichment, boolean reviewRequired) { + this.sourceId = sourceId; + this.owner = owner; + this.ownerName = ownerName; + this.ownerVersion = ownerVersion; + this.registryId = registryId; + this.metadata = metadata; + this.channel = channel; + this.blockTag = Collections.unmodifiableList(new ArrayList<>(blockTag)); + this.loaded = loaded; + this.active = active; + this.external = external; + this.enrichment = enrichment; + this.reviewRequired = reviewRequired; + } + } + void resetEntry(String section, String id) { JsonObject originalSection = object(original, section); if (originalSection.has(id)) { @@ -387,6 +1217,159 @@ List installedBiomeIds() { return result; } + BiomeDirectoryModel.Snapshot biomeDirectory() { + return BiomeDirectoryModel.snapshot(root, biomeProviderDefaults, availableDimensionIds()); + } + + List biomePaletteIds(String dimensionId) { + List result = new ArrayList<>(); + for (Entry entry : section("biome_palettes").entrySet()) { + if (!entry.getValue().isJsonObject() + || BiomeReplacementRules.isOverridePalette(entry.getKey())) continue; + if (dimensionId.equals(string(entry.getValue().getAsJsonObject(), "dimension", ""))) { + result.add(entry.getKey()); + } + } + return Collections.unmodifiableList(result); + } + + JsonObject biomePaletteById(String paletteId) { + JsonObject palettes = section("biome_palettes"); + return palettes.has(paletteId) && palettes.get(paletteId).isJsonObject() + ? palettes.getAsJsonObject(paletteId) : null; + } + + List biomePlacementIdsForPalette(String paletteId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette == null) return Collections.emptyList(); + List result = new ArrayList<>(JsonCopies.keys(object(palette, "biomes"))); + Collections.sort(result); + return Collections.unmodifiableList(result); + } + + JsonObject biomePlacementByPalette(String paletteId, String biomeId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette == null) throw new IllegalArgumentException("Unknown biome palette " + paletteId); + return objectEntry(object(palette, "biomes"), biomeId); + } + + void removeBiomePlacementByPalette(String paletteId, String biomeId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette != null) object(palette, "biomes").remove(biomeId); + } + + Map biomeReplacements(String dimensionId) { + return BiomeReplacementRules.read(root, dimensionId); + } + + void replaceBiome(String dimensionId, String sourceId, String targetId) { + if (!loadedBiome(sourceId) || !loadedBiome(targetId)) { + throw new IllegalArgumentException("Both replacement biomes must be loaded"); + } + BiomeReplacementRules.set(root, dimensionId, sourceId, targetId); + } + + void leaveBiomeOriginal(String dimensionId, String sourceId) { + BiomeReplacementRules.remove(root, dimensionId, sourceId); + } + + void resetBiome(String dimensionId, String biomeId) { + leaveBiomeOriginal(dimensionId, biomeId); + JsonObject defaults = biomeProviderDefaults.biomePalettesCopy(); + Set active = biomeProviderDefaults.activeProviderIds(); + JsonObject palettes = section("biome_palettes"); + for (String id : new ArrayList<>(JsonCopies.keys(palettes))) { + if (BiomeReplacementRules.isOverridePalette(id) || !palettes.get(id).isJsonObject()) continue; + JsonObject palette = palettes.getAsJsonObject(id); + if (!dimensionId.equals(string(palette, "dimension", ""))) continue; + String owner = string(palette, "source_provider", ""); + if (!owner.isEmpty() && !active.contains(owner)) continue; + JsonObject entries = object(palette, "biomes"); + JsonObject defaultPalette = defaults.has(id) && defaults.get(id).isJsonObject() + ? defaults.getAsJsonObject(id) : null; + JsonObject defaultEntries = defaultPalette == null ? new JsonObject() + : object(defaultPalette, "biomes"); + if (defaultEntries.has(biomeId)) { + entries.add(biomeId, JsonCopies.copy(defaultEntries.get(biomeId))); + } else { + entries.remove(biomeId); + } + } + for (Entry entry : defaults.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject definition = entry.getValue().getAsJsonObject(); + if (!dimensionId.equals(string(definition, "dimension", "")) + || !object(definition, "biomes").has(biomeId)) continue; + if (!palettes.has(entry.getKey())) { + palettes.add(entry.getKey(), JsonCopies.copy(definition)); + } else if (palettes.get(entry.getKey()).isJsonObject()) { + object(palettes.getAsJsonObject(entry.getKey()), "biomes").add(biomeId, + JsonCopies.copy(object(definition, "biomes").get(biomeId))); + } + } + } + + void resetBiomePalette(String paletteId) { + JsonObject palettes = section("biome_palettes"); + if (!palettes.has(paletteId) || !palettes.get(paletteId).isJsonObject()) return; + JsonObject current = palettes.getAsJsonObject(paletteId); + String owner = string(current, "source_provider", ""); + if (!owner.isEmpty() && !biomeProviderDefaults.activeProviderIds().contains(owner)) return; + JsonObject defaults = biomeProviderDefaults.biomePalettesCopy(); + if (defaults.has(paletteId)) palettes.add(paletteId, JsonCopies.copy(defaults.get(paletteId))); + else palettes.remove(paletteId); + } + + void resetBiomeDimension(String dimensionId) { + resetBiomeSections(dimensionId); + BiomeReplacementRules.clearDimension(root, dimensionId); + } + + void resetAllBiomeManagement() { + resetBiomeSections(null); + for (String dimension : new ArrayList<>(availableDimensionIds())) { + BiomeReplacementRules.clearDimension(root, dimension); + } + } + + private void resetBiomeSections(String dimensionId) { + resetProviderSection("biome_palettes", biomeProviderDefaults.biomePalettesCopy(), dimensionId); + resetProviderSection("dimension_materials", biomeProviderDefaults.dimensionMaterialsCopy(), dimensionId); + } + + private void resetProviderSection(String sectionName, JsonObject defaults, String dimensionId) { + JsonObject current = section(sectionName); + Set active = biomeProviderDefaults.activeProviderIds(); + for (String id : new ArrayList<>(JsonCopies.keys(current))) { + if (!current.get(id).isJsonObject()) continue; + JsonObject definition = current.getAsJsonObject(id); + if (dimensionId != null && !dimensionId.equals(string(definition, "dimension", ""))) continue; + String owner = string(definition, "source_provider", ""); + if (!owner.isEmpty() && !active.contains(owner)) continue; + if (defaults.has(id)) current.add(id, JsonCopies.copy(defaults.get(id))); + else current.remove(id); + } + for (Entry entry : defaults.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject definition = entry.getValue().getAsJsonObject(); + if (dimensionId == null || dimensionId.equals(string(definition, "dimension", ""))) { + current.add(entry.getKey(), JsonCopies.copy(definition)); + } + } + } + + boolean biomeTargetProviderDeclared(String dimensionId, String biomeId) { + for (BiomeDirectoryModel.BiomeEntry entry : biomeDirectory().entries(dimensionId, true)) { + if (biomeId.equals(entry.id)) return entry.providerDeclared; + } + return false; + } + + private static boolean loadedBiome(String id) { + try { return ForgeRegistries.BIOMES.getValue(new ResourceLocation(id)) != null; } + catch (RuntimeException ignored) { return false; } + } + String biomePaletteId(String dimensionId) { for (Entry entry : section("biome_palettes").entrySet()) { if (entry.getValue().isJsonObject() && dimensionId.equals(string( @@ -485,8 +1468,7 @@ void setMaterialBlock(String dimensionId, String key, String blockId, boolean fl } if (!validResource(blockId)) return; Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation(blockId)); - if (block == null || block == Blocks.AIR - || (fluid && block.getDefaultState().getFluidState().isEmpty())) return; + if (block == null || block == Blocks.AIR || (fluid && !isFluidBlock(block))) return; JsonObject materials = dimensionMaterials(dimensionId, true); materials.addProperty(key, blockId); materials.addProperty("enabled", true); @@ -502,9 +1484,10 @@ List availableMaterialBlockIds(String search, boolean fluidOnly) { List result = new ArrayList<>(); for (Block block : ForgeRegistries.BLOCKS.getValues()) { ResourceLocation id = ForgeRegistries.BLOCKS.getKey(block); - if (id == null || block == Blocks.AIR || (!fluidOnly && block.asItem() == Items.AIR) - || (fluidOnly && block.getDefaultState().getFluidState().isEmpty()) - || (!fluidOnly && block.getDefaultState().hasTileEntity()) + if (id == null || block == Blocks.AIR + || (!fluidOnly && block.asItem() == Items.AIR) + || (fluidOnly && !isFluidBlock(block)) + || (!fluidOnly && block.hasTileEntity(block.getDefaultState())) || (!query.isEmpty() && !id.toString().contains(query))) continue; result.add(id.toString()); } @@ -533,7 +1516,7 @@ List availableFluidBlockIds(String search) { List result = new ArrayList<>(); for (Block block : ForgeRegistries.BLOCKS.getValues()) { ResourceLocation id = ForgeRegistries.BLOCKS.getKey(block); - if (id != null && block != Blocks.AIR && !block.getDefaultState().getFluidState().isEmpty() + if (id != null && block != Blocks.AIR && isFluidBlock(block) && !configured.contains(id.toString()) && (query.isEmpty() || id.toString().contains(query))) result.add(id.toString()); } @@ -624,7 +1607,7 @@ JsonObject weightMap(String section, String id) { } void addGeome(String id) { - String normalized = id.trim().toLowerCase(Locale.ROOT); + String normalized = id.trim(); if (!validGeomeId(normalized) || section("geomes").has(normalized)) { return; } @@ -709,8 +1692,10 @@ List validate() { continue; } JsonObject rock = entry.getValue().getAsJsonObject(); - if (!validBlock(string(rock, "block", entry.getKey()))) { - errors.add("Invalid rock block: " + string(rock, "block", entry.getKey())); + String rockBlock = string(rock, "block", entry.getKey()); + if (!validBlock(rockBlock)) { + if (allowsMissingProviderAsset(rock, rockBlock)) continue; + errors.add("Invalid rock block: " + rockBlock); continue; } if (!bool(rock, "enabled", true) || decimal(rock, "weight", 1.0D) <= 0.0D) { @@ -746,14 +1731,17 @@ List validate() { continue; } JsonObject ore = entry.getValue().getAsJsonObject(); - if (!validBlock(string(ore, "block", entry.getKey()))) { - errors.add("Invalid ore block: " + string(ore, "block", entry.getKey())); + String oreBlock = string(ore, "block", entry.getKey()); + if (!validBlock(oreBlock)) { + if (allowsMissingProviderAsset(ore, oreBlock)) continue; + errors.add("Invalid ore block: " + oreBlock); continue; } if (!bool(ore, "enabled", true)) { continue; } - if (ore.has("deep_output") && !validBlock(string(ore, "deep_output", ""))) { + if (ore.has("deep_output") && !validBlock(string(ore, "deep_output", "")) + && !allowsMissingProviderAsset(ore, string(ore, "deep_output", ""))) { errors.add("Invalid deep ore block: " + string(ore, "deep_output", "")); } JsonObject dimensions = ore.has("dimensions") && ore.get("dimensions").isJsonObject() @@ -805,7 +1793,8 @@ List validate() { errors.add("Invalid ore pattern for " + entry.getKey() + " in " + dimension.getKey()); } if (bool(rule, "enabled", true)) { - boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(ore)) || validIdArray(rule.get("host_tags")); if (rule.has("host_families") && rule.get("host_families").isJsonArray()) { for (JsonElement family : rule.getAsJsonArray("host_families")) { @@ -818,6 +1807,8 @@ List validate() { validateGeomeWeights(errors, entry.getKey(), rule.get("geomes"), geomes); } } + validateOreMaterialGroups(errors); + validateOreSourcePolicies(errors); validateRuleGeomes(errors, section("biomes"), geomes, "biome"); validateRuleGeomes(errors, section("biome_dictionary"), geomes, "biome type"); for (Entry entry : section("terrain_dimensions").entrySet()) { @@ -827,7 +1818,8 @@ List validate() { } JsonObject dimension = entry.getValue().getAsJsonObject(); if (!bool(dimension, "enabled", true)) continue; - boolean hosts = validBlockArray(dimension.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(dimension.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(dimension)) || validIdArray(dimension.get("host_tags")); if (!hosts) errors.add("Enabled terrain dimension has no valid hosts: " + entry.getKey()); if (dimension.has("biome_ids")) { @@ -844,7 +1836,9 @@ List validate() { continue; } JsonObject deposit = entry.getValue().getAsJsonObject(); - if (!validFluidBlock(string(deposit, "block", ""))) { + String depositBlock = string(deposit, "block", ""); + if (!validFluidBlock(depositBlock)) { + if (allowsMissingProviderAsset(deposit, depositBlock)) continue; errors.add("Fluid deposit output is not a fluid block: " + entry.getKey()); continue; } @@ -880,7 +1874,8 @@ List validate() { errors.add("Invalid fluid placement values for " + entry.getKey() + " in " + dimension.getKey()); } - boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(deposit)) || validIdArray(rule.get("host_tags")); if (rule.has("host_families") && rule.get("host_families").isJsonArray()) { for (JsonElement family : rule.getAsJsonArray("host_families")) { @@ -898,6 +1893,8 @@ List validate() { continue; } JsonObject palette = entry.getValue().getAsJsonObject(); + boolean replacementPalette = BiomeReplacementRules.isOverridePalette(entry.getKey()); + boolean orphanedProvider = orphanedProviderDefinition(palette); if (!validResource(string(palette, "dimension", ""))) { errors.add("Invalid biome palette dimension: " + entry.getKey()); } @@ -907,7 +1904,9 @@ List validate() { if (biomes.size() == 0) errors.add("Enabled biome palette has no biomes: " + entry.getKey()); for (Entry biome : biomes.entrySet()) { if (!validResource(biome.getKey()) - || ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null + || (!replacementPalette + && ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null + && !orphanedProvider) || !biome.getValue().isJsonObject()) { errors.add("Invalid biome placement: " + biome.getKey()); continue; @@ -928,16 +1927,19 @@ > decimal(placement, "max_downfall", 1.0D)) { continue; } JsonObject materials = entry.getValue().getAsJsonObject(); + boolean orphanedProvider = orphanedProviderDefinition(materials); if (!validResource(string(materials, "dimension", ""))) { errors.add("Invalid dimension materials target: " + entry.getKey()); } for (String key : new String[] { "default_fluid", "deep_aquifer_fluid" }) { - if (materials.has(key) && !validFluidBlock(string(materials, key, ""))) { + if (materials.has(key) && !validFluidBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid fluid material in " + entry.getKey()); } } for (String key : new String[] { "snow_block", "ice_block" }) { - if (materials.has(key) && !validBlock(string(materials, key, ""))) { + if (materials.has(key) && !validBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid weather material in " + entry.getKey()); } } @@ -945,6 +1947,62 @@ > decimal(placement, "max_downfall", 1.0D)) { return errors; } + private void validateOreMaterialGroups(List errors) { + Set aliases = new HashSet<>(); + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!validResource(entry.getKey()) || !entry.getValue().isJsonObject()) { + errors.add("Invalid ore material group: " + entry.getKey()); + continue; + } + JsonObject group = entry.getValue().getAsJsonObject(); + if (string(group, "display_name", "").trim().isEmpty()) { + errors.add("Ore material group has no display name: " + entry.getKey()); + } + for (String alias : stringList(group, "block_tag_entries")) { + if (!validBlockTagName(alias)) { + errors.add("Invalid block tag in " + entry.getKey() + ": " + alias); + } else if (!aliases.add(alias)) { + errors.add("Block tag belongs to more than one material group: " + alias); + } + } + } + } + + private void validateOreSourcePolicies(List errors) { + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) { + errors.add("Invalid ore source policy: " + entry.getKey()); + continue; + } + JsonObject policy = entry.getValue().getAsJsonObject(); + if (bool(policy, "dormant", false) + || !"consolidated".equals(string(policy, "mode", "keep_separate"))) continue; + JsonObject outputs = policy.has("outputs") && policy.get("outputs").isJsonObject() + ? policy.getAsJsonObject("outputs") : new JsonObject(); + JsonObject placements = policy.has("placement_sources") + && policy.get("placement_sources").isJsonObject() + ? policy.getAsJsonObject("placement_sources") : new JsonObject(); + String outputMode = outputMode(policy); + if (outputs.entrySet().isEmpty()) { + errors.add("Consolidated ore source policy has no selected outputs: " + entry.getKey()); + } + if (placements.entrySet().isEmpty()) { + errors.add("Consolidated ore source policy has no placement source: " + entry.getKey()); + } + if ("single".equals(outputMode) && outputs.entrySet().size() != 1) { + errors.add("Single-output ore source policy must select exactly one output: " + entry.getKey()); + } + for (Entry output : outputs.entrySet()) { + try { + double value = output.getValue().getAsDouble(); + if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException(); + } catch (RuntimeException invalid) { + errors.add("Ore source output weight must be positive: " + entry.getKey()); + } + } + } + } + private static void validateOrePattern(JsonObject rule) { if (!OreSpawnPatterns.isBuiltIn(rule)) { OreSpawnPatterns.decode(rule); @@ -1003,6 +2061,11 @@ private static boolean validIdArray(JsonElement element) { } private static boolean validBlockArray(JsonElement element, List errors, String oreId) { + return validBlockArray(element, errors, oreId, false); + } + + private static boolean validBlockArray(JsonElement element, List errors, String oreId, + boolean allowMissingProviderBlocks) { if (element == null || !element.isJsonArray() || element.getAsJsonArray().size() == 0) return false; boolean found = false; for (JsonElement value : element.getAsJsonArray()) { @@ -1014,6 +2077,10 @@ private static boolean validBlockArray(JsonElement element, List errors, continue; } if (!validBlock(id)) { + if (allowMissingProviderBlocks && validResource(id)) { + found = true; + continue; + } errors.add("Unknown host block '" + id + "' for " + oreId); continue; } @@ -1022,6 +2089,15 @@ private static boolean validBlockArray(JsonElement element, List errors, return found; } + private static boolean allowsMissingProviderAsset(JsonObject definition, String id) { + return orphanedProviderDefinition(definition) && validResource(id); + } + + private static boolean orphanedProviderDefinition(JsonObject definition) { + return bool(definition, "orphaned_provider", false) + && !string(definition, "source_provider", "").isEmpty(); + } + static JsonObject defaultOreDimension() { JsonObject dimension = new JsonObject(); dimension.addProperty("enabled", true); @@ -1047,7 +2123,8 @@ static JsonObject defaultOreDimension() { } private static boolean isSelectable(Block block, boolean showAll) { - if (block == Blocks.AIR || block.asItem() == Items.AIR || block instanceof FlowingFluidBlock) { + if (block == Blocks.AIR || block.asItem() == Items.AIR + || block instanceof FlowingFluidBlock || isFluidBlock(block)) { return false; } if (showAll) { @@ -1157,10 +2234,15 @@ private static String safePath(String registryId) { private static boolean validFluidBlock(String id) { if (!validResource(id)) return false; Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation(id)); - return block != null && block != Blocks.AIR && !block.getDefaultState().getFluidState().isEmpty(); + return block != null && block != Blocks.AIR && isFluidBlock(block); + } + + static boolean isFluidBlock(Block block) { + return FluidBlocks.isFluidBlock(block); } private static boolean validResource(String id) { + if (id == null || !id.matches("[a-z0-9_.-]+:[a-z0-9_./-]+")) return false; try { new ResourceLocation(id); return true; @@ -1169,6 +2251,57 @@ private static boolean validResource(String id) { } } + private static boolean validBlockTagName(String name) { + if (name == null || name.length() > 128 || !validResource(name)) return false; + String path = new ResourceLocation(name).getPath(); + return path.startsWith("ores/") && path.length() > "ores/".length(); + } + + private static String oreTagMaterial(String tag) { + return new ResourceLocation(tag).getPath().substring("ores/".length()); + } + + private static JsonObject sectionCopy(JsonObject parent, String key) { + return parent.has(key) && parent.get(key).isJsonObject() + ? JsonCopies.copy(parent.getAsJsonObject(key)) : new JsonObject(); + } + + private static List stringList(JsonObject parent, String key) { + List result = new ArrayList<>(); + if (parent.has(key) && parent.get(key).isJsonArray()) { + for (JsonElement element : parent.getAsJsonArray(key)) { + try { + String value = element.getAsString(); + if (!result.contains(value)) result.add(value); + } catch (RuntimeException ignored) { } + } + } + return result; + } + + private static JsonArray strings(Iterable values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String displayName(String id) { + int split = id.indexOf(':'); + String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static String outputMode(JsonObject policy) { + String configured = string(policy, "output_mode", ""); + if ("balanced".equals(configured) || "single".equals(configured) + || "custom".equals(configured)) return configured; + Map outputs = decimalMap(policy, "outputs"); + if (outputs.size() <= 1) return "single"; + double first = outputs.values().iterator().next(); + for (double value : outputs.values()) if (Double.compare(first, value) != 0) return "custom"; + return "balanced"; + } + private static boolean validGeomeId(String id) { if (id == null || id.isEmpty()) return false; if (id.indexOf(':') < 0) return id.matches("[a-z0-9_.-]+"); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java index 1d28a1a6..4eeff525 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java @@ -100,9 +100,17 @@ protected void init() { previous.active = page > 0; next.active = page + 1 < pageCount; - int addWidth = Math.min(150, contentWidth - 105); + int addWidth = tab == MaterialTab.ORES ? Math.max(50, (contentWidth - 105) / 2) + : Math.min(150, contentWidth - 105); + int addX = contentLeft + contentWidth - addWidth; + Button sources = addButton(OreSpawnScreenLayout.button(this, font, + addX - addWidth - 5, controlsY, addWidth, 20, + new TranslationTextComponent("button.orespawn.ore_sources"), + button -> minecraft.displayGuiScreen(new OreSourceListScreen(this, session)))); + sources.visible = tab == MaterialTab.ORES; + OreSpawnScreenLayout.explain(this, sources, "tooltip.orespawn.ore_sources"); Button add = addButton(OreSpawnScreenLayout.button(this, font, - contentLeft + contentWidth - addWidth, controlsY, addWidth, 20, + addX, controlsY, addWidth, 20, new TranslationTextComponent("button.orespawn.add_block"), button -> openBlockPicker())); add.visible = tab != MaterialTab.UNASSIGNED; OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.material.add_block"); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java new file mode 100644 index 00000000..84206091 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -0,0 +1,379 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.resources.I18n; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.text.TranslationTextComponent; + +/** Settings opened by a group's cog: its name, aliases and placement source. */ +final class OreSourceGroupSettingsScreen extends OreSpawnScreen { + private static final int MARGIN = 10; + private static final int NAME_TOP = 24; + private static final int ALIAS_LABEL_TOP = 48; + private static final int ALIAS_TOP = 58; + private final Screen parent; + private final GeologyEditorSession session; + private final String groupKey; + private String pendingAliasMove; + private String pendingAliasValue; + private boolean pendingDissolve; + private String error; + private int aliasScroll; + private int placementScroll; + private int contentX; + private int contentWidth; + private int aliasListHeight; + private int addAliasY; + private int placementLabelY; + private int placementListY; + private TextFieldWidget nameField; + private TextFieldWidget aliasField; + private CompactScrollList aliasList; + private CompactScrollList placementList; + + OreSourceGroupSettingsScreen(Screen parent, GeologyEditorSession session, String groupKey) { + super(new TranslationTextComponent("screen.orespawn.ore_source.group_settings")); + this.parent = parent; + this.session = session; + this.groupKey = groupKey; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + OreSourceGroup group = selectedGroup(); + if (group == null) { + minecraft.displayGuiScreen(parent); + return; + } + contentWidth = Math.max(240, Math.min(600, width - (MARGIN * 2))); + contentX = (width - contentWidth) / 2; + boolean hasGroupAction = group.curated || group.canDeleteEmpty() || group.canDissolve(); + String groupActionKey = group.curated ? "button.orespawn.reset" + : group.canDissolve() ? (pendingDissolve + ? "button.orespawn.ore_source.confirm_dissolve" + : "button.orespawn.ore_source.dissolve_group") + : "button.orespawn.ore_source.delete_group"; + int actionWidth = hasGroupAction + ? Math.min(120, Math.max(55, font.getStringWidth(I18n.format(groupActionKey)) + 10)) : 0; + int nameLabelWidth = font.getStringWidth( + I18n.format("option.orespawn.ore_source.group_name")) + 8; + + nameField = addButton(new TextFieldWidget(font, contentX + nameLabelWidth, NAME_TOP, + contentWidth - nameLabelWidth - actionWidth - (hasGroupAction ? 5 : 0), 20, + new TranslationTextComponent("option.orespawn.ore_source.group_name"))); + nameField.setMaxLength(64); + nameField.setValue(group.displayName); + if (hasGroupAction) { + Button groupAction = addButton(OreSpawnScreenLayout.button(this, font, + contentX + contentWidth - actionWidth, NAME_TOP, actionWidth, 20, + new TranslationTextComponent(groupActionKey), button -> handleGroupAction(group))); + OreSpawnScreenLayout.explain(this, groupAction, group.curated + ? "tooltip.orespawn.ore_source.reset_group" + : group.canDissolve() ? "tooltip.orespawn.ore_source.dissolve_group" + : "tooltip.orespawn.ore_source.delete_group"); + } + + aliasListHeight = aliasListHeight(height); + final List aliases = group.blockTagEntries; + aliasList = addButton(new CompactScrollList(this, contentX, ALIAS_TOP, + contentWidth, aliasListHeight) { + @Override protected int size() { return aliases.size(); } + @Override protected String rowText(int index) { return aliases.get(index); } + @Override protected List rowTooltip(int index) { + return java.util.Collections.singletonList(aliases.get(index)); + } + @Override protected int rowActionWidth(int index) { return 18; } + @Override protected void drawRowAction(net.minecraft.client.Minecraft minecraft, + int index, int x, int y, int actionWidth, int rowHeight, boolean hovered) { + if (hovered) fill(x, y, x + actionWidth, y + rowHeight, 0x805F5F5F); + drawCenteredString(minecraft.fontRenderer, "-", x + (actionWidth / 2), y + 3, 0xFFFFFF); + } + @Override protected void onRowActionPressed(int index) { + syncName(); + session.removeOreMaterialAlias(group.material, aliases.get(index)); + pendingAliasMove = null; + pendingAliasValue = null; + pendingDissolve = false; + error = null; + captureScroll(); + rebuild(); + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList( + I18n.format("tooltip.orespawn.ore_source.remove_alias", aliases.get(index))); + } + }); + aliasList.setFirstIndex(aliasScroll); + + addAliasY = ALIAS_TOP + aliasListHeight + 4; + aliasField = addButton(new TextFieldWidget(font, contentX, addAliasY, + contentWidth - 46, 20, + new TranslationTextComponent("option.orespawn.ore_source.alias"))); + aliasField.setMaxLength(128); + if (pendingAliasMove != null) { + int split = pendingAliasMove.indexOf('|'); + aliasField.setValue(split < 0 ? pendingAliasMove : pendingAliasMove.substring(0, split)); + } else if (pendingAliasValue != null) { + aliasField.setValue(pendingAliasValue); + } + Button add = addButton(new Button(contentX + contentWidth - 41, addAliasY, 41, 20, + new StringTextComponent(pendingAliasMove == null ? "+" + : I18n.format("button.orespawn.ore_source.move")), button -> addAlias(group))); + OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.ore_source.alias"); + + final List channels = placementChannels(group); + placementLabelY = addAliasY + 27; + int helpX = contentX + font.getStringWidth( + I18n.format("label.orespawn.ore_source.placement_rules")) + 8; + addButton(new Button(helpX, placementLabelY - 4, 16, 16, + new StringTextComponent("?"), button -> { }, (button, mouseX, mouseY) -> { + List lines = new ArrayList<>(); + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_rules")); + lines.add(placementStatus(group, channels)); + lines.add(I18n.format("tooltip.orespawn.ore_source.external_excluded")); + renderStringTooltip(lines, mouseX, mouseY); + })); + placementListY = placementLabelY + 14; + placementList = addButton(new CompactScrollList(this, contentX, placementListY, + contentWidth, Math.max(16, height - 32 - placementListY)) { + @Override protected int size() { return channels.size(); } + @Override protected String rowText(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + String selected = group.placements.get(channel); + int choice = OreSourceListScreen.indexOf(choices, selected); + String source = choice < 0 + ? I18n.format("label.orespawn.ore_source.missing") + : OreSourceListScreen.candidateLabel(choices.get(choice)); + return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + + placementSourceLabel(source, choices.size()); + } + @Override protected boolean rowEnabled(int index) { + String channel = channels.get(index); + return placementSelectable(group, channel); + } + @Override protected int rowColor(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + return choices.isEmpty() ? 0x777777 + : placementSelectable(group, channel) ? 0xFFFFFF : 0xC0C0C0; + } + @Override protected void onRowPressed(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + if (choices.size() <= 1) return; + int selected = OreSourceListScreen.indexOf(choices, group.placements.get(channel)); + OreSourceCandidate next = choices.get((selected + 1 + choices.size()) % choices.size()); + syncName(); + session.setOreSourcePlacement(group.key, channel, next.sourceId); + captureScroll(); + rebuild(); + } + @Override protected List rowTooltip(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + List lines = new ArrayList<>(); + lines.add(channelLabel(channel) + " (" + channel + ")"); + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_rules")); + if ("keep_separate".equals(group.mode)) { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_requires_consolidated")); + } else if (choices.isEmpty()) { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_missing")); + } else if (choices.size() == 1) { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_readonly")); + } else { + lines.add(I18n.format("tooltip.orespawn.ore_source.placement_select")); + } + lines.add(I18n.format("tooltip.orespawn.ore_source.external_excluded")); + return lines; + } + }); + placementList.setFirstIndex(placementScroll); + + int doneWidth = doneButtonWidth(contentWidth); + addButton(new Button(contentX + contentWidth - doneWidth, height - 28, doneWidth, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private void handleGroupAction(OreSourceGroup group) { + syncName(); + pendingAliasMove = null; + pendingAliasValue = null; + if (group.curated) { + session.resetOreMaterialGroup(group.material); + pendingDissolve = false; + error = null; + rebuild(); + return; + } + if (group.canDeleteEmpty()) { + if (session.deleteEmptyOreMaterialGroup(group.material)) minecraft.displayGuiScreen(parent); + return; + } + if (!group.canDissolve()) return; + if (!pendingDissolve) { + pendingDissolve = true; + error = I18n.format("error.orespawn.ore_source.confirm_dissolve"); + rebuild(); + return; + } + if (session.dissolveOreMaterialGroup(group.material)) minecraft.displayGuiScreen(parent); + } + + private void addAlias(OreSourceGroup group) { + syncName(); + String alias = aliasField == null ? "" : aliasField.getValue().trim(); + String owner = session.blockTagOwner(alias); + String move = owner == null || owner.equals(group.material) ? null : alias + '|' + owner; + if (move != null && session.vanillaOreManagementRequiredForAliasMove(group.material, alias)) { + pendingDissolve = false; + pendingAliasMove = null; + pendingAliasValue = alias; + error = I18n.format("error.orespawn.ore_source.manage_vanilla_first"); + rebuild(); + return; + } + if (move != null && !move.equals(pendingAliasMove)) { + pendingDissolve = false; + pendingAliasMove = move; + pendingAliasValue = alias; + error = I18n.format("error.orespawn.ore_source.alias_owned", + OreSourceListScreen.display(owner)); + rebuild(); + return; + } + if (!session.addOreMaterialAlias(group.material, alias, move != null)) { + error = I18n.format("error.orespawn.ore_source.alias"); + return; + } + pendingAliasMove = null; + pendingAliasValue = null; + pendingDissolve = false; + error = null; + rebuild(); + } + + private OreSourceGroup selectedGroup() { + for (OreSourceGroup group : session.oreSourceGroups()) { + if (groupKey.equals(group.key)) return group; + } + return null; + } + + private void syncName() { + OreSourceGroup group = selectedGroup(); + if (group != null && nameField != null) { + session.renameOreMaterialGroup(group.material, nameField.getValue()); + } + } + + private void captureScroll() { + if (aliasList != null) aliasScroll = aliasList.firstIndex(); + if (placementList != null) placementScroll = placementList.firstIndex(); + } + + private void rebuild() { + buttons.clear(); + children.clear(); + init(); + } + + static int aliasListHeight(int height) { + return height >= 260 ? 80 : 64; + } + + static int doneButtonWidth(int contentWidth) { + return Math.min(120, Math.max(80, contentWidth / 4)); + } + + static int validationMessageWidth(int contentWidth) { + return Math.max(40, contentWidth - doneButtonWidth(contentWidth) - 8); + } + + static List placementChannels(OreSourceGroup group) { + Set channels = new TreeSet<>(group.channels()); + channels.addAll(group.placements.keySet()); + return new ArrayList<>(channels); + } + + static String channelLabel(String channel) { + return "orespawn:standard".equals(channel) + ? I18n.format("label.orespawn.ore_source.standard_veins") + : OreSourceListScreen.display(channel); + } + + static String placementSourceLabel(String source, int choices) { + return choices > 1 + ? I18n.format("label.orespawn.ore_source.placement_choice_count", source, choices) + : source; + } + + static boolean placementSelectable(OreSourceGroup group, String channel) { + return !"keep_separate".equals(group.mode) + && OreSourceListScreen.placementCandidates(group, channel).size() > 1; + } + + static String placementStatus(OreSourceGroup group, List channels) { + if (channels.isEmpty()) return I18n.format("label.orespawn.ore_source.no_placement_rules"); + if ("keep_separate".equals(group.mode)) { + return I18n.format("tooltip.orespawn.ore_source.placement_requires_consolidated"); + } + boolean selectable = false; + for (String channel : channels) { + int choices = OreSourceListScreen.placementCandidates(group, channel).size(); + if (choices == 0) return I18n.format("tooltip.orespawn.ore_source.placement_missing"); + selectable |= choices > 1; + } + return I18n.format(selectable ? "tooltip.orespawn.ore_source.placement_select" + : "tooltip.orespawn.ore_source.placement_readonly"); + } + + @Override + public void onClose() { + syncName(); + captureScroll(); + minecraft.displayGuiScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, new TranslationTextComponent("option.orespawn.ore_source.group_name"), + contentX + 2, NAME_TOP + 6, 0xA0A0A0); + drawString(font, new TranslationTextComponent("label.orespawn.ore_source.aliases"), + contentX + 2, ALIAS_LABEL_TOP, 0xA0A0A0); + drawString(font, new TranslationTextComponent("label.orespawn.ore_source.placement_rules"), + contentX + 2, placementLabelY, 0xFFFFFF); + OreSourceGroup group = selectedGroup(); + if (group != null && group.blockTagEntries.isEmpty()) { + drawCenteredString(font, new TranslationTextComponent("label.orespawn.ore_source.no_aliases"), + contentX + (contentWidth / 2), ALIAS_TOP + 4, 0x777777); + } + super.render(mouseX, mouseY, partialTick); + if (error != null) { + int messageWidth = validationMessageWidth(contentWidth); + StringTextComponent message = new StringTextComponent(error); + drawString(font, new StringTextComponent(OreSpawnScreenLayout.fit(font, + message, messageWidth)), contentX + 2, height - 22, 0xFF5555); + } + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + if (error != null) { + int messageWidth = validationMessageWidth(contentWidth); + if (mouseX >= contentX && mouseX < contentX + messageWidth + && mouseY >= height - 26 && mouseY < height - 8) { + renderStringTooltip(java.util.Collections.singletonList(error), mouseX, mouseY); + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java new file mode 100644 index 00000000..2072b99c --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -0,0 +1,497 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screen.Screen; +import com.mojang.blaze3d.platform.GlStateManager; +import net.minecraft.client.resources.I18n; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.text.TranslationTextComponent; + +/** Shows ore groups beside their outputs, with each list scrolling independently. */ +final class OreSourceListScreen extends OreSpawnScreen { + private static final int HORIZONTAL_MARGIN = 10; + private static final int PANE_GAP = 6; + private static final int CONTENT_TOP = 24; + private final Screen parent; + private final GeologyEditorSession session; + private final Map weights = new LinkedHashMap<>(); + private String selectedKey; + private String error; + private boolean showAll; + private boolean resetArmed; + private int groupScroll; + private int outputScroll; + private int leftPaneX; + private int leftPaneWidth; + private int rightPaneX; + private int rightPaneWidth; + private int paneBottom; + private CompactScrollList groupList; + private CompactScrollList outputList; + private List visibleCandidates = java.util.Collections.emptyList(); + + OreSourceListScreen(Screen parent, GeologyEditorSession session) { + super(new TranslationTextComponent("screen.orespawn.ore_sources")); + this.parent = parent; + this.session = session; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + weights.clear(); + List groups = orderedGroups(); + if (selected(groups) == null && !groups.isEmpty()) selectedKey = groups.get(0).key; + + int contentWidth = contentWidth(width); + leftPaneX = (width - contentWidth) / 2; + leftPaneWidth = leftPaneWidth(width); + rightPaneX = leftPaneX + leftPaneWidth + PANE_GAP; + rightPaneWidth = contentWidth - leftPaneWidth - PANE_GAP; + paneBottom = height - 32; + + initGroups(groups); + initOutputs(selected(groups)); + String resetLabel = I18n.format(resetArmed + ? "button.orespawn.ore_source.confirm_reset_all" + : "button.orespawn.ore_source.reset_all"); + int resetWidth = Math.min(leftPaneWidth, Math.max(54, font.getStringWidth(resetLabel) + 10)); + Button reset = addButton(OreSpawnScreenLayout.button(this, font, + leftPaneX, height - 28, resetWidth, 20, + new StringTextComponent(resetLabel), button -> resetAll())); + OreSpawnScreenLayout.explain(this, reset, "tooltip.orespawn.ore_source.reset_all"); + int doneWidth = Math.min(150, rightPaneWidth); + addButton(new Button(rightPaneX + rightPaneWidth - doneWidth, height - 28, doneWidth, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private void resetAll() { + if (!resetArmed) { + resetArmed = true; + error = null; + rebuild(); + return; + } + session.resetOreSourcesToDefaults(); + selectedKey = null; + groupScroll = 0; + outputScroll = 0; + showAll = false; + resetArmed = false; + error = null; + rebuild(); + } + + private void initGroups(final List groups) { + int addX = leftPaneX + font.getStringWidth(I18n.format("label.orespawn.ore_source.groups")) + 8; + Button add = addButton(new Button(addX, 4, 16, 16, + new StringTextComponent("+"), button -> { + if (!syncWeights()) return; + captureScroll(); + selectedKey = session.addOreMaterialGroup(); + outputScroll = 0; + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, add, "button.orespawn.ore_source.add_group"); + int showX = addX + 20; + String showLabel = I18n.format(showAll + ? "button.orespawn.ore_source.hide_single" + : "button.orespawn.show_all"); + int showWidth = compactFilterWidth(leftPaneX + leftPaneWidth - showX, + font.getStringWidth(showLabel)); + Button show = addButton(OreSpawnScreenLayout.button(this, font, + showX, 4, showWidth, 16, + new StringTextComponent(showLabel), button -> { + if (!syncWeights()) return; + captureScroll(); + showAll = !showAll; + groupScroll = 0; + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, show, "tooltip.orespawn.ore_source.group_colours"); + + groupList = addButton(new CompactScrollList(this, leftPaneX, CONTENT_TOP, + leftPaneWidth, paneBottom - CONTENT_TOP) { + @Override protected int size() { return groups.size(); } + + @Override protected String rowText(int index) { + OreSourceGroup group = groups.get(index); + return group.displayName + " - " + display(group.domain); + } + + @Override protected int rowColor(int index) { + return groupRowColor(groups.get(index)); + } + + @Override protected boolean rowSelected(int index) { + return groups.get(index).key.equals(selectedKey); + } + + @Override protected void onRowPressed(int index) { + if (!syncWeights()) return; + captureScroll(); + selectedKey = groups.get(index).key; + outputScroll = 0; + error = null; + rebuild(); + } + + @Override protected List rowTooltip(int index) { + return groupTooltip(groups.get(index)); + } + + @Override protected int rowActionWidth(int index) { return 16; } + + @Override protected void drawRowAction(Minecraft minecraft, int index, + int x, int y, int actionWidth, int rowHeight, boolean hovered) { + drawCompactCog(minecraft, x, y, actionWidth, rowHeight, hovered); + } + + @Override protected void onRowActionPressed(int index) { + if (!syncWeights()) return; + captureScroll(); + selectedKey = groups.get(index).key; + minecraft.displayGuiScreen(new OreSourceGroupSettingsScreen( + OreSourceListScreen.this, session, selectedKey)); + } + + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList( + I18n.format("tooltip.orespawn.ore_source.group_settings")); + } + }); + groupList.setFirstIndex(groupScroll); + int selected = groupIndexOf(groups, selectedKey); + if (selected >= 0) groupList.ensureVisible(selected); + } + + private void initOutputs(final OreSourceGroup group) { + if (group == null) { + visibleCandidates = java.util.Collections.emptyList(); + return; + } + String mode = "keep_separate".equals(group.mode) ? "keep_original" : group.outputMode; + String acceptLabel = I18n.format("button.orespawn.ore_source.accept"); + int acceptWidth = group.needsReview() + ? Math.min(Math.max(42, font.getStringWidth(acceptLabel) + 10), rightPaneWidth / 3) + : 0; + int modeWidth = rightPaneWidth - (acceptWidth == 0 ? 0 : acceptWidth + 4); + Button modeButton = addButton(OreSpawnScreenLayout.button(this, font, + rightPaneX, CONTENT_TOP, modeWidth, 20, + new TranslationTextComponent("option.orespawn.ore_source.output_mode", + new TranslationTextComponent("mode.orespawn.ore_source." + mode).getFormattedText()), + button -> { + if (!syncWeights()) return; + cycleMode(group); + outputScroll = 0; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, modeButton, "tooltip.orespawn.ore_source.output_mode"); + if (acceptWidth > 0) { + Button accept = addButton(OreSpawnScreenLayout.button(this, font, + rightPaneX + modeWidth + 4, CONTENT_TOP, acceptWidth, 20, + new StringTextComponent(acceptLabel), button -> { + if (!syncWeights()) return; + captureScroll(); + session.acceptOreSourcePolicy(group.key); + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, accept, "tooltip.orespawn.ore_source.accept"); + } + + visibleCandidates = group.outputCandidates(); + final List candidates = visibleCandidates; + outputList = addButton(new CompactScrollList(this, rightPaneX, CONTENT_TOP + 24, + rightPaneWidth, paneBottom - CONTENT_TOP - 24) { + @Override protected int size() { return candidates.size(); } + + @Override protected String rowText(int index) { + OreSourceCandidate candidate = candidates.get(index); + boolean selected = group.outputs.containsKey(candidate.sourceId); + boolean single = "single".equals(group.outputMode) + && !"keep_separate".equals(group.mode); + String marker = single ? (selected ? "(*) " : "( ) ") + : (selected ? "[x] " : "[ ] "); + return marker + candidateLabel(candidate); + } + + @Override protected boolean rowEnabled(int index) { + OreSourceCandidate candidate = candidates.get(index); + return candidate.loaded && !candidate.enrichment + && !"keep_separate".equals(group.mode); + } + + @Override protected boolean rowSelected(int index) { + return group.outputs.containsKey(candidates.get(index).sourceId); + } + + @Override protected void onRowPressed(int index) { + if (!syncWeights()) return; + OreSourceCandidate candidate = candidates.get(index); + boolean single = "single".equals(group.outputMode) + && !"keep_separate".equals(group.mode); + session.setOreSourceOutput(group.key, candidate.sourceId, + single || !group.outputs.containsKey(candidate.sourceId), + group.outputs.containsKey(candidate.sourceId) + ? group.outputs.get(candidate.sourceId) : 1.0D); + captureScroll(); + rebuild(); + } + + @Override protected List rowTooltip(int index) { + return candidateTooltip(candidates.get(index)); + } + + @Override protected int rowActionWidth(int index) { + OreSourceCandidate candidate = candidates.get(index); + return "custom".equals(group.outputMode) + && !"keep_separate".equals(group.mode) + && group.outputs.containsKey(candidate.sourceId) ? 48 : 0; + } + }); + outputList.setFirstIndex(outputScroll); + + if ("custom".equals(group.outputMode) && !"keep_separate".equals(group.mode)) { + for (OreSourceCandidate candidate : candidates) { + if (!group.outputs.containsKey(candidate.sourceId)) continue; + TextFieldWidget weight = addButton(new TextFieldWidget(font, 0, 0, 42, 14, + new TranslationTextComponent("option.orespawn.ore_source.weight"))); + weight.setMaxLength(12); + weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); + weight.active = candidate.loaded && !candidate.enrichment; + weight.visible = false; + weights.put(candidate.sourceId, weight); + } + } + } + + private void updateWeightBounds() { + if (outputList == null) return; + for (int index = 0; index < visibleCandidates.size(); index++) { + OreSourceCandidate candidate = visibleCandidates.get(index); + TextFieldWidget weight = weights.get(candidate.sourceId); + if (weight == null) continue; + int y = outputList.rowY(index); + weight.visible = y >= 0; + if (weight.visible) weight.setBounds(outputList.contentRight() - 45, y + 1, 42, 14); + } + } + + private void cycleMode(OreSourceGroup group) { + resetArmed = false; + if (!group.hasManagedPlacementSource()) { + if (!"keep_separate".equals(group.mode)) session.restoreOreSourceOriginalMode(group.key); + error = I18n.format("label.orespawn.ore_source.no_placement_rules"); + return; + } + error = null; + if ("keep_separate".equals(group.mode)) session.setOreSourceOutputMode(group.key, "balanced"); + else if ("balanced".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "single"); + else if ("single".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "custom"); + else session.restoreOreSourceOriginalMode(group.key); + } + + private boolean syncWeights() { + OreSourceGroup group = selected(orderedGroups()); + if (group == null || !"custom".equals(group.outputMode)) return true; + for (Map.Entry entry : weights.entrySet()) { + if (!group.outputs.containsKey(entry.getKey())) continue; + try { + double value = Double.parseDouble(entry.getValue().getValue().trim()); + if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException(); + session.setOreSourceOutput(group.key, entry.getKey(), true, value); + } catch (NumberFormatException invalid) { + error = I18n.format("error.orespawn.ore_source.weight"); + return false; + } + } + error = null; + return true; + } + + private void captureScroll() { + resetArmed = false; + if (groupList != null) groupScroll = groupList.firstIndex(); + if (outputList != null) outputScroll = outputList.firstIndex(); + } + + private List orderedGroups() { + List result = new ArrayList<>(); + for (OreSourceGroup group : session.oreSourceGroups()) { + if (showAll || showByDefault(group)) result.add(group); + } + result.sort((left, right) -> { + int leftRank = groupStatusRank(left); + int rightRank = groupStatusRank(right); + if (leftRank != rightRank) return Integer.compare(leftRank, rightRank); + int name = left.displayName.compareToIgnoreCase(right.displayName); + if (name != 0) return name; + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return result; + } + + static boolean showByDefault(OreSourceGroup group) { + return !group.isRoutineSingleSource(); + } + + static int groupRowColor(OreSourceGroup group) { + return group.needsAttention() ? 0xFF5555 + : group.isRoutineSingleSource() ? 0x55FF55 : 0xFFFF55; + } + + private static int groupStatusRank(OreSourceGroup group) { + return group.needsAttention() ? 0 : group.isRoutineSingleSource() ? 2 : 1; + } + + private OreSourceGroup selected(List groups) { + if (selectedKey == null) return null; + for (OreSourceGroup group : groups) if (selectedKey.equals(group.key)) return group; + return null; + } + + static List placementCandidates(OreSourceGroup group, String channel) { + List result = new ArrayList<>(); + for (OreSourceCandidate candidate : group.candidates) { + if (candidate.loaded && candidate.active && !candidate.external && !candidate.enrichment + && channel.equals(candidate.channel)) result.add(candidate); + } + return result; + } + + static int indexOf(List candidates, String sourceId) { + for (int index = 0; index < candidates.size(); index++) { + if (candidates.get(index).sourceId.equals(sourceId)) return index; + } + return -1; + } + + static int groupIndexOf(List groups, String key) { + for (int index = 0; index < groups.size(); index++) { + if (groups.get(index).key.equals(key)) return index; + } + return -1; + } + + static String candidateLabel(OreSourceCandidate candidate) { + String owner = candidate.ownerName.isEmpty() ? candidate.owner : candidate.ownerName; + String state = candidate.external ? I18n.format("status.orespawn.ore_source.external_short") + : !candidate.loaded ? I18n.format("status.orespawn.ore_source.missing_short") + : !candidate.active ? I18n.format("status.orespawn.ore_source.output_only") : ""; + return owner + " - " + candidate.registryId + (state.isEmpty() ? "" : " (" + state + ")"); + } + + static List candidateTooltip(OreSourceCandidate candidate) { + List result = new ArrayList<>(); + result.add(I18n.format("label.orespawn.ore_source.owner", candidate.owner, + candidate.ownerVersion.isEmpty() ? "?" : candidate.ownerVersion)); + result.add(I18n.format("label.orespawn.ore_source.registry", candidate.registryId, candidate.metadata)); + result.add(I18n.format("label.orespawn.ore_source.dictionary", + candidate.blockTag.isEmpty() ? "-" : String.join(", ", candidate.blockTag))); + result.add(I18n.format(candidate.external ? "tooltip.orespawn.ore_source.external" + : !candidate.loaded ? "tooltip.orespawn.ore_source.missing" + : !candidate.active ? "tooltip.orespawn.ore_source.output_only" + : "tooltip.orespawn.ore_source.controlled")); + return result; + } + + static List groupTooltip(OreSourceGroup group) { + List result = new ArrayList<>(); + result.add(group.material); + result.add(I18n.format("label.orespawn.ore_source.summary", group.outputCandidates().size(), + I18n.format("status.orespawn.ore_source." + group.status))); + result.add(group.blockTagEntries.isEmpty() ? "-" + : String.join(", ", group.blockTagEntries)); + result.add(I18n.format("tooltip.orespawn.ore_source.group_colours")); + return result; + } + + static String formatWeight(Double weight) { + if (weight == null) return "1"; + long whole = weight.longValue(); + return weight.doubleValue() == whole ? Long.toString(whole) : Double.toString(weight.doubleValue()); + } + + static String display(String id) { + int split = id.indexOf(':'); + String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static void drawCompactCog(Minecraft minecraft, int x, int y, + int width, int height, boolean hovered) { + if (hovered) fill(x, y, x + width, y + height, 0x805F5F5F); + CogButton.drawCog(x + width / 2, y + height / 2, true); + } + + private void rebuild() { + buttons.clear(); + children.clear(); + init(); + } + + static int contentWidth(int width) { + return Math.max(240, Math.min(700, width - (HORIZONTAL_MARGIN * 2))); + } + + static int leftPaneWidth(int width) { + int content = contentWidth(width); + return Math.max(104, Math.min(280, (content * 43) / 100)); + } + + static int listHeight(int height) { + return Math.max(32, (height - 32) - CONTENT_TOP); + } + + static int compactFilterWidth(int availableWidth, int labelWidth) { + return Math.min(Math.max(38, labelWidth + 10), Math.max(16, availableWidth)); + } + + @Override + public void onClose() { + captureScroll(); + if (syncWeights()) minecraft.displayGuiScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + fill(leftPaneX - 2, CONTENT_TOP - 2, + leftPaneX + leftPaneWidth + 2, paneBottom + 2, 0x70000000); + fill(rightPaneX - 2, CONTENT_TOP - 2, + rightPaneX + rightPaneWidth + 2, paneBottom + 2, 0x70000000); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, new TranslationTextComponent("label.orespawn.ore_source.groups"), + leftPaneX + 2, 8, 0xFFFFFF); + if (error != null) { + drawCenteredString(font, new StringTextComponent(OreSpawnScreenLayout.fit(font, + new StringTextComponent(error), width - 24)), width / 2, height - 39, 0xFF5555); + } + if (selected(orderedGroups()) == null) { + int messageY = CONTENT_TOP + 10; + for (String line : font.listFormattedStringToWidth( + new TranslationTextComponent("label.orespawn.ore_source.none").getFormattedText(), + groupList.contentRight() - leftPaneX - 12)) { + drawString(font, new StringTextComponent(line), leftPaneX + 6, messageY, 0xA0A0A0); + messageY += 10; + } + } + updateWeightBounds(); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java new file mode 100644 index 00000000..09d541db --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java @@ -0,0 +1,124 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import zone.moddev.mc.orespawn.OreSpawn; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo; + +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.loading.moddiscovery.ModInfo; + +/** Collects installed mod settings for the Mods screen without exposing mutable data. */ +final class OreSpawnModDirectoryModel { + private OreSpawnModDirectoryModel() { + } + + static List snapshot() { + List loaded = new ArrayList<>(); + for (ModInfo mod : ModList.get().getMods()) { + loaded.add(new LoadedMod(mod.getModId(), mod.getDisplayName(), + mod.getVersion().toString())); + } + return assemble(loaded, WorldgenIntegrationManager.providerIntegrations(), + WorldSettingsExtensionRegistry.extensions()); + } + + static List assemble(List loadedMods, + List providers, List extensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedMod mod : loadedMods) { + if (mod != null && mod.modId != null) loaded.put(mod.modId, mod); + } + Map integrations = new LinkedHashMap<>(); + for (ProviderIntegrationInfo provider : providers) { + if (isVisible(provider.modId(), loaded)) { + integrations.computeIfAbsent(provider.modId(), ignored -> new MutableEntry()) + .provider = provider; + } + } + for (WorldSettingsExtension extension : extensions) { + String modId = extension.id().getNamespace(); + if (isVisible(modId, loaded)) { + integrations.computeIfAbsent(modId, ignored -> new MutableEntry()) + .extension = extension; + } + } + + List result = new ArrayList<>(); + for (Map.Entry integration : integrations.entrySet()) { + LoadedMod mod = loaded.get(integration.getKey()); + result.add(new Entry(mod.modId, nonBlank(mod.displayName, mod.modId), + nonBlank(mod.version, "?"), integration.getValue().provider, + integration.getValue().extension)); + } + result.sort(Comparator.comparing((Entry entry) -> entry.displayName.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.modId)); + return Collections.unmodifiableList(result); + } + + private static boolean isVisible(String modId, Map loaded) { + return modId != null && !OreSpawn.MODID.equals(modId) && loaded.containsKey(modId); + } + + private static String nonBlank(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value; + } + + static final class LoadedMod { + final String modId; + final String displayName; + final String version; + + LoadedMod(String modId, String displayName, String version) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + } + } + + static final class Entry { + final String modId; + final String displayName; + final String version; + final ProviderIntegrationInfo provider; + final WorldSettingsExtension extension; + + Entry(String modId, String displayName, String version, + ProviderIntegrationInfo provider, WorldSettingsExtension extension) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + this.provider = provider; + this.extension = extension; + } + + boolean configurable() { return extension != null; } + boolean nativeOs4() { return provider != null && provider.nativeOs4(); } + int schemaVersion() { return provider == null ? -1 : provider.schemaVersion(); } + int providerRevision() { return provider == null ? -1 : provider.providerRevision(); } + List legacyLineages() { + return provider == null ? Collections.emptyList() : provider.legacyLineages(); + } + String statusKey() { + if (provider == null) return "config_only"; + if (provider.rejected()) return "rejected"; + ProviderStatus status = provider.status(); + return status == null ? "inactive" : status.name().toLowerCase(Locale.ROOT); + } + } + + private static final class MutableEntry { + ProviderIntegrationInfo provider; + WorldSettingsExtension extension; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java new file mode 100644 index 00000000..19d070a3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java @@ -0,0 +1,189 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.resources.I18n; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.text.TranslationTextComponent; + +/** Paginated directory of loaded mods known to OreSpawn. */ +final class OreSpawnModsScreen extends OreSpawnScreen { + private static final int ROW_HEIGHT = 42; + private final Screen parent; + private final List entries; + private int page; + private String error; + private int contentLeft; + private int contentWidth; + private int listTop; + private int pageSize; + + OreSpawnModsScreen(Screen parent) { + this(parent, OreSpawnModDirectoryModel.snapshot()); + } + + OreSpawnModsScreen(Screen parent, List entries) { + super(new TranslationTextComponent("screen.orespawn.mods")); + this.parent = parent; + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + } + + @Override + protected void init() { + contentWidth = Math.min(390, Math.max(280, width - 24)); + contentLeft = (width - contentWidth) / 2; + listTop = 38; + int controlsY = height - 52; + pageSize = pageSize(height); + int pageCount = pageCount(entries.size(), pageSize); + page = Math.max(0, Math.min(page, pageCount - 1)); + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < entries.size(); index++) { + Entry entry = entries.get(start + index); + CogButton cog = addButton(new CogButton(contentLeft + contentWidth - 26, + listTop + index * ROW_HEIGHT + 9, button -> open(entry), + (button, mouseX, mouseY) -> renderStringTooltip(tooltip(entry), mouseX, mouseY))); + cog.active = entry.configurable(); + } + + Button previous = addButton(new Button(contentLeft, controlsY, 80, 20, + new TranslationTextComponent("button.orespawn.previous"), + button -> { page--; rebuildWidgets(); })); + Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20, + new TranslationTextComponent("button.orespawn.next"), + button -> { page++; rebuildWidgets(); })); + previous.active = page > 0; + next.active = page + 1 < pageCount; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + DialogTexts.GUI_DONE, button -> onClose())); + } + + private void open(Entry entry) { + if (!entry.configurable()) return; + try { + Screen screen = entry.extension.createScreen(this); + if (screen == null) { + error = I18n.format("error.orespawn.mod.screen_missing", entry.modId); + return; + } + WorldSettingsExtensionNavigation.open(this, screen); + } catch (RuntimeException failure) { + error = I18n.format("error.orespawn.mod.screen_failed", entry.modId, + failure.getClass().getSimpleName()); + } + } + + private List tooltip(Entry entry) { + List lines = new ArrayList<>(); + lines.add(I18n.format(entry.configurable() + ? "tooltip.orespawn.mod.configure" : "tooltip.orespawn.mod.unavailable", + entry.displayName)); + lines.add(I18n.format("label.orespawn.mod.id_version", entry.modId, entry.version)); + lines.add(integration(entry)); + for (Integer lineage : entry.legacyLineages()) { + if (!entry.nativeOs4() && lineage.intValue() == primaryLegacy(entry)) continue; + lines.add(I18n.format("label.orespawn.mod.additional_lineage", lineage)); + } + lines.add(I18n.format("label.orespawn.mod.status", + I18n.format("status.orespawn.mod." + entry.statusKey()))); + return lines; + } + + private String integration(Entry entry) { + if (entry.nativeOs4()) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.format("label.orespawn.mod.os4_version", + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.format("label.orespawn.mod.os4"); + } + int legacy = primaryLegacy(entry); + if (legacy > 0) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.format("label.orespawn.mod.legacy_version", legacy, + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.format("label.orespawn.mod.legacy", legacy); + } + return I18n.format("label.orespawn.mod.config_only"); + } + + private static int primaryLegacy(Entry entry) { + List lineages = entry.legacyLineages(); + return lineages.isEmpty() ? 0 : lineages.get(lineages.size() - 1); + } + + private void rebuildWidgets() { + buttons.clear(); + children.clear(); + init(); + } + + static int pageSize(int height) { + return Math.max(1, ((height - 52) - 38) / ROW_HEIGHT); + } + + static int pageCount(int entries, int pageSize) { + return Math.max(1, (Math.max(0, entries) + Math.max(1, pageSize) - 1) + / Math.max(1, pageSize)); + } + + @Override + public void onClose() { + minecraft.displayGuiScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 14, 0xFFFFFF); + if (entries.isEmpty()) { + drawCenteredString(font, new TranslationTextComponent("label.orespawn.mod.none"), + width / 2, height / 2 - 6, 0xA0A0A0); + } + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < entries.size(); index++) { + drawEntry(entries.get(start + index), listTop + index * ROW_HEIGHT); + } + int pages = pageCount(entries.size(), pageSize); + drawCenteredString(font, new StringTextComponent((page + 1) + " / " + pages), + width / 2, height - 46, 0xA0A0A0); + if (error != null) { + drawCenteredString(font, new StringTextComponent(error), width / 2, 27, 0xFF5555); + } + super.render(mouseX, mouseY, partialTick); + } + + private void drawEntry(Entry entry, int y) { + fill(contentLeft, y, contentLeft + contentWidth, y + 38, 0x70000000); + int textWidth = contentWidth - 36; + drawString(font, new StringTextComponent(trim(entry.displayName, textWidth)), + contentLeft + 6, y + 3, 0xFFFFFF); + drawString(font, new StringTextComponent(trim( + I18n.format("label.orespawn.mod.id_version", entry.modId, entry.version), textWidth)), + contentLeft + 6, y + 14, 0xA0A0A0); + String details = I18n.format("label.orespawn.mod.integration_status", integration(entry), + I18n.format("status.orespawn.mod." + entry.statusKey())); + drawString(font, new StringTextComponent(trim(details, textWidth)), + contentLeft + 6, y + 25, statusColor(entry.statusKey())); + } + + private String trim(String value, int maximumWidth) { + if (font.getStringWidth(value) <= maximumWidth) return value; + String suffix = "..."; + int width = maximumWidth - font.getStringWidth(suffix); + return font.trimStringToWidth(value, Math.max(0, width)) + suffix; + } + + private static int statusColor(String status) { + if ("active".equals(status)) return 0x55FF55; + if ("pending".equals(status)) return 0xFFFF55; + if ("rejected".equals(status)) return 0xFF5555; + return 0xA0A0A0; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java index 0fdbc401..9908463a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java @@ -9,10 +9,20 @@ import net.minecraft.util.text.ITextComponent; /** - * Shared Minecraft 1.14 screen helpers. Concrete screens implement the native - * matrix-free {@link Screen#render(int, int, float)} contract directly. + * Shared helpers for Minecraft 1.14 screens. + * Each screen renders through {@link Screen#render(int, int, float)}. */ abstract class OreSpawnScreen extends Screen { + @Override + public void render(int mouseX, int mouseY, float partialTick) { + super.render(mouseX, mouseY, partialTick); + for (Widget widget : buttons) { + if (widget instanceof CompactScrollList) { + ((CompactScrollList) widget).renderTooltip(mouseX, mouseY); + } + } + } + OreSpawnScreen(ITextComponent title) { super(title); } @@ -34,7 +44,11 @@ protected final void renderComponentTooltip(List lines renderTooltip(text, mouseX, mouseY); } - /** Package-private view used by the separately packaged client qualification fixture. */ + final void renderStringTooltip(List lines, int mouseX, int mouseY) { + renderTooltip(lines, mouseX, mouseY); + } + + /** Gives the isolated client test access to the screen's buttons. */ final List qualificationButtons() { return buttons; } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java index 9af9ab3c..01a20837 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java @@ -8,6 +8,7 @@ import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; +import zone.moddev.mc.orespawn.worldgen.GeomeConfig; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; @@ -106,8 +107,11 @@ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), contentWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.biomes_world_materials"), button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials")); - addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex), - contentWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.help"), + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.mods"), + button -> openMods(), "tooltip.orespawn.main.mods")); + addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.help"), button -> openHelp())); } else { geologyModeButton = OreSpawnScreenLayout.explain(this, @@ -150,12 +154,15 @@ columnWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.materi addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), columnWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.advanced"), button -> openAdvanced(), "tooltip.orespawn.main.advanced")); - addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++), + addButton(OreSpawnScreenLayout.explainedButton(this, font, right, top + (row * rowIndex++), + columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), + "tooltip.orespawn.main.fluid_editor")); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.mods"), + button -> openMods(), "tooltip.orespawn.main.mods")); + addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex), columnWidth, BUTTON_HEIGHT, new TranslationTextComponent("button.orespawn.help"), button -> openHelp())); - addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), - contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), - "tooltip.orespawn.main.fluid_editor")); } addButton(OreSpawnScreenLayout.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT, DialogTexts.GUI_DONE, button -> saveAndClose())); @@ -274,6 +281,11 @@ private void saveAndClose() { validationError = new net.minecraft.util.text.StringTextComponent(errors.get(0)); return; } + if (session.oreMaterialGroupsChanged() + && !GeomeConfig.persistOreMaterialGroups(session.oreMaterialGroupsCopy())) { + validationError = new TranslationTextComponent("error.orespawn.ore_source.defaults_write"); + return; + } WorldGeologyProfileManager.setPendingNewWorldProfile(session.profile()); minecraft.displayGuiScreen(parent); } @@ -318,6 +330,11 @@ private void openFluidDeposits() { minecraft.displayGuiScreen(new FluidDepositListScreen(this, session)); } + private void openMods() { + syncSession(); + minecraft.displayGuiScreen(new OreSpawnModsScreen(this)); + } + private void openHelp() { minecraft.displayGuiScreen(new OreSpawnGuideScreen(this)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java index a0f9aed2..d18b3833 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java @@ -3,7 +3,7 @@ import net.minecraft.client.gui.FontRenderer; import net.minecraft.util.text.ITextComponent; -/** 1.14 string-widget bridge retaining the later editor method names internally. */ +/** Wraps Minecraft 1.14's text field with the editor's string-based constructor. */ class TextFieldWidget extends net.minecraft.client.gui.widget.TextFieldWidget { TextFieldWidget(FontRenderer font, int x, int y, int width, int height, ITextComponent label) { this(font, x, y, width, height, label.getFormattedText()); @@ -24,4 +24,11 @@ String getValue() { void setMaxLength(int length) { setMaxStringLength(length); } + + void setBounds(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java new file mode 100644 index 00000000..2a5621bc --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java @@ -0,0 +1,42 @@ +package zone.moddev.mc.orespawn.client; + +import zone.moddev.mc.orespawn.OreSpawn; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screen.MainMenuScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraftforge.client.event.GuiOpenEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.common.Mod; + +/** Keeps an add-on's inherited Escape action inside OreSpawn's screen stack. */ +@Mod.EventBusSubscriber(modid = OreSpawn.MODID, value = Dist.CLIENT) +public final class WorldSettingsExtensionNavigation { + private static Screen directory; + private static Screen extensionScreen; + + private WorldSettingsExtensionNavigation() { + } + + static synchronized void open(Screen parentDirectory, Screen screen) { + directory = parentDirectory; + extensionScreen = screen; + Minecraft.getInstance().displayGuiScreen(screen); + } + + @SubscribeEvent + public static synchronized void onScreenOpen(GuiOpenEvent event) { + if (extensionScreen == null) return; + // Allow the initial jump from the directory to the add-on's screen. + if (event.getGui() == extensionScreen) return; + Minecraft minecraft = Minecraft.getInstance(); + boolean inheritedEscape = event.getGui() == null + || (minecraft.world == null && event.getGui() instanceof MainMenuScreen); + if (minecraft.currentScreen == extensionScreen && inheritedEscape) { + event.setGui(directory); + } + // Done already returns to the directory. Leave the add-on's other screen transitions alone. + directory = null; + extensionScreen = null; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java index 54a9de16..9ad46e67 100644 --- a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java +++ b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java @@ -29,7 +29,6 @@ public final class DocumentationExporter { "BIOMES.md", "MIGRATION.md", "TROUBLESHOOTING.md", - "AGENTS.md", "VERSIONS.md", "examples/examplemod-orespawn.json", "examples/orespawn-global.json", diff --git a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java index 2a7102cc..938e25bd 100644 --- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java @@ -47,6 +47,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Internal owner of provider discovery, validation, merging, and lifecycle. */ public final class WorldgenIntegrationManager { @@ -167,6 +168,56 @@ public static synchronized Set activeProviderIds() { return Collections.unmodifiableSet(new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet())); } + /** A stable snapshot of integrations for the client Mods directory. */ + public static synchronized List providerIntegrations() { + Set ids = new LinkedHashSet<>(); + ids.addAll(API_PROVIDERS.keySet()); + ids.addAll(RESOURCE_PROVIDERS.keySet()); + ids.addAll(FILE_PROVIDER_IDS); + ids.addAll(ACTIVE_PROVIDERS.keySet()); + ids.addAll(INVALID_PROVIDERS); + List sorted = new ArrayList<>(ids); + Collections.sort(sorted); + List result = new ArrayList<>(); + for (String id : sorted) { + JsonObject root = FILE_PROVIDERS.get(id); + if (root == null) root = RESOURCE_PROVIDERS.get(id); + ProviderDefinition active = ACTIVE_PROVIDERS.get(id); + if (root == null && active != null) root = active.root; + WorldgenProvider api = API_PROVIDERS.get(id); + boolean hasProvider = root != null || api != null || FILE_PROVIDER_IDS.contains(id); + result.add(new ProviderIntegrationInfo(id, hasProvider, hasProvider, + root == null ? (api == null ? -1 : 5) : integer(root, "schema_version", -1), + root == null ? (api == null ? -1 : api.revision()) + : integer(root, "provider_revision", -1), + getProviderStatus(id), INVALID_PROVIDERS.contains(id), + Collections.emptyList())); + } + return Collections.unmodifiableList(result); + } + + /** The editor works on copies, so changing a screen cannot alter a provider definition. */ + public static synchronized BiomeProviderDefaultsSnapshot biomeProviderDefaults() { + JsonObject palettes = new JsonObject(); + JsonObject materials = new JsonObject(); + for (ProviderDefinition provider : ACTIVE_PROVIDERS.values()) { + copyOwnedDefaults(provider, "biome_palettes", palettes); + copyOwnedDefaults(provider, "dimension_materials", materials); + } + return new BiomeProviderDefaultsSnapshot(palettes, materials, + new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet())); + } + + private static void copyOwnedDefaults(ProviderDefinition provider, String section, + JsonObject target) { + for (Entry entry : provider.section(section).entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject value = JsonCopies.copy(entry.getValue().getAsJsonObject()); + value.addProperty("source_provider", provider.modId); + target.add(entry.getKey(), value); + } + } + /** Merge new provider-owned defaults without overwriting pack or world values. */ public static synchronized boolean mergeProviderDefinitions(JsonObject target) { JsonObject manifests = object(target, "providers"); @@ -442,7 +493,7 @@ private static void rebuildActiveProviders() { static void validateProvider(String providerId, JsonObject root) { int schema = integer(root, "schema_version", -1); - if (schema != 1 && schema != 2 && schema != 3 && schema != 4) { + if (schema != 1 && schema != 2 && schema != 3 && schema != 4 && schema != 5) { throw new JsonSyntaxException("unsupported schema_version"); } if (!providerId.equals(string(root, "provider_modid", ""))) { @@ -486,7 +537,7 @@ static void validateProvider(String providerId, JsonObject root) { if ("rocks".equals(section)) { validateRock(entry.getKey(), entry.getValue().getAsJsonObject()); } else if ("ores".equals(section)) { - validateOre(entry.getKey(), entry.getValue().getAsJsonObject()); + validateOre(entry.getKey(), entry.getValue().getAsJsonObject(), schema); } else if ("geomes".equals(section)) { validateGeome(entry.getKey(), entry.getValue().getAsJsonObject()); } else if ("biome_rules".equals(section)) { @@ -540,7 +591,11 @@ private static void validateRock(String idText, JsonObject rock) { validateWeights(optionalObject(rock, "geomes")); } - private static void validateOre(String idText, JsonObject ore) { + private static void validateOre(String idText, JsonObject ore, int schema) { + if (schema < 5 && ore.has("material")) { + throw new JsonSyntaxException("ore material requires provider schema 5"); + } + if (ore.has("material")) new ResourceLocation(string(ore, "material", "")); String blockId = string(ore, "block", idText); Block output = block(blockId); if (output == null || output == Blocks.AIR) { @@ -554,19 +609,23 @@ private static void validateOre(String idText, JsonObject ore) { } for (Entry entry : dimensions.entrySet()) { new ResourceLocation(entry.getKey()); - validateOreRule(idText, entry); + validateOreRule(idText, entry, schema); } for (Entry entry : selectors.entrySet()) { OreDimensionSelector.fromId(new ResourceLocation(entry.getKey())); - validateOreRule(idText, entry); + validateOreRule(idText, entry, schema); } } - private static void validateOreRule(String idText, Entry entry) { + private static void validateOreRule(String idText, Entry entry, int schema) { if (!entry.getValue().isJsonObject()) { throw new JsonSyntaxException("ore dimension is not an object: " + entry.getKey()); } JsonObject rule = entry.getValue().getAsJsonObject(); + if (schema < 5 && rule.has("placement_channel")) { + throw new JsonSyntaxException("ore placement_channel requires provider schema 5"); + } + if (rule.has("placement_channel")) new ResourceLocation(string(rule, "placement_channel", "")); if (!bool(rule, "enabled", true)) return; int minY = integer(rule, "min_y", Integer.MIN_VALUE); int maxY = integer(rule, "max_y", Integer.MIN_VALUE); @@ -673,7 +732,7 @@ private static void validateTerrainDimension(String id, JsonObject dimension) { private static void validateFluidDeposit(String id, JsonObject deposit) { String blockId = string(deposit, "block", ""); Block output = block(blockId); - if (output == null || output == Blocks.AIR || output.getDefaultState().getFluidState().isEmpty()) { + if (!FluidBlocks.isFluidBlock(output)) { throw new JsonSyntaxException("fluid deposit output is not a fluid block: " + blockId); } JsonObject dimensions = requiredObject(deposit, "dimensions"); @@ -804,7 +863,7 @@ private static void validateDimensionMaterials(String id, JsonObject materials) for (String key : new String[] { "default_fluid", "deep_aquifer_fluid" }) { if (!materials.has(key)) continue; Block value = block(materials.get(key).getAsString()); - if (value == null || value == Blocks.AIR || value.getDefaultState().getFluidState().isEmpty()) { + if (!FluidBlocks.isFluidBlock(value)) { throw new JsonSyntaxException("dimension material is not a fluid block for " + id + ": " + materials.get(key).getAsString()); } @@ -1032,6 +1091,58 @@ JsonObject section(String name) { } } + /** Provider status shown by the client without exposing mutable provider data. */ + public static final class ProviderIntegrationInfo { + private final String modId; + private final boolean nativeOs4; + private final boolean provider; + private final int schemaVersion; + private final int providerRevision; + private final ProviderStatus status; + private final boolean rejected; + private final List legacyLineages; + + public ProviderIntegrationInfo(String modId, boolean nativeOs4, boolean provider, + int schemaVersion, int providerRevision, ProviderStatus status, + boolean rejected, List legacyLineages) { + this.modId = modId; + this.nativeOs4 = nativeOs4; + this.provider = provider; + this.schemaVersion = schemaVersion; + this.providerRevision = providerRevision; + this.status = status; + this.rejected = rejected; + this.legacyLineages = Collections.unmodifiableList(new ArrayList<>(legacyLineages)); + } + + public String modId() { return modId; } + public boolean nativeOs4() { return nativeOs4; } + public boolean hasProvider() { return provider; } + public int schemaVersion() { return schemaVersion; } + public int providerRevision() { return providerRevision; } + public ProviderStatus status() { return status; } + public boolean rejected() { return rejected; } + public List legacyLineages() { return legacyLineages; } + } + + /** Copies of provider defaults used when the editor resets biome and material settings. */ + public static final class BiomeProviderDefaultsSnapshot { + private final JsonObject palettes; + private final JsonObject materials; + private final Set activeProviders; + + private BiomeProviderDefaultsSnapshot(JsonObject palettes, JsonObject materials, + Set activeProviders) { + this.palettes = JsonCopies.copy(palettes); + this.materials = JsonCopies.copy(materials); + this.activeProviders = Collections.unmodifiableSet(new LinkedHashSet<>(activeProviders)); + } + + public JsonObject biomePalettesCopy() { return JsonCopies.copy(palettes); } + public JsonObject dimensionMaterialsCopy() { return JsonCopies.copy(materials); } + public Set activeProviderIds() { return activeProviders; } + } + public static final class TemplateDefinition { private final ResourceLocation id; private final String nameKey; diff --git a/src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java b/src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java new file mode 100644 index 00000000..462e74ea --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java @@ -0,0 +1,19 @@ +package zone.moddev.mc.orespawn.util; + +import net.minecraft.block.Block; +import net.minecraft.block.Blocks; +import net.minecraft.block.FlowingFluidBlock; +import net.minecraft.fluid.IFluidState; + +/** Distinguishes a fluid's own block from a waterlogged block. */ +public final class FluidBlocks { + private FluidBlocks() { } + + public static boolean isFluidBlock(Block block) { + if (block == null || block == Blocks.AIR) return false; + // Fluid blocks are identifiable even before their fluid holders are populated. + if (block instanceof FlowingFluidBlock) return true; + IFluidState fluid = block.getDefaultState().getFluidState(); + return !fluid.isEmpty() && fluid.getBlockState().getBlock() == block; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java b/src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java index e29ac3ff..9e43c06b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java +++ b/src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java @@ -8,11 +8,10 @@ import com.google.gson.JsonObject; /** - * Gson-version-neutral defensive copying for configuration data. + * Copies configuration JSON without exposing the original mutable data. * - *

Minecraft 1.14.4 bundles a Gson version where {@code deepCopy()} is not - * public. Keeping the compatibility shim here avoids changing any public JSON - * contracts or relying on a newer Gson at runtime.

+ *

Minecraft 1.14.4's Gson has no public {@code deepCopy()}. Keep that workaround here + * so callers do not need a newer Gson or a different JSON format.

*/ public final class JsonCopies { private JsonCopies() { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java index e69a401a..09d9a6c2 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedTerrainDimension.java @@ -9,7 +9,7 @@ import net.minecraft.block.BlockState; import net.minecraft.block.Blocks; -/** Immutable setup-time resolution of one terrain replacement dimension. */ +/** Terrain replacement settings resolved once before a dimension generates chunks. */ final class BakedTerrainDimension { final ResourceLocation key; private final Set biomeIds; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java index 04765e5a..07b2adb1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeFeatureInstaller.java @@ -12,9 +12,8 @@ import net.minecraftforge.registries.ForgeRegistries; /** - * Reversibly mutates Forge 28's static biome feature lists. Later versions - * rebuild immutable generation settings; Minecraft 1.14 exposes the stage - * lists directly instead. + * Adds and removes OreSpawn features in Forge 28's biome lists. + * Minecraft 1.14 exposes mutable lists, so the original entries must be saved and restored. */ final class BiomeFeatureInstaller { private static final Map>>> ORIGINALS = diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenBootstrap.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenBootstrap.java index ade74263..769fb02a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenBootstrap.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenBootstrap.java @@ -1,6 +1,6 @@ package zone.moddev.mc.orespawn.worldgen; -/** Internal bootstrap for biome-source codecs used by saved generator data. */ +/** Keeps the shared biome-source startup hook; Minecraft 1.14 needs no codec registration. */ public final class BiomeWorldgenBootstrap { private BiomeWorldgenBootstrap() { } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java index 8836d880..80007700 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java @@ -31,9 +31,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Bakes and installs optional biome and dimension-material integration. */ final class BiomeWorldgenManager { + private static final String UI_OVERRIDE_PREFIX = "orespawn:ui/biome_overrides/"; private static final Logger LOGGER = LogManager.getLogger(); private static volatile Map baked = Collections.emptyMap(); @@ -113,11 +115,11 @@ private static BakedBiomeWorldgen bake(ServerWorld level, JsonObject root) { return new BakedBiomeWorldgen(palettes, surfaces, materials); } - private static List bakePalettes(JsonObject root, + static List bakePalettes(JsonObject root, ResourceLocation dimension, Map surfaces) { JsonObject section = object(root, "biome_palettes"); List result = new ArrayList<>(); - for (Entry paletteEntry : section.entrySet()) { + for (Entry paletteEntry : orderedPaletteEntries(section)) { if (!paletteEntry.getValue().isJsonObject()) continue; JsonObject json = paletteEntry.getValue().getAsJsonObject(); if (!bool(json, "enabled", true) @@ -177,6 +179,17 @@ private static List bakePalettes(JsonObject root, return result; } + static List> orderedPaletteEntries(JsonObject section) { + List> ordered = new ArrayList<>(); + for (Entry entry : section.entrySet()) { + if (!entry.getKey().startsWith(UI_OVERRIDE_PREFIX)) ordered.add(entry); + } + for (Entry entry : section.entrySet()) { + if (entry.getKey().startsWith(UI_OVERRIDE_PREFIX)) ordered.add(entry); + } + return ordered; + } + private static Map bakeChoices( JsonObject palette, BakedBiomeWorldgen.Entry[] entries) { boolean replace = "replace".equals(string(palette, "mode", "augment")); @@ -305,7 +318,7 @@ private static BlockState state(JsonObject json, String key, boolean fluid) { Block block = ForgeRegistries.BLOCKS.getValue( new ResourceLocation(json.get(key).getAsString())); if (block == null || block == Blocks.AIR - || (fluid && block.getDefaultState().getFluidState().isEmpty())) return null; + || (fluid && !FluidBlocks.isFluidBlock(block))) return null; return block.getDefaultState(); } catch (RuntimeException e) { return null; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/ContextFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/ContextFeature.java index f1ad37d4..08150c86 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/ContextFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/ContextFeature.java @@ -12,7 +12,7 @@ import net.minecraft.world.gen.feature.Feature; import net.minecraft.world.gen.feature.IFeatureConfig; -/** Preserves the later context-shaped feature implementation on Forge 28. */ +/** Adapts Minecraft 1.14's feature arguments to OreSpawn's placement context. */ abstract class ContextFeature extends Feature { ContextFeature(Function, ? extends FC> decoder) { super(decoder); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/FeaturePlaceContext.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/FeaturePlaceContext.java index b06ef710..dd58a48f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FeaturePlaceContext.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FeaturePlaceContext.java @@ -8,7 +8,7 @@ import net.minecraft.world.gen.GenerationSettings; import net.minecraft.world.gen.feature.IFeatureConfig; -/** Target-local adapter for the context object introduced by Minecraft 1.17. */ +/** Collects the world, generator, random source and origin for one feature placement. */ final class FeaturePlaceContext { private final IWorld level; private final ChunkGenerator chunkGenerator; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java index 7bed5cfe..a548d795 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java @@ -41,6 +41,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** One allocation-light feature for all provider-owned underground fluid deposits. */ public final class FluidDepositFeature extends ContextFeature { @@ -52,6 +53,7 @@ public final class FluidDepositFeature extends ContextFeature { private static final Object CLASSIFIER_LOCK = new Object(); private static ConfiguredFeature configuredFeature; + private static Map resolvedHostRules = Collections.emptyMap(); private static volatile Map depositsByDimension = EMPTY_DIMENSIONS; private static volatile Map geomeConfigs = Collections.emptyMap(); private static volatile Map classifiers = Collections.emptyMap(); @@ -71,8 +73,18 @@ public static void registerConfiguredFeature() { } public static void refreshWorldConfig() { + refreshWorldConfig(Collections.emptyMap()); + } + + static void refreshTagOnlyWorldConfig() { + refreshWorldConfig(resolvedHostRules); + } + + private static void refreshWorldConfig(Map previous) { JsonObject profile = WorldGeologyProfileManager.activeProfile().rootCopy(); - depositsByDimension = bakeDeposits(profile); + Map resolved = new HashMap<>(); + depositsByDimension = bakeDeposits(profile, previous, resolved); + resolvedHostRules = resolved; Map configs = new HashMap<>(); for (ResourceLocation dimension : depositsByDimension.keySet()) { BakedGeomeConfig config = WorldIds.OVERWORLD.equals(dimension) @@ -211,7 +223,7 @@ private static boolean placeLobe(IWorld world, IChunk chunk, BakedDeposit deposi BlockState existing = chunk.getBlockState(cursor); if (deposit.accepts(existing, geome, config)) { cursor.setPos(x, y, z); - // Output was validated while baking; keep a final runtime guard for registry oddities. + // Check again before writing: a deposit must never place air or a non-fluid block. if (deposit.output.getBlock() != Blocks.AIR && !deposit.output.getFluidState().isEmpty()) { chunk.setBlockState(cursor, deposit.output, false); changed = true; @@ -305,7 +317,8 @@ static boolean isSealingState(BlockState state, BlockState output) { || (state.getMaterial().blocksMovement() && state.getFluidState().isEmpty()); } - private static Map bakeDeposits(JsonObject profile) { + private static Map bakeDeposits(JsonObject profile, + Map previous, Map resolved) { if (!bool(profile, "place_fluid_deposits", true) || !profile.has("fluid_deposits") || !profile.get("fluid_deposits").isJsonObject()) { return EMPTY_DIMENSIONS; @@ -318,7 +331,7 @@ private static Map bakeDeposits(JsonObject pro JsonObject deposit = depositEntry.getValue().getAsJsonObject(); if (!bool(deposit, "enabled", true)) continue; Block output = block(string(deposit, "block", "")); - if (output == null || output == Blocks.AIR || output.getDefaultState().getFluidState().isEmpty() + if (!FluidBlocks.isFluidBlock(output) || !deposit.has("dimensions") || !deposit.get("dimensions").isJsonObject()) { LOGGER.warn("Ignoring invalid fluid deposit '{}'", depositEntry.getKey()); continue; @@ -332,9 +345,16 @@ private static Map bakeDeposits(JsonObject pro if (dimensionId == null) continue; BakedGeomeConfig config = WorldIds.OVERWORLD.equals(dimensionId) ? GeomeConfig.baked() : GeomeConfig.baked(dimensionId); - BakedDeposit baked = bakeDeposit(output.getDefaultState(), rule, config, - resolvedTags); + String key = depositEntry.getKey() + "|" + dimensionId; + BakedDeposit baked = previous.get(key); + Set explicitHosts = Collections.newSetFromMap(new IdentityHashMap()); + addBlocks(explicitHosts, rule.get("host_blocks")); + // As with ores, leave successful block/family hosts unchanged during the tag retry. + if (baked == null || (baked.familyMask == 0 && explicitHosts.isEmpty())) { + baked = bakeDeposit(output.getDefaultState(), rule, config, resolvedTags); + } if (baked != null) { + resolved.put(key, baked); grouped.computeIfAbsent(dimensionId, ignored -> new ArrayList<>()).add(baked); } else { LOGGER.warn("Ignoring invalid fluid deposit '{}' in '{}'", diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java index 3fe90e89..50481861 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -63,6 +63,10 @@ public final class GeomeConfig { private static final Path CONFIG_V3_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v3.bak"); private static final Path CONFIG_V4_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v4.bak"); private static final Path CONFIG_V5_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v5.bak"); + private static final Path CONFIG_V6_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v6.bak"); + private static final Path CONFIG_V7_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v7.bak"); + private static final Path MATERIAL_GROUPS_BACKUP_PATH = Paths.get("config", + "orespawn-worldgen.pre-material-groups.bak"); private static final Path BIOME_DEFAULTS_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.pre-biome-revision-3.bak"); private static final Path WORLDGEN_ALIAS_DEFAULTS_BACKUP_PATH = Paths.get("config", @@ -72,7 +76,7 @@ public final class GeomeConfig { private static final Path CONFIG_TEMP_PATH = Paths.get("config", "orespawn-worldgen.json.tmp"); private static final Path PROVIDER_DEFAULTS_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.pre-provider-defaults.bak"); - public static final int SCHEMA_VERSION = 6; + public static final int SCHEMA_VERSION = 8; private static final int BIOME_DEFAULTS_REVISION = 3; private static final int WORLDGEN_ALIAS_DEFAULTS_REVISION = 1; private static final int ORE_DEFAULTS_REVISION = 10; @@ -98,6 +102,7 @@ public static synchronized BakedGeomeConfig bake() { JsonObject root = loadConfig(); boolean changed = OreSpawnOreIntegration.mergeProviderOres(root); changed |= FluidDepositMigration.normalize(root); + changed |= OreSourcePolicies.initialize(root, false); if (changed) { writeProviderMerge(root); } @@ -163,6 +168,23 @@ public static WorldGeologyProfile globalBaseProfile() { return globalBaseProfile; } + /** Saves accepted material names and tag aliases as defaults for new worlds. */ + public static synchronized boolean persistOreMaterialGroups(JsonObject groups) { + JsonObject updated = globalBaseConfigSnapshot(); + updated.add(OreMaterialGroups.SECTION, JsonCopies.copy(groups)); + OreMaterialGroups.initialize(updated); + updated.addProperty("schema_version", SCHEMA_VERSION); + if (!writeUpdatedConfig(updated, MATERIAL_GROUPS_BACKUP_PATH)) return false; + globalBaseConfigRoot = JsonCopies.copy(updated); + globalBaseProfile = WorldGeologyProfile.fromGlobalConfig(updated, + OreSpawnConfig.geologyMode(), OreSpawnConfig.placeCrudeOil()); + JsonObject effective = applyFreshWorldTemplate(updated); + globalConfigRoot = JsonCopies.copy(effective); + globalProfile = WorldGeologyProfile.fromGlobalConfig(effective, + OreSpawnConfig.geologyMode(), OreSpawnConfig.placeCrudeOil()); + return true; + } + static boolean hasTerrainReplacement(ResourceLocation dimension) { return terrainDimension(dimension) != null; } @@ -195,13 +217,15 @@ private static JsonObject loadConfig() { int schemaVersion = getInt(root, "schema_version", 1); if (schemaVersion < SCHEMA_VERSION) { JsonObject migrated = schemaVersion <= 1 ? migrateV1(root) : JsonCopies.copy(root); - migrated = migrateToV6(migrated, defaults); + migrated = migrateToV8(migrated, defaults); writeMigratedConfig(migrated, schemaVersion <= 1 ? CONFIG_BACKUP_PATH : schemaVersion == 2 ? CONFIG_V2_BACKUP_PATH : schemaVersion == 3 ? CONFIG_V3_BACKUP_PATH : schemaVersion == 4 ? CONFIG_V4_BACKUP_PATH - : CONFIG_V5_BACKUP_PATH); + : schemaVersion == 5 ? CONFIG_V5_BACKUP_PATH + : schemaVersion == 6 ? CONFIG_V6_BACKUP_PATH + : CONFIG_V7_BACKUP_PATH); return migrated; } if (schemaVersion > SCHEMA_VERSION) { @@ -650,14 +674,15 @@ private static JsonObject migrateV1(JsonObject original) { return refreshBiomeDefaults(migrated, defaultConfig()); } - private static JsonObject migrateToV6(JsonObject original, JsonObject defaults) { + private static JsonObject migrateToV8(JsonObject original, JsonObject defaults) { JsonObject migrated = getInt(original, "biome_defaults_revision", 0) < BIOME_DEFAULTS_REVISION ? refreshBiomeDefaults(original, defaults) : JsonCopies.copy(original); FluidDepositMigration.normalize(migrated); for (String key : new String[] { "geology_mode", "place_fluid_deposits", "fluid_deposits", "manage_vanilla_ores", "ore_defaults_revision", "cyano", "ores", "ore_providers", "providers", "worldgen_aliases", "default_template", - "terrain_dimensions", "biome_palettes", "dimension_materials" }) { + "terrain_dimensions", "biome_palettes", "dimension_materials", + OreMaterialGroups.SECTION, OreSourcePolicies.SECTION }) { if (!migrated.has(key)) { migrated.add(key, JsonCopies.copy(defaults.get(key))); } @@ -1406,6 +1431,8 @@ private static JsonObject defaultConfig() { root.add("terrain_dimensions", new JsonObject()); root.add("biome_palettes", new JsonObject()); root.add("dimension_materials", new JsonObject()); + root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults()); + root.add(OreSourcePolicies.SECTION, new JsonObject()); JsonObject retrogen = new JsonObject(); retrogen.addProperty("enabled", false); retrogen.addProperty("force", false); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java index c63b5316..df2194da 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -190,8 +190,8 @@ int classifyColumn(Biome biome, int x, int z, double[] regionalValues) { } /** - * Classifies a column once for read-only API sampling. All Y queries on the - * returned object reuse the same biome, geome, stratum, and formation data. + * Classifies a column once for read-only API sampling. + * Every height query reuses the same biome, geome, stratum and formation data. */ public ColumnSample sampleColumn(Biome biome, ResourceLocation biomeId, int x, int z) { double[] regionalValues = new double[config.geomeCount()]; @@ -285,8 +285,7 @@ private BlockState pickStableReplacement(int geomeIndex, long formationRegion, i int rockIndex = regionallyVariedRocks[layerBucket] ? regionalIndex : originalFamilyIndex; int rockBucket = whiteNoiseArray[rockIndex] & 0xFF; if (familyDiversitySlots > 1) { - // Reuse the same rendezvous table while preventing thick single-family provinces - // from collapsing onto one exact rock. + // Reuse the rendezvous table, but vary the rock so a thick single-family province is not uniform. rockBucket ^= LITHOLOGY_ROCK_SALTS[familySlot]; } RockFamily family = config.pickStableFamilyAtWorldY(geomeIndex, worldY, formationY, @@ -353,9 +352,8 @@ static int pickStableLayerGeome(double[] geomeScores, int firstGeome, int second return higherGeome; } - // Bit reversal supplies an allocation-free low-discrepancy sequence. Nearby - // layers therefore cross a close geome boundary at different horizontal - // positions instead of moving as one full-height wall. + // Bit reversal spreads these samples evenly without allocating. + // Nearby layers cross the geome boundary at different positions, rather than forming one tall wall. int pairPhase = (lowerGeome * 53) + (higherGeome * 97); int layerBucket = (layerIndex + phase + pairPhase) & 0xFF; int threshold = Integer.reverse(layerBucket) >>> 24; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index 5b4fe027..8ffdda64 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -359,7 +359,7 @@ private static void writeReport(Path config, List lines) { private static void writeUpgradeReport(Path config, int imported, List detail) { List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.114041 Upgrade Report"); + lines.add("OreSpawn 4.1.0.114041 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn settings were imported into the OS4 profile."); 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 d967c7e2..b2170071 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -35,14 +35,12 @@ import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; /** - * Snapshots the exact Mineralogy geology contract used by an already-generated - * world before OreSpawn becomes responsible for that world's geology. + * Saves a world's existing Mineralogy geology settings before OreSpawn takes over generation. * - *

The 1.10 and 1.12 Forge configuration files are related but not - * interchangeable. Mineralogy 5.x uses a third, TOML-based contract and can - * select either its Cyano layer engine or its geome engine. Saved world mod - * metadata therefore chooses the lineage; merely finding an old file in a - * reused instance is never enough to reclassify a fresh world.

+ *

Mineralogy's 1.10 and 1.12 Forge configurations are similar but not interchangeable. + * Mineralogy 5.x uses TOML and can select either the Cyano layer engine or the geome engine. + * Choose the version from the world's saved mod metadata, not from leftover configuration files + * that may belong to another world.

*/ final class LegacyMineralogyProfileMigration { private static final Logger LOGGER = LogManager.getLogger(); @@ -199,7 +197,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configPath, Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); List missing = missingBlocks(igneous, metamorphic, sedimentary); List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.114041 Upgrade Report"); + lines.add("OreSpawn 4.1.0.114041 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/MmdOreConflictCatalog.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalog.java new file mode 100644 index 00000000..38b282dd --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalog.java @@ -0,0 +1,64 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Names known MMD ore providers; it never creates an ore or a placement rule. */ +final class MmdOreConflictCatalog { + private static final Set ORDINARY = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList( + "basemetals", "modernmetals", "basegems", "baseminerals", "fantasymetals", + "electricadvantage", "steamadvantage", "poweradvantage", "mineralogy"))); + private static final Map> PRIORITIES = priorities(); + + private MmdOreConflictCatalog() { } + + static boolean ordinary(String owner) { + return ORDINARY.contains(owner); + } + + static boolean enrichment(String owner) { + return "densemetals".equals(owner); + } + + static boolean netherOnly(String owner) { + return "nethermetals".equals(owner); + } + + static boolean endOnly(String owner) { + return "endmetals".equals(owner); + } + + static List priority(String material) { + return PRIORITIES.getOrDefault(material, Collections.emptyList()); + } + + /** Automatic consolidation requires two loaded ordinary MMD providers sharing an exact ore tag. */ + static boolean automaticallyBalance(Map> tagsByOwner) { + Map> ownersByTag = new HashMap<>(); + for (Map.Entry> entry : tagsByOwner.entrySet()) { + if (!ordinary(entry.getKey())) continue; + for (String tag : entry.getValue()) { + if (!OreMaterialGroups.validTag(tag)) continue; + ownersByTag.computeIfAbsent(tag, ignored -> new HashSet<>()).add(entry.getKey()); + } + } + for (Set owners : ownersByTag.values()) if (owners.size() >= 2) return true; + return false; + } + + private static Map> priorities() { + Map> result = new LinkedHashMap<>(); + result.put("sulfur", Collections.unmodifiableList(Arrays.asList( + "mineralogy", "baseminerals", "electricadvantage"))); + result.put("lithium", Collections.unmodifiableList(Arrays.asList( + "baseminerals", "electricadvantage"))); + return Collections.unmodifiableMap(result); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreHeightDistribution.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreHeightDistribution.java index 6c5b51d0..77004320 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreHeightDistribution.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreHeightDistribution.java @@ -44,8 +44,7 @@ int sample(Random random, int min, int max) { } private static int bottomBand(int range) { - // Vanilla's low redstone placement mixes a full-range pass with a - // triangular pass concentrated in the bottom half. + // Vanilla's low redstone mixes a full-range pass with a triangular pass in the bottom half. return Math.max(1, (range + 1) / 2); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java new file mode 100644 index 00000000..dcaddf91 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java @@ -0,0 +1,320 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.util.ResourceLocation; +import zone.moddev.mc.orespawn.util.JsonCopies; + +/** Stable material names and exact block-tag aliases used to group equivalent ore sources. */ +final class OreMaterialGroups { + static final String SECTION = "ore_material_groups"; + static final String TAGS = "block_tag_entries"; + static final String LEGACY_ALIASES = "ore_dictionary_entries"; + private static final ResourceLocation LEGACY_REVIEW = new ResourceLocation("orespawn", "review_required"); + private static final ResourceLocation SULFUR = new ResourceLocation("orespawn", "sulfur"); + private static final ResourceLocation ALUMINUM = new ResourceLocation("orespawn", "aluminum"); + + private OreMaterialGroups() { } + + static boolean initialize(JsonObject root) { + JsonObject before = root.has(SECTION) && root.get(SECTION).isJsonObject() + ? JsonCopies.copy(root.getAsJsonObject(SECTION)) : new JsonObject(); + JsonObject normalized = normalize(before); + mergeCurated(normalized, SULFUR, "Sulfur", "forge:ores/sulfur", "forge:ores/sulphur"); + mergeCurated(normalized, ALUMINUM, "Aluminum", "forge:ores/aluminum", "forge:ores/aluminium"); + deduplicateAliases(normalized); + root.add(SECTION, normalized); + return !before.toString().equals(normalized.toString()); + } + + static JsonObject defaults() { + JsonObject root = new JsonObject(); + mergeCurated(root, SULFUR, "Sulfur", "forge:ores/sulfur", "forge:ores/sulphur"); + mergeCurated(root, ALUMINUM, "Aluminum", "forge:ores/aluminum", "forge:ores/aluminium"); + return root; + } + + static Inference infer(JsonObject root, Iterable blockTags) { + Map owners = aliases(root); + Set materials = new LinkedHashSet<>(); + Set exactNames = new LinkedHashSet<>(); + for (String name : blockTags) { + if (!validTag(name)) continue; + exactNames.add(name); + ResourceLocation material = owners.get(name); + if (material == null) { + ResourceLocation tag = resource(name); + String token = tag.getPath().substring("ores/".length()); + try { material = new ResourceLocation("orespawn", token); } + catch (RuntimeException invalid) { material = null; } + } + if (material != null) materials.add(material); + } + List exact = new ArrayList<>(exactNames); + Collections.sort(exact); + if (materials.size() == 1) { + ResourceLocation material = materials.iterator().next(); + return new Inference(material, false, exact); + } + return new Inference(materials.size() > 1 ? provisionalMaterial(exact) : null, + materials.size() > 1, exact); + } + + static List definitions(JsonObject root) { + JsonObject groups = root.has(SECTION) && root.get(SECTION).isJsonObject() + ? root.getAsJsonObject(SECTION) : defaults(); + List result = new ArrayList<>(); + for (Entry entry : groups.entrySet()) { + ResourceLocation id = resource(entry.getKey()); + if (id == null || !entry.getValue().isJsonObject()) continue; + JsonObject value = entry.getValue().getAsJsonObject(); + List names = new ArrayList<>(); + if (value.has(TAGS) && value.get(TAGS).isJsonArray()) { + for (JsonElement element : value.getAsJsonArray(TAGS)) { + try { + String name = element.getAsString(); + if (validTag(name) && !names.contains(name)) names.add(name); + } catch (RuntimeException ignored) { } + } + } + Collections.sort(names); + String display = string(value, "display_name", humanize(id.getPath())); + result.add(new Definition(id, display, names, legacyAliases(value), curated(id))); + } + result.sort(Comparator.comparing((Definition value) -> value.displayName.toLowerCase(Locale.ROOT)) + .thenComparing(value -> value.id.toString())); + return Collections.unmodifiableList(result); + } + + static Definition definition(JsonObject root, ResourceLocation id) { + for (Definition definition : definitions(root)) if (definition.id.equals(id)) return definition; + return new Definition(id, humanize(id.getPath()), Collections.emptyList(), + Collections.emptyList(), curated(id)); + } + + static boolean ensureDefinition(JsonObject root, ResourceLocation id, Iterable blockTags) { + List exact = exactTags(blockTags); + JsonObject groups = root.has(SECTION) && root.get(SECTION).isJsonObject() + ? root.getAsJsonObject(SECTION) : defaults(); + boolean changed = !root.has(SECTION) || !root.get(SECTION).isJsonObject(); + JsonObject value; + if (groups.has(id.toString()) && groups.get(id.toString()).isJsonObject()) { + value = groups.getAsJsonObject(id.toString()); + } else { + value = new JsonObject(); + value.addProperty("display_name", defaultDisplayName(id, exact)); + value.add(TAGS, new JsonArray()); + groups.add(id.toString(), value); + changed = true; + } + JsonArray aliases = value.has(TAGS) + && value.get(TAGS).isJsonArray() + ? value.getAsJsonArray(TAGS) : new JsonArray(); + if (!value.has(TAGS) || !value.get(TAGS).isJsonArray()) { + value.add(TAGS, aliases); + changed = true; + } + Set present = new LinkedHashSet<>(); + for (JsonElement alias : aliases) { + try { present.add(alias.getAsString()); } catch (RuntimeException ignored) { } + } + Map owners = aliases(root); + for (String name : exact) { + if (!validTag(name) || present.contains(name)) continue; + ResourceLocation owner = owners.get(name); + if (owner != null && !owner.equals(id)) continue; + aliases.add(new JsonPrimitive(name)); + present.add(name); + changed = true; + } + root.add(SECTION, groups); + return changed; + } + + static boolean validTag(String name) { + ResourceLocation tag = resource(name); + return tag != null && name.length() <= 128 + && tag.getPath().startsWith("ores/") + && tag.getPath().length() > "ores/".length(); + } + + static boolean curated(ResourceLocation id) { + return SULFUR.equals(id) || ALUMINUM.equals(id); + } + + private static JsonObject normalize(JsonObject groups) { + JsonObject result = new JsonObject(); + for (Entry entry : groups.entrySet()) { + ResourceLocation id = resource(entry.getKey()); + if (id == null || LEGACY_REVIEW.equals(id) || !entry.getValue().isJsonObject()) continue; + JsonObject source = entry.getValue().getAsJsonObject(); + JsonObject value = new JsonObject(); + value.addProperty("display_name", string(source, "display_name", humanize(id.getPath()))); + JsonArray names = new JsonArray(); + Set seen = new LinkedHashSet<>(); + if (source.has(TAGS) && source.get(TAGS).isJsonArray()) { + for (JsonElement element : source.getAsJsonArray(TAGS)) { + try { + String name = element.getAsString(); + if (validTag(name) && seen.add(name)) names.add(new JsonPrimitive(name)); + } catch (RuntimeException ignored) { } + } + } + value.add(TAGS, names); + JsonArray dormant = new JsonArray(); + for (String alias : legacyAliases(source)) dormant.add(new JsonPrimitive(alias)); + if (dormant.size() > 0) value.add(LEGACY_ALIASES, dormant); + result.add(id.toString(), value); + } + return result; + } + + private static void mergeCurated(JsonObject groups, ResourceLocation id, String display, String... aliases) { + if (groups.has(id.toString()) && groups.get(id.toString()).isJsonObject()) return; + JsonObject value = new JsonObject(); + value.addProperty("display_name", display); + JsonArray names = new JsonArray(); + for (String alias : aliases) names.add(new JsonPrimitive(alias)); + value.add(TAGS, names); + groups.add(id.toString(), value); + } + + private static void deduplicateAliases(JsonObject groups) { + Set claimed = new LinkedHashSet<>(); + for (Entry entry : groups.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject value = entry.getValue().getAsJsonObject(); + JsonArray unique = new JsonArray(); + if (value.has(TAGS) && value.get(TAGS).isJsonArray()) { + for (JsonElement element : value.getAsJsonArray(TAGS)) { + try { + String name = element.getAsString(); + if (validTag(name) && claimed.add(name)) unique.add(new JsonPrimitive(name)); + } catch (RuntimeException ignored) { } + } + } + value.add(TAGS, unique); + } + } + + private static Map aliases(JsonObject root) { + Map result = new LinkedHashMap<>(); + for (Definition definition : definitions(root)) { + for (String name : definition.blockTagEntries) result.putIfAbsent(name, definition.id); + } + return result; + } + + private static List legacyAliases(JsonObject value) { + Set result = new LinkedHashSet<>(); + if (value.has(LEGACY_ALIASES) && value.get(LEGACY_ALIASES).isJsonArray()) { + for (JsonElement element : value.getAsJsonArray(LEGACY_ALIASES)) { + try { + String alias = element.getAsString(); + if (alias.length() <= 128 && alias.matches("ore[A-Z][A-Za-z0-9_]+")) result.add(alias); + } catch (RuntimeException ignored) { } + } + } + return new ArrayList<>(result); + } + + private static ResourceLocation resource(String value) { + try { return value == null || value.trim().isEmpty() ? null : new ResourceLocation(value); } + catch (RuntimeException ignored) { return null; } + } + + private static String string(JsonObject root, String key, String fallback) { + try { return root.has(key) ? root.get(key).getAsString() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private static String humanize(String path) { + String value = path.replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static ResourceLocation provisionalMaterial(List exact) { + String token = exact.isEmpty() ? "unknown" + : resource(exact.get(0)).getPath().substring("ores/".length()); + return new ResourceLocation("orespawn", "review/" + token + '-' + stableAliasHash(exact)); + } + + private static String stableAliasHash(List exact) { + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return padded.append(value).toString(); + } + + private static List exactTags(Iterable blockTags) { + Set unique = new LinkedHashSet<>(); + for (String name : blockTags) if (validTag(name)) unique.add(name); + List result = new ArrayList<>(unique); + Collections.sort(result); + return result; + } + + private static String defaultDisplayName(ResourceLocation id, List exact) { + if (!"orespawn".equals(id.getNamespace()) || !id.getPath().startsWith("review/")) { + return humanize(id.getPath()); + } + StringBuilder display = new StringBuilder(); + for (String name : exact) { + if (display.length() > 0) display.append(", "); + display.append(humanize(resource(name).getPath().substring("ores/".length()))); + } + return display.length() == 0 ? "Review required" : display.toString(); + } + + static final class Definition { + final ResourceLocation id; + final String displayName; + final List blockTagEntries; + final List dormantOreDictionaryEntries; + final boolean curated; + + Definition(ResourceLocation id, String displayName, List blockTagEntries, + List dormantOreDictionaryEntries, boolean curated) { + this.id = id; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.dormantOreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(dormantOreDictionaryEntries)); + this.curated = curated; + } + } + + static final class Inference { + final ResourceLocation material; + final boolean reviewRequired; + final List names; + + Inference(ResourceLocation material, boolean reviewRequired, List names) { + this.material = material; + this.reviewRequired = reviewRequired; + this.names = Collections.unmodifiableList(new ArrayList<>(names)); + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OrePattern.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OrePattern.java index 5f599954..55b49153 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OrePattern.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OrePattern.java @@ -11,10 +11,10 @@ public enum OrePattern { CLUSTERS("clusters"), UNDERFLUIDS("underfluids"); - /** @deprecated Config and source compatibility alias. */ + /** @deprecated Use {@link #CLUSTERS}. Kept for older configurations and callers. */ @Deprecated public static final OrePattern CLUSTER = CLUSTERS; - /** @deprecated Config and source compatibility alias. */ + /** @deprecated Use {@link #NORMAL_CLOUD}. Kept for older configurations and callers. */ @Deprecated public static final OrePattern CLOUD = NORMAL_CLOUD; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreRetrogenManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreRetrogenManager.java index c8c4ba10..f9293baa 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreRetrogenManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreRetrogenManager.java @@ -13,7 +13,7 @@ import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.world.ChunkDataEvent; -/** Bounded, marker-based ore and flat-bedrock retrogen with no reflected internals. */ +/** Updates existing chunks in small batches, using markers to avoid repeating ore and bedrock work. */ public final class OreRetrogenManager { private static final String ROOT_TAG = "OreSpawn"; private static final String REVISION_TAG = "generation_revision"; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java new file mode 100644 index 00000000..641feaf0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -0,0 +1,808 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.ArrayList; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import zone.moddev.mc.orespawn.util.JsonCopies; + +import net.minecraft.block.Block; +import net.minecraft.block.Blocks; +import net.minecraft.tags.BlockTags; +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.registries.ForgeRegistries; + +/** Finds equivalent managed ore sources and retains the player's selections. */ +final class OreSourcePolicies { + static final ResourceLocation STANDARD = new ResourceLocation("orespawn", "standard"); + static final String SECTION = "ore_source_policies"; + private static final String MODE_CONSOLIDATED = "consolidated"; + private static final String MODE_SEPARATE = "keep_separate"; + private static final String OUTPUT_BALANCED = "balanced"; + private static final String OUTPUT_SINGLE = "single"; + private static final String OUTPUT_CUSTOM = "custom"; + + private OreSourcePolicies() { } + + /** + * Finds available sources and creates policies only where none are stored. + * Keep existing selections so they can be restored when a missing mod returns. + */ + static boolean initialize(JsonObject root, boolean existingWorld) { + boolean changed = OreMaterialGroups.initialize(root); + boolean hadPolicies = root.has(SECTION) && root.get(SECTION).isJsonObject(); + JsonObject policies = object(root, SECTION); + Map groups = discover(root); + for (Group group : groups.values()) { + Set names = new LinkedHashSet<>(); + for (Candidate candidate : group.candidates) names.addAll(candidate.blockTags); + changed |= OreMaterialGroups.ensureDefinition(root, group.material, names); + } + changed |= !hadPolicies; + Set refreshedKeys = new LinkedHashSet<>(); + for (Group group : groups.values()) { + String key = key(group.material, group.domain); + JsonObject previous = policies.has(key) && policies.get(key).isJsonObject() + ? policies.getAsJsonObject(key) : null; + JsonObject refreshed = previous == null + ? createPolicy(group, existingWorld) : refreshPolicy(previous, group); + refreshed.remove("dormant"); + if (previous == null || !previous.toString().equals(refreshed.toString())) { + policies.add(key, refreshed); + changed = true; + } + refreshedKeys.add(key); + } + for (Entry entry : new ArrayList<>(policies.entrySet())) { + if (refreshedKeys.contains(entry.getKey()) || !entry.getValue().isJsonObject()) continue; + JsonObject previous = entry.getValue().getAsJsonObject(); + ResourceLocation material = resource(string(previous, "material", "")); + ResourceLocation domain = resource(string(previous, "domain", "")); + if (material == null || domain == null) continue; + Group group = groups.get(key(material, domain)); + if (group == null) group = new Group(material, domain); + JsonObject refreshed = refreshPolicy(previous, group); + if (candidatesWereReassigned(root, previous, material)) { + refreshed.addProperty("dormant", true); + } + if (!previous.toString().equals(refreshed.toString())) { + policies.add(entry.getKey(), refreshed); + changed = true; + } + } + root.add(SECTION, policies); + return changed; + } + + /** + * A stored policy may be absent from discovery because its mod is missing or its aliases moved. + * Show missing mods so the player can see their saved settings. + * Keep moved policies dormant so moving the aliases back restores those settings. + */ + private static boolean candidatesWereReassigned(JsonObject root, JsonObject policy, + ResourceLocation material) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false; + boolean foundInferredCandidate = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "material_declared", false)) return false; + List names = strings(candidate.get("block_tags")); + if (names.isEmpty()) return false; + OreMaterialGroups.Inference inferred = OreMaterialGroups.infer(root, names); + if (inferred.material == null || material.equals(inferred.material)) return false; + foundInferredCandidate = true; + } + return foundInferredCandidate; + } + + static Snapshot snapshot(JsonObject root) { + Map discovered = discover(root); + JsonObject policies = object(root, SECTION); + List views = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + if (bool(policy, "dormant", false)) continue; + ResourceLocation material = resource(string(policy, "material", "")); + ResourceLocation domain = resource(string(policy, "domain", "")); + if (material == null || domain == null) continue; + Group current = discovered.get(key(material, domain)); + List candidates = readCandidates(policy, current); + String mode = string(policy, "mode", MODE_SEPARATE); + OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, material); + views.add(new GroupView(entry.getKey(), material, domain, definition.displayName, + definition.blockTagEntries, definition.curated, + mode, outputMode(policy), status(policy, candidates), candidates, + decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"))); + seen.add(entry.getKey()); + } + for (Group group : discovered.values()) { + String key = key(group.material, group.domain); + if (seen.contains(key)) continue; + JsonObject policy = createPolicy(group, true); + OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, group.material); + views.add(new GroupView(key, group.material, group.domain, definition.displayName, + definition.blockTagEntries, definition.curated, MODE_SEPARATE, OUTPUT_CUSTOM, + status(policy, group.candidates), immutableCandidates(group.candidates), + decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"))); + } + views.sort(Comparator.comparing((GroupView view) -> view.material.toString()) + .thenComparing(view -> view.domain.toString())); + return new Snapshot(views); + } + + static ResourceLocation material(JsonObject ore, Block output) { + ResourceLocation explicit = resource(string(ore, "material", "")); + if (explicit != null) return explicit; + return inferMaterial(blockTags(output)).material; + } + + static ResourceLocation material(JsonObject root, JsonObject ore, Block output) { + ResourceLocation explicit = resource(string(ore, "material", "")); + if (explicit != null) return explicit; + return inferMaterial(root, blockTags(output)).material; + } + + static ResourceLocation placementChannel(JsonObject rule) { + ResourceLocation explicit = resource(string(rule, "placement_channel", "")); + if (explicit != null) return explicit; + JsonObject pattern = rule.has("pattern") && rule.get("pattern").isJsonObject() + ? rule.getAsJsonObject("pattern") : null; + if (pattern != null) { + ResourceLocation type = resource(string(pattern, "type", "")); + if (type != null) return type; + } + String legacy = string(rule, "pattern", ""); + ResourceLocation type = legacy.indexOf(':') >= 0 ? resource(legacy) : null; + return type != null ? type : STANDARD; + } + + static Inference inferMaterial(Iterable blockTags) { + JsonObject root = new JsonObject(); + root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults()); + return inferMaterial(root, blockTags); + } + + static Inference inferMaterial(JsonObject root, Iterable blockTags) { + OreMaterialGroups.Inference inferred = OreMaterialGroups.infer(root, blockTags); + return new Inference(inferred.material, inferred.reviewRequired, inferred.names); + } + + static String key(ResourceLocation material, ResourceLocation domain) { + return material.toString() + "|" + domain.toString(); + } + + private static Map discover(JsonObject root) { + Map groups = new LinkedHashMap<>(); + boolean manageVanillaOres = bool(root, "manage_vanilla_ores", false); + JsonObject ores = object(root, "ores"); + for (Entry oreEntry : ores.entrySet()) { + if (!oreEntry.getValue().isJsonObject()) continue; + JsonObject ore = oreEntry.getValue().getAsJsonObject(); + boolean oreActive = bool(ore, "enabled", true) + && (!bool(ore, "native_generation", false) || manageVanillaOres); + ResourceLocation blockId = resource(string(ore, "block", oreEntry.getKey())); + Block block = blockId == null ? null : ForgeRegistries.BLOCKS.getValue(blockId); + if (block == null || block == Blocks.AIR) continue; + int metadata = integer(ore, "metadata", 0); + Inference inferred = inferMaterial(root, blockTags(block)); + ResourceLocation material = resource(string(ore, "material", "")); + boolean materialDeclared = material != null; + if (material == null) material = inferred.material; + if (material == null) continue; + String owner = owner(ore, blockId); + Role role = role(owner); + if (role == Role.NONE) continue; + for (Entry dimension : object(ore, "dimensions").entrySet()) { + addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material, + resource(dimension.getKey()), dimension.getValue(), inferred, role, + oreActive, materialDeclared); + } + for (Entry selector : object(ore, "dimension_selectors").entrySet()) { + addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material, + resource(selector.getKey()), selector.getValue(), inferred, role, + oreActive, materialDeclared); + } + } + addExternalTagCandidates(groups, root); + return groups; + } + + private static void addConfigured(Map groups, String sourceId, String owner, + ResourceLocation blockId, int metadata, ResourceLocation material, ResourceLocation domain, + JsonElement ruleElement, Inference inferred, Role role, boolean oreActive, + boolean materialDeclared) { + if (domain == null || !ruleElement.isJsonObject()) return; + if (role == Role.NETHER && !"minecraft:the_nether".equals(domain.toString())) return; + if (role == Role.END && !"minecraft:the_end".equals(domain.toString())) return; + JsonObject rule = ruleElement.getAsJsonObject(); + boolean active = oreActive && bool(rule, "enabled", true); + Candidate candidate = new Candidate(sourceId, owner, modName(owner), modVersion(owner), blockId, + metadata, material, domain, placementChannel(rule), inferred.names, true, active, + false, role == Role.ENRICHMENT, inferred.reviewRequired, materialDeclared); + group(groups, material, domain).add(candidate); + } + + private static void addExternalTagCandidates(Map groups, JsonObject root) { + if (groups.isEmpty()) return; + Map> namesByMaterial = new HashMap<>(); + for (String name : safeBlockTagNames()) { + Inference inference = inferMaterial(root, Collections.singletonList(name)); + if (inference.material != null && !inference.reviewRequired) { + namesByMaterial.computeIfAbsent(inference.material, ignored -> new ArrayList<>()).add(name); + } + } + for (Group group : groups.values()) { + List names = namesByMaterial.get(group.material); + if (names == null) continue; + for (String tagName : names) { + for (Block block : safeTaggedBlocks(tagName)) { + ResourceLocation id = block == null ? null : ForgeRegistries.BLOCKS.getKey(block); + if (id == null || block == Blocks.AIR || group.hasBlock(id, 0)) continue; + String owner = id.getNamespace(); + Role role = role(owner); + if (role == Role.NONE || role == Role.ENRICHMENT) continue; + if (role == Role.NETHER && !"minecraft:the_nether".equals(group.domain.toString())) continue; + if (role == Role.END && !"minecraft:the_end".equals(group.domain.toString())) continue; + String sourceId = "external/" + id.toString(); + group.add(new Candidate(sourceId, owner, modName(owner), modVersion(owner), id, + 0, group.material, group.domain, STANDARD, + Collections.singletonList(tagName), loaded(owner), false, true, false, false, + false)); + } + } + } + } + + static JsonObject createPolicy(Group group, boolean existingWorld) { + JsonObject policy = new JsonObject(); + policy.addProperty("material", group.material.toString()); + policy.addProperty("domain", group.domain.toString()); + Candidate preferred = preferred(group); + boolean automatic = !existingWorld && preferred != null && highConfidence(group); + policy.addProperty("mode", automatic ? MODE_CONSOLIDATED : MODE_SEPARATE); + policy.addProperty("output_mode", automatic ? OUTPUT_BALANCED : OUTPUT_CUSTOM); + JsonObject outputs = new JsonObject(); + for (Candidate candidate : group.outputs()) { + if (candidate.external) continue; + outputs.addProperty(candidate.sourceId, 1.0D); + } + policy.add("outputs", outputs); + JsonObject placements = new JsonObject(); + for (ResourceLocation channel : group.channels()) { + Candidate selected = preferred(group, channel); + if (selected != null) placements.addProperty(channel.toString(), selected.sourceId); + } + policy.add("placement_sources", placements); + policy.addProperty("review_required", !automatic && group.reviewRequired()); + policy.addProperty("status", automatic ? "consolidated" + : group.hasExternal() ? "external_generation" + : group.reviewRequired() ? "review_required" : "separate"); + policy.add("candidates", candidates(group.candidates)); + return policy; + } + + private static JsonObject refreshPolicy(JsonObject previous, Group group) { + JsonObject policy = JsonCopies.copy(previous); + policy.addProperty("material", group.material.toString()); + policy.addProperty("domain", group.domain.toString()); + if (!MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))) { + policy.addProperty("mode", MODE_SEPARATE); + } + policy.addProperty("output_mode", outputMode(policy)); + if (!policy.has("outputs") || !policy.get("outputs").isJsonObject()) { + JsonObject outputs = new JsonObject(); + for (Candidate candidate : group.outputs()) if (!candidate.external) { + outputs.addProperty(candidate.sourceId, 1.0D); + } + policy.add("outputs", outputs); + } + if (!policy.has("placement_sources") || !policy.get("placement_sources").isJsonObject()) { + policy.add("placement_sources", new JsonObject()); + } + List merged = readCandidates(previous, group); + policy.add("candidates", candidates(merged)); + String status = status(policy, merged); + policy.addProperty("status", status); + policy.addProperty("review_required", "review_required".equals(status) + || bool(previous, "review_required", false)); + return policy; + } + + private static String outputMode(JsonObject policy) { + String configured = string(policy, "output_mode", ""); + if (OUTPUT_BALANCED.equals(configured) || OUTPUT_SINGLE.equals(configured) + || OUTPUT_CUSTOM.equals(configured)) return configured; + Map outputs = decimalMap(policy, "outputs"); + if (outputs.size() <= 1) return OUTPUT_SINGLE; + double first = outputs.values().iterator().next(); + for (double value : outputs.values()) { + if (Double.compare(first, value) != 0) return OUTPUT_CUSTOM; + } + return OUTPUT_BALANCED; + } + + private static String status(JsonObject policy, List candidates) { + Map current = new HashMap<>(); + boolean external = false; + boolean review = false; + for (Candidate candidate : candidates) { + current.put(candidate.sourceId, candidate); + external |= candidate.external && candidate.loaded; + review |= candidate.reviewRequired; + } + Map outputs = stringMap(policy, "outputs"); + Map placements = stringMap(policy, "placement_sources"); + if (MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE)) + && (outputs.isEmpty() || placements.isEmpty())) return "missing_source"; + for (String selected : outputs.keySet()) { + Candidate candidate = current.get(selected); + if (candidate == null || !candidate.loaded) return "missing_source"; + } + for (String selected : placements.values()) { + Candidate candidate = current.get(selected); + if (candidate == null || !candidate.loaded || !candidate.active) return "missing_source"; + } + if (external) return "external_generation"; + if (review || bool(policy, "review_required", false)) return "review_required"; + return MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE)) + ? "consolidated" : "separate"; + } + + private static List readCandidates(JsonObject policy, Group current) { + Map merged = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + Candidate candidate = candidate(element, false); + if (candidate != null) merged.put(candidate.identity(), candidate); + } + } + if (current != null) { + for (Candidate candidate : current.candidates) merged.put(candidate.identity(), candidate); + } + List result = new ArrayList<>(); + Set currentIds = new LinkedHashSet<>(); + if (current != null) for (Candidate candidate : current.candidates) currentIds.add(candidate.identity()); + for (Candidate candidate : merged.values()) { + result.add(currentIds.contains(candidate.identity()) ? candidate : candidate.missing()); + } + result.sort(CANDIDATE_ORDER); + return Collections.unmodifiableList(result); + } + + private static JsonArray candidates(List values) { + JsonArray result = new JsonArray(); + List sorted = new ArrayList<>(values); + sorted.sort(CANDIDATE_ORDER); + for (Candidate candidate : sorted) result.add(candidate.toJson()); + return result; + } + + private static List immutableCandidates(List values) { + List copy = new ArrayList<>(values); + copy.sort(CANDIDATE_ORDER); + return Collections.unmodifiableList(copy); + } + + private static final Comparator CANDIDATE_ORDER = Comparator + .comparing((Candidate candidate) -> candidate.owner) + .thenComparing(candidate -> candidate.sourceId) + .thenComparing(candidate -> candidate.channel.toString()); + + private static Candidate candidate(JsonElement element, boolean defaultLoaded) { + if (element == null || !element.isJsonObject()) return null; + JsonObject json = element.getAsJsonObject(); + ResourceLocation block = resource(string(json, "registry_id", "")); + ResourceLocation material = resource(string(json, "material", "")); + ResourceLocation domain = resource(string(json, "domain", "")); + ResourceLocation channel = resource(string(json, "placement_channel", "")); + if (block == null || material == null || domain == null || channel == null) return null; + List names = strings(json.get("block_tags")); + return new Candidate(string(json, "source_id", ""), string(json, "owner", block.getNamespace()), + string(json, "owner_name", ""), string(json, "owner_version", ""), block, + integer(json, "metadata", 0), material, domain, channel, names, + bool(json, "loaded", defaultLoaded), bool(json, "placement_active", defaultLoaded), + bool(json, "external", false), bool(json, "enrichment", false), + bool(json, "review_required", false), bool(json, "material_declared", false)); + } + + private static boolean highConfidence(Group group) { + Map> tagsByOwner = new HashMap<>(); + for (Candidate candidate : group.configured()) { + if (!candidate.loaded || candidate.enrichment) continue; + tagsByOwner.computeIfAbsent(candidate.owner, ignored -> new LinkedHashSet<>()) + .addAll(candidate.blockTags); + } + return MmdOreConflictCatalog.automaticallyBalance(tagsByOwner); + } + + private static Candidate preferred(Group group) { + for (String owner : MmdOreConflictCatalog.priority(group.material.getPath())) { + for (Candidate candidate : group.configured()) if (owner.equals(candidate.owner)) return candidate; + } + for (Candidate candidate : group.configured()) { + if (MmdOreConflictCatalog.ordinary(candidate.owner)) return candidate; + } + return group.configured().isEmpty() ? null : group.configured().get(0); + } + + private static Candidate preferred(Group group, ResourceLocation channel) { + Candidate preferred = preferred(group); + if (preferred != null && channel.equals(preferred.channel)) return preferred; + for (Candidate candidate : group.configured()) if (channel.equals(candidate.channel)) return candidate; + return null; + } + + private static Role role(String owner) { + if (MmdOreConflictCatalog.enrichment(owner)) return Role.ENRICHMENT; + if ("basesciences".equals(owner)) return Role.NONE; + if (MmdOreConflictCatalog.netherOnly(owner)) return Role.NETHER; + if (MmdOreConflictCatalog.endOnly(owner)) return Role.END; + if (MmdOreConflictCatalog.ordinary(owner) || "minecraft".equals(owner) || "orespawn".equals(owner)) { + return Role.ORDINARY; + } + return Role.ORDINARY; + } + + private static Group group(Map groups, ResourceLocation material, ResourceLocation domain) { + String key = key(material, domain); + return groups.computeIfAbsent(key, ignored -> new Group(material, domain)); + } + + private static String owner(JsonObject ore, ResourceLocation block) { + String owner = string(ore, "source_provider", string(ore, "source_mod", block.getNamespace())); + return owner.trim().toLowerCase(Locale.ROOT); + } + + private static boolean loaded(String owner) { + if ("minecraft".equals(owner) || "orespawn".equals(owner)) return true; + try { return ModList.get().isLoaded(owner); } + catch (Throwable ignored) { return false; } + } + + private static String modName(String owner) { + return owner; + } + + private static String modVersion(String owner) { + return ""; + } + + private static List blockTags(Block block) { + List result = new ArrayList<>(); + for (Entry entry : tagMap().entrySet()) { + if (OreMaterialGroups.validTag(entry.getKey().toString()) + && tagMembers(entry.getValue()).contains(block)) result.add(entry.getKey().toString()); + } + Collections.sort(result); + return result; + } + + private static List safeBlockTagNames() { + List result = new ArrayList<>(); + for (ResourceLocation id : tagMap().keySet()) { + if (OreMaterialGroups.validTag(id.toString())) result.add(id.toString()); + } + Collections.sort(result); + return result; + } + + private static List safeTaggedBlocks(String name) { + Object tag = tagMap().get(new ResourceLocation(name)); + return tag == null ? Collections.emptyList() : new ArrayList<>(tagMembers(tag)); + } + + @SuppressWarnings("unchecked") + private static Map tagMap() { + Object collection = BlockTags.getCollection(); + try { + Method method = mappedMethod(collection.getClass(), "getTagMap", "func_200039_c"); + return (Map) method.invoke(collection); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Cannot read the block tags on this Forge runtime", failure); + } + } + + @SuppressWarnings("unchecked") + private static java.util.Collection tagMembers(Object tag) { + try { + Method method = mappedMethod(tag.getClass(), "getAllElements", "func_199885_a"); + return (java.util.Collection) method.invoke(tag); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Cannot read a block tag on this Forge runtime", failure); + } + } + + private static Method mappedMethod(Class owner, String named, String srg) + throws NoSuchMethodException { + try { return owner.getMethod(named); } + catch (NoSuchMethodException ignored) { return owner.getMethod(srg); } + } + + private static JsonObject object(JsonObject root, String key) { + if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key); + JsonObject result = new JsonObject(); + root.add(key, result); + return result; + } + + private static ResourceLocation resource(String value) { + try { return value == null || value.trim().isEmpty() ? null : new ResourceLocation(value); } + catch (RuntimeException ignored) { return null; } + } + + private static Map stringMap(JsonObject root, String key) { + if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : root.getAsJsonObject(key).entrySet()) { + try { result.put(entry.getKey(), entry.getValue().getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static Map decimalMap(JsonObject root, String key) { + if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : root.getAsJsonObject(key).entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static List strings(JsonElement element) { + if (element == null || !element.isJsonArray()) return Collections.emptyList(); + List result = new ArrayList<>(); + for (JsonElement value : element.getAsJsonArray()) { + try { result.add(value.getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableList(result); + } + + private static String string(JsonObject root, String key, String fallback) { + try { return root.has(key) ? root.get(key).getAsString() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private static boolean bool(JsonObject root, String key, boolean fallback) { + try { return root.has(key) ? root.get(key).getAsBoolean() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private static int integer(JsonObject root, String key, int fallback) { + try { return root.has(key) ? root.get(key).getAsInt() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private enum Role { ORDINARY, NETHER, END, ENRICHMENT, NONE } + + static final class Inference { + final ResourceLocation material; + final boolean reviewRequired; + final List names; + + Inference(ResourceLocation material, boolean reviewRequired, List names) { + this.material = material; + this.reviewRequired = reviewRequired; + this.names = Collections.unmodifiableList(new ArrayList<>(names)); + } + } + + static final class Snapshot { + private final List groups; + + Snapshot(List groups) { + this.groups = Collections.unmodifiableList(new ArrayList<>(groups)); + } + + List groups() { return groups; } + } + + static final class GroupView { + final String key; + final ResourceLocation material; + final ResourceLocation domain; + final String displayName; + final List blockTagEntries; + final boolean curated; + final String mode; + final String outputMode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + GroupView(String key, ResourceLocation material, ResourceLocation domain, + String displayName, List blockTagEntries, boolean curated, + String mode, String outputMode, String status, List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.curated = curated; + this.mode = mode; + this.outputMode = outputMode; + this.status = status; + this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates)); + this.outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs)); + this.placements = Collections.unmodifiableMap(new LinkedHashMap<>(placements)); + } + } + + static final class Candidate { + final String sourceId; + final String owner; + final String ownerName; + final String ownerVersion; + final ResourceLocation block; + final int metadata; + final ResourceLocation material; + final ResourceLocation domain; + final ResourceLocation channel; + final List blockTags; + final boolean loaded; + final boolean active; + final boolean external; + final boolean enrichment; + final boolean reviewRequired; + final boolean materialDeclared; + + Candidate(String sourceId, String owner, String ownerName, String ownerVersion, + ResourceLocation block, int metadata, ResourceLocation material, ResourceLocation domain, + ResourceLocation channel, List blockTags, boolean loaded, boolean active, + boolean external, boolean enrichment, boolean reviewRequired, boolean materialDeclared) { + this.sourceId = sourceId; + this.owner = owner; + this.ownerName = ownerName; + this.ownerVersion = ownerVersion; + this.block = block; + this.metadata = metadata; + this.material = material; + this.domain = domain; + this.channel = channel; + this.blockTags = Collections.unmodifiableList(new ArrayList<>(blockTags)); + this.loaded = loaded; + this.active = active; + this.external = external; + this.enrichment = enrichment; + this.reviewRequired = reviewRequired; + this.materialDeclared = materialDeclared; + } + + String identity() { return sourceId + '|' + channel.toString(); } + + String outputIdentity() { return block.toString() + '|' + metadata; } + + Candidate missing() { + return new Candidate(sourceId, owner, ownerName, ownerVersion, block, metadata, material, + domain, channel, blockTags, false, false, external, enrichment, reviewRequired, + materialDeclared); + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("source_id", sourceId); + json.addProperty("owner", owner); + json.addProperty("owner_name", ownerName); + json.addProperty("owner_version", ownerVersion); + json.addProperty("registry_id", block.toString()); + json.addProperty("metadata", metadata); + json.addProperty("material", material.toString()); + json.addProperty("domain", domain.toString()); + json.addProperty("placement_channel", channel.toString()); + JsonArray tags = new JsonArray(); + for (String name : blockTags) tags.add(new JsonPrimitive(name)); + json.add("block_tags", tags); + json.addProperty("loaded", loaded); + json.addProperty("placement_active", active); + json.addProperty("external", external); + json.addProperty("enrichment", enrichment); + json.addProperty("review_required", reviewRequired); + json.addProperty("material_declared", materialDeclared); + return json; + } + } + + static final class Group { + final ResourceLocation material; + final ResourceLocation domain; + final List candidates = new ArrayList<>(); + + Group(ResourceLocation material, ResourceLocation domain) { + this.material = material; + this.domain = domain; + } + + void add(Candidate candidate) { + for (Candidate value : candidates) if (value.identity().equals(candidate.identity())) return; + candidates.add(candidate); + } + + boolean hasBlock(ResourceLocation block, int metadata) { + for (Candidate value : candidates) { + if (value.block.equals(block) && value.metadata == metadata) return true; + } + return false; + } + + List managed() { + List result = new ArrayList<>(); + for (Candidate value : candidates) if (!value.external && !value.enrichment) result.add(value); + result.sort(CANDIDATE_ORDER); + return result; + } + + List configured() { + List result = new ArrayList<>(); + for (Candidate value : managed()) if (value.active) result.add(value); + return result; + } + + List outputs() { + Map unique = new LinkedHashMap<>(); + for (Candidate value : candidates) { + if (value.enrichment || !value.loaded) continue; + String key = value.outputIdentity(); + Candidate previous = unique.get(key); + if (previous == null || (!previous.active && value.active) + || (previous.external && !value.external)) unique.put(key, value); + } + List result = new ArrayList<>(unique.values()); + result.sort(CANDIDATE_ORDER); + return result; + } + + Set channels() { + Set result = new LinkedHashSet<>(); + for (Candidate value : configured()) result.add(value.channel); + return result; + } + + boolean hasExternal() { + for (Candidate value : candidates) if (value.external) return true; + return false; + } + + boolean reviewRequired() { + for (Candidate value : candidates) if (value.reviewRequired) return true; + return hasManagedConflict() && !highConfidence(this); + } + + private boolean hasManagedConflict() { + List managed = managed(); + Set outputs = new LinkedHashSet<>(); + Map> ownersByChannel = new LinkedHashMap<>(); + for (Candidate value : managed) { + outputs.add(value.outputIdentity()); + if (value.active) ownersByChannel.computeIfAbsent(value.channel, + ignored -> new LinkedHashSet<>()).add(value.owner); + } + if (outputs.size() > 1) return true; + for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return true; + return false; + } + + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java index e777e70f..4366eeca 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Random; import java.util.Set; import java.util.function.Supplier; @@ -18,7 +19,10 @@ import com.google.gson.JsonObject; import zone.moddev.mc.orespawn.OreSpawn; import zone.moddev.mc.orespawn.api.CompiledOrePattern; +import zone.moddev.mc.orespawn.api.GeologySampler; +import zone.moddev.mc.orespawn.api.OreGenerationContext; import zone.moddev.mc.orespawn.api.OreDimensionSelector; +import zone.moddev.mc.orespawn.api.OreSpawnApi; import zone.moddev.mc.orespawn.api.OrePlacementContext; import zone.moddev.mc.orespawn.init.OreSpawnPatterns; @@ -58,6 +62,7 @@ public final class OreSpawnOreGeneration extends ContextFeature private static final Object CLASSIFIER_LOCK = new Object(); private static ConfiguredFeature configuredFeature; + private static Map resolvedHostRules = Collections.emptyMap(); private static volatile Map oresByDimension = EMPTY_DIMENSIONS; private static volatile Map> vanillaTakeoverOutputs = Collections.emptyMap(); private static volatile BakedOre[] selectorOres = NO_ORES; @@ -81,8 +86,19 @@ public static void registerConfiguredFeatures() { } public static void refreshWorldConfig() { + refreshWorldConfig(Collections.emptyMap()); + } + + static void refreshTagOnlyWorldConfig() { + refreshWorldConfig(resolvedHostRules); + } + + private static void refreshWorldConfig(Map previous) { geomeConfig = GeomeConfig.baked(); - BakedOres baked = bakeOres(WorldGeologyProfileManager.activeProfile().rootCopy(), geomeConfig); + Map resolved = new HashMap<>(); + BakedOres baked = bakeOres(WorldGeologyProfileManager.activeProfile().rootCopy(), + geomeConfig, previous, resolved); + resolvedHostRules = resolved; oresByDimension = baked.byDimension; vanillaTakeoverOutputs = baked.vanillaOutputs; selectorOres = baked.selectorOres; @@ -113,6 +129,7 @@ static boolean needsVanillaOreGate(ResourceLocation dimension) { @Override boolean place(FeaturePlaceContext context) { + WorldGeologyProfileManager.resolveFreshPoliciesBeforeGeneration(); IWorld world = context.level(); ResourceLocation dimension = WorldIds.dimension(world); BakedOre[] ores = oresForDimension(dimension); @@ -175,7 +192,8 @@ private static boolean generateChunk(IWorld world, IChunk chunk, Biome biome, } int attempts = attemptsForFrequency(random, frequency); for (int attempt = 0; attempt < attempts; attempt++) { - changed |= placeAttempt(world, chunk, random, ore, geome, scratch); + changed |= placeAttempt(world, chunk, random, ore, geome, + dimension, worldSeed, attempt, scratch); } } if (changed) { @@ -194,7 +212,7 @@ private static long mix(long seed, long chunk, long revision) { } private static boolean placeAttempt(IWorld world, IChunk chunk, Random random, - BakedOre ore, int geome, + BakedOre ore, int geome, ResourceLocation dimension, long worldSeed, int attempt, GenerationScratch scratch) { int minY = Math.max(ore.minY, 0); int maxY = Math.min(ore.maxY, 256 - 1); @@ -205,7 +223,11 @@ private static boolean placeAttempt(IWorld world, IChunk chunk, Random random, int y = ore.heightDistribution.sample(random, minY, maxY); int z = chunk.getPos().getZStart() + random.nextInt(16); int quantity = sampleQuantity(random, ore.minQuantity, ore.maxQuantity); - scratch.patternContext.initialize(world, chunk, random, ore, geome, x, y, z, minY, maxY, + scratch.patternContext.initialize(world, chunk, random, ore, geome, + dimension, worldSeed, + ore.arbitrationSources.length == 0 ? null + : ore.outputSource(worldSeed, chunk.getPos().asLong(), attempt), + x, y, z, minY, maxY, quantity); return ore.pattern.place(scratch.patternContext); } @@ -237,28 +259,25 @@ private static boolean insideChunk(IChunk chunk, int x, int y, int z) { && y >= 0 && y < 256; } - private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { + private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config, + Map previous, Map resolved) { if (!profile.has("ores") || !profile.get("ores").isJsonObject()) { return BakedOres.EMPTY; } boolean manageVanillaOres = bool(profile, "manage_vanilla_ores", false); Map> resolvedTags = new HashMap<>(); List rules = new ArrayList<>(); + Map outputBundles = new LinkedHashMap<>(); Set explicitDimensions = new HashSet<>(); for (Entry oreEntry : profile.getAsJsonObject("ores").entrySet()) { if (!oreEntry.getValue().isJsonObject()) { continue; } JsonObject oreJson = oreEntry.getValue().getAsJsonObject(); - if (!bool(oreJson, "enabled", true)) { - continue; - } + boolean oreEnabled = bool(oreJson, "enabled", true); boolean nativeGeneration = bool(oreJson, "native_generation", false); boolean retrogen = bool(oreJson, "retrogen", true); boolean suppressVanilla = nativeGeneration || bool(oreJson, "suppress_vanilla", false); - if (nativeGeneration && !manageVanillaOres) { - continue; - } ResourceLocation oreId = resource(string(oreJson, "block", oreEntry.getKey())); Block output = oreId == null ? null : ForgeRegistries.BLOCKS.getValue(oreId); JsonObject dimensions = objectOrEmpty(oreJson, "dimensions"); @@ -275,6 +294,12 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { } int deepOutputMaxY = integer(oreJson, "deep_output_max_y", -1); BakedOutput[] outputs = bakeOutputs(oreJson, output.getDefaultState()); + ResourceLocation ruleId = resource(oreEntry.getKey()); + if (ruleId == null) ruleId = oreId; + outputBundles.put(ruleId.toString(), new BakedOutputBundle( + output.getDefaultState(), deepOutput, deepOutputMaxY, outputs)); + if (!oreEnabled || (nativeGeneration && !manageVanillaOres)) continue; + ResourceLocation material = OreSourcePolicies.material(profile, oreJson, output); BakedOreRule rule = new BakedOreRule(output, manageVanillaOres && suppressVanilla); for (Entry dimensionEntry : dimensions.entrySet()) { @@ -293,9 +318,12 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (!bool(dimension, "enabled", true)) { continue; } - BakedOre baked = bakeOre(output.getDefaultState(), deepOutput, deepOutputMaxY, outputs, - dimension, config, resolvedTags, retrogen); + BakedOre baked = bakeOre(ruleId, material, dimensionId, + output.getDefaultState(), deepOutput, deepOutputMaxY, outputs, + dimension, config, resolvedTags, retrogen, + previous.get(ruleId + "|" + dimensionId)); if (baked != null) { + resolved.put(ruleId + "|" + dimensionId, baked); rule.explicit.put(dimensionId, baked); } else { reportBakeProblem("Ignoring invalid placement rule for OreSpawn-managed ore '{}' in '{}'", @@ -313,8 +341,11 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (!selectorEntry.getValue().isJsonObject()) continue; JsonObject selector = selectorEntry.getValue().getAsJsonObject(); if (!bool(selector, "enabled", true)) continue; - rule.selector = bakeOre(output.getDefaultState(), deepOutput, deepOutputMaxY, outputs, - selector, config, resolvedTags, retrogen); + rule.selector = bakeOre(ruleId, material, selectorId, + output.getDefaultState(), deepOutput, deepOutputMaxY, outputs, + selector, config, resolvedTags, retrogen, + previous.get(ruleId + "|" + selectorId)); + if (rule.selector != null) resolved.put(ruleId + "|" + selectorId, rule.selector); if (rule.selector == null) { reportBakeProblem("Ignoring invalid selector rule for OreSpawn-managed ore '{}'", oreEntry.getKey()); @@ -336,6 +367,7 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (rule.suppressVanilla) suppressed.add(rule.output); } } + combined = arbitrate(profile, combined, outputBundles); result.put(dimension, combined.toArray(new BakedOre[combined.size()])); vanillaOutputs.put(dimension, Collections.unmodifiableSet(suppressed)); } @@ -346,6 +378,7 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { selectorList.add(rule.selector); if (rule.suppressVanilla) selectorOutputs.add(rule.output); } + selectorList = arbitrate(profile, selectorList, outputBundles); BakedOre[] selectorResult = selectorList.toArray(new BakedOre[selectorList.size()]); LOGGER.info("Baked {} OreSpawn-managed ore definitions across {} dimensions", rules.size(), result.size()); @@ -358,12 +391,91 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { Collections.unmodifiableSet(selectorOutputs)); } + private static List arbitrate(JsonObject profile, List original, + Map outputBundles) { + if (!profile.has(OreSourcePolicies.SECTION) + || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return original; + JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION); + Map> groups = new LinkedHashMap<>(); + for (BakedOre ore : original) { + if (ore.material != null) groups.computeIfAbsent( + OreSourcePolicies.key(ore.material, ore.policyDomain), ignored -> new ArrayList<>()).add(ore); + } + Map> replacements = new HashMap<>(); + for (Entry> entry : groups.entrySet()) { + String key = entry.getKey(); + if (!policies.has(key) || !policies.get(key).isJsonObject()) continue; + JsonObject policy = policies.getAsJsonObject(key); + if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) continue; + List sources = new ArrayList<>(); + for (Entry output : positiveWeights(objectOrEmpty(policy, "outputs")).entrySet()) { + BakedOutputBundle bundle = outputBundles.get(output.getKey()); + if (bundle == null) bundle = candidateBundle(policy, output.getKey()); + if (bundle != null) sources.add(new BakedSource(bundle, output.getValue())); + } + Map available = new HashMap<>(); + for (BakedOre ore : entry.getValue()) available.put(ore.ruleId.toString(), ore); + List selected = new ArrayList<>(); + for (Entry placement : objectOrEmpty(policy, "placement_sources").entrySet()) { + ResourceLocation channel = resource(placement.getKey()); + String sourceId; + try { sourceId = placement.getValue().getAsString(); } + catch (RuntimeException invalid) { continue; } + BakedOre ore = available.get(sourceId); + if (ore != null && channel != null && channel.equals(ore.channel) && !sources.isEmpty()) { + selected.add(ore.withSources(sources)); + } + } + replacements.put(key, selected); + } + if (replacements.isEmpty()) return original; + List result = new ArrayList<>(); + Set emitted = new HashSet<>(); + for (BakedOre ore : original) { + String key = ore.material == null ? null + : OreSourcePolicies.key(ore.material, ore.policyDomain); + List selected = key == null ? null : replacements.get(key); + if (selected == null) result.add(ore); + else if (emitted.add(key)) result.addAll(selected); + } + return result; + } + + private static Map positiveWeights(JsonObject values) { + Map result = new LinkedHashMap<>(); + for (Entry entry : values.entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return result; + } + + private static BakedOutputBundle candidateBundle(JsonObject policy, String sourceId) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return null; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!sourceId.equals(string(candidate, "source_id", "")) + || !bool(candidate, "loaded", false) + || bool(candidate, "enrichment", false)) continue; + ResourceLocation id = resource(string(candidate, "registry_id", "")); + Block block = id == null ? null : ForgeRegistries.BLOCKS.getValue(id); + if (block == null || block == Blocks.AIR) return null; + BlockState state = block.getDefaultState(); + return new BakedOutputBundle(state, state, -1, + new BakedOutput[] { new BakedOutput(state, 1.0D, + Integer.MIN_VALUE, Integer.MAX_VALUE) }); + } + return null; + } + private static void reportBakeProblem(String message, Object... arguments) { - // Loading invokes provisional bakes before the server's registries and - // data-pack tags are authoritative. Keep diagnostics available at debug - // level then, but warn once the server-thread bake can make a real - // validity decision. - if (WorldGeologyProfileManager.activeServer() == null) { + // Registries and data-pack tags are incomplete during startup's provisional bakes. + // Log at debug level until the server-thread bake can tell whether a rule is valid. + if (WorldGeologyProfileManager.activeServer() == null + || WorldGeologyProfileManager.worldTagsPending()) { LOGGER.debug(message, arguments); } else { LOGGER.warn(message, arguments); @@ -378,10 +490,19 @@ private static JsonObject objectOrEmpty(JsonObject root, String key) { return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject(); } - private static BakedOre bakeOre(BlockState output, BlockState deepOutput, int deepOutputMaxY, + private static BakedOre bakeOre(ResourceLocation ruleId, ResourceLocation material, + ResourceLocation domain, BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, JsonObject json, BakedGeomeConfig config, Map> resolvedTags, - boolean retrogen) { + boolean retrogen, BakedOre previous) { + // Re-reading optional tags can expand a working host set and shift later vein positions. + // Only rules that depend entirely on tags need the post-load retry. + if (previous != null) { + Map explicitHosts = new IdentityHashMap<>(); + addBlocks(explicitHosts, json.get("host_blocks")); + if (previous.familyMask != 0 || !explicitHosts.isEmpty()) + return previous.withMaterial(material); + } int minY = integer(json, "min_y", 0); int maxY = integer(json, "max_y", 255); double frequency = decimal(json, "frequency", 0.0D); @@ -442,7 +563,8 @@ private static BakedOre bakeOre(BlockState output, BlockState deepOutput, int de Set includedDictionaryBiomes = resolveBiomeDictionary(json, "biome_dictionary"); Set excludedDictionaryBiomes = resolveBiomeDictionary(json, "excluded_biome_dictionary"); - return new BakedOre(output, deepOutput, deepOutputMaxY, outputs, + return new BakedOre(ruleId, material, domain, + OreSourcePolicies.placementChannel(json), output, deepOutput, deepOutputMaxY, outputs, minY, maxY, Math.min(64.0D, frequency), minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, spread, verticalSpread, nodeSize, @@ -614,6 +736,10 @@ private static BlockState blockState(JsonObject json, String key, BlockState fal } private static final class BakedOre { + final ResourceLocation ruleId; + final ResourceLocation material; + final ResourceLocation policyDomain; + final ResourceLocation channel; final BlockState output; final BlockState deepOutput; final int deepOutputMaxY; @@ -637,8 +763,12 @@ private static final class BakedOre { final Set includedDictionaryBiomes; final Set excludedDictionaryBiomes; final boolean retrogen; + final BakedSource[] arbitrationSources; + final long sourceSalt; - BakedOre(BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, + BakedOre(ResourceLocation ruleId, ResourceLocation material, + ResourceLocation policyDomain, ResourceLocation channel, + BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, int minY, int maxY, double frequency, int minQuantity, int maxQuantity, CompiledOrePattern pattern, OreHeightDistribution heightDistribution, double discardChanceOnAirExposure, @@ -647,6 +777,28 @@ private static final class BakedOre { Set includedBiomeIds, Set excludedBiomeIds, Set includedDictionaryBiomes, Set excludedDictionaryBiomes, boolean retrogen) { + this(ruleId, material, policyDomain, channel, output, deepOutput, deepOutputMaxY, outputs, + minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution, + discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, + familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds, + includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, new BakedSource[0]); + } + + private BakedOre(ResourceLocation ruleId, ResourceLocation material, + ResourceLocation policyDomain, ResourceLocation channel, + BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, + int minY, int maxY, double frequency, int minQuantity, int maxQuantity, + CompiledOrePattern pattern, OreHeightDistribution heightDistribution, + double discardChanceOnAirExposure, + int spread, int verticalSpread, int nodeSize, + Map hostBlocks, int familyMask, double[] geomeWeights, + Set includedBiomeIds, Set excludedBiomeIds, + Set includedDictionaryBiomes, Set excludedDictionaryBiomes, + boolean retrogen, BakedSource[] arbitrationSources) { + this.ruleId = ruleId; + this.material = material; + this.policyDomain = policyDomain; + this.channel = channel; this.output = output; this.deepOutput = deepOutput; this.deepOutputMaxY = deepOutputMaxY; @@ -670,6 +822,41 @@ private static final class BakedOre { this.includedDictionaryBiomes = includedDictionaryBiomes; this.excludedDictionaryBiomes = excludedDictionaryBiomes; this.retrogen = retrogen; + this.arbitrationSources = arbitrationSources; + this.sourceSalt = material == null ? 0L + : ((long) material.hashCode() << 32) ^ channel.hashCode(); + } + + BakedOre withSources(List sources) { + return new BakedOre(ruleId, material, policyDomain, channel, + output, deepOutput, deepOutputMaxY, outputs, minY, maxY, frequency, + minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, + spread, verticalSpread, nodeSize, hostBlocks, familyMask, geomeWeights, + includedBiomeIds, excludedBiomeIds, includedDictionaryBiomes, + excludedDictionaryBiomes, retrogen, + sources.toArray(new BakedSource[sources.size()])); + } + + BakedOre withMaterial(ResourceLocation nextMaterial) { + return new BakedOre(ruleId, nextMaterial, policyDomain, channel, + output, deepOutput, deepOutputMaxY, outputs, minY, maxY, frequency, + minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, + spread, verticalSpread, nodeSize, hostBlocks, familyMask, geomeWeights, + includedBiomeIds, excludedBiomeIds, includedDictionaryBiomes, + excludedDictionaryBiomes, retrogen); + } + + BakedOutputBundle outputSource(long worldSeed, long chunkIdentity, int attempt) { + if (arbitrationSources.length == 0) return null; + double total = 0.0D; + for (BakedSource source : arbitrationSources) total += source.weight; + long sample = mix(worldSeed ^ sourceSalt, chunkIdentity, attempt); + double choice = (sample >>> 11) * 0x1.0p-53 * total; + for (BakedSource source : arbitrationSources) { + choice -= source.weight; + if (choice < 0.0D) return source.bundle; + } + return arbitrationSources[arbitrationSources.length - 1].bundle; } BlockState outputAt(int y, Random random) { @@ -718,6 +905,45 @@ private static final class BakedOreRule { } } + private static final class BakedSource { + final BakedOutputBundle bundle; + final double weight; + + BakedSource(BakedOutputBundle bundle, double weight) { + this.bundle = bundle; + this.weight = weight; + } + } + + private static final class BakedOutputBundle { + final BlockState output; + final BlockState deepOutput; + final int deepOutputMaxY; + final BakedOutput[] outputs; + + BakedOutputBundle(BlockState output, BlockState deepOutput, int deepOutputMaxY, + BakedOutput[] outputs) { + this.output = output; + this.deepOutput = deepOutput; + this.deepOutputMaxY = deepOutputMaxY; + this.outputs = outputs; + } + + BlockState outputAt(int y, Random random) { + if (y <= deepOutputMaxY) return deepOutput; + double total = 0.0D; + for (BakedOutput candidate : outputs) if (candidate.acceptsY(y)) total += candidate.weight; + if (total <= 0.0D) return output; + double choice = random.nextDouble() * total; + for (BakedOutput candidate : outputs) { + if (!candidate.acceptsY(y)) continue; + choice -= candidate.weight; + if (choice <= 0.0D) return candidate.state; + } + return output; + } + } + private static final class BakedOutput { final BlockState state; final double weight; @@ -768,13 +994,24 @@ private static final class GenerationScratch { } } - private static final class PatternContext implements OrePlacementContext { + private static final class PatternContext implements OreGenerationContext { private final BlockPos.MutableBlockPos cursor; private final BlockPos.MutableBlockPos airCursor = new BlockPos.MutableBlockPos(); private IWorld world; private IChunk chunk; private Random random; private BakedOre ore; + private BakedOutputBundle selectedOutput; + private BakedOutputBundle identifiedOutput; + private long identifiedOutputIdentity; + private boolean hasIdentifiedOutput; + private ResourceLocation dimension; + private long worldSeed; + private int chunkX; + private int chunkZ; + private net.minecraft.server.MinecraftServer samplerServer; + private ResourceLocation samplerDimension; + private Optional sampler = Optional.empty(); private int geome; private int originX; private int originY; @@ -788,11 +1025,20 @@ private static final class PatternContext implements OrePlacementContext { } void initialize(IWorld world, IChunk chunk, Random random, BakedOre ore, int geome, + ResourceLocation dimension, long worldSeed, + BakedOutputBundle selectedOutput, int originX, int originY, int originZ, int minY, int maxY, int quantity) { this.world = world; this.chunk = chunk; this.random = random; this.ore = ore; + this.selectedOutput = selectedOutput; + this.identifiedOutput = null; + this.hasIdentifiedOutput = false; + this.dimension = dimension; + this.worldSeed = worldSeed; + this.chunkX = chunk.getPos().x; + this.chunkZ = chunk.getPos().z; this.geome = geome; this.originX = originX; this.originY = originY; @@ -812,6 +1058,27 @@ void initialize(IWorld world, IChunk chunk, Random random, BakedOre ore, int geo @Override public int spread() { return ore.spread; } @Override public int verticalSpread() { return ore.verticalSpread; } @Override public int nodeSize() { return ore.nodeSize; } + @Override public long worldSeed() { return worldSeed; } + @Override public ResourceLocation dimension() { return dimension; } + @Override public int chunkX() { return chunkX; } + @Override public int chunkZ() { return chunkZ; } + @Override public Optional geologySampler() { + net.minecraft.server.MinecraftServer server = WorldGeologyProfileManager.activeServer(); + if (server != samplerServer || !dimension.equals(samplerDimension)) { + samplerServer = server; + samplerDimension = dimension; + sampler = Optional.empty(); + if (server != null) { + for (ServerWorld level : server.forgeGetWorldMap().values()) { + if (dimension.equals(WorldIds.dimension(level))) { + sampler = OreSpawnApi.createSampler(level); + break; + } + } + } + } + return sampler; + } @Override public boolean inside(int x, int y, int z) { @@ -834,6 +1101,35 @@ public boolean isFluid(int x, int y, int z, Fluid fluid) { @Override public boolean tryPlace(int x, int y, int z) { + // The ordinary pattern path runs for every candidate block in a vein. + if (!inside(x, y, z)) return false; + cursor.setPos(x, y, z); + BlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor); + if (!ore.accepts(existing, random, geomeConfig)) return false; + if (ore.discardChanceOnAirExposure > 0.0D + && random.nextDouble() < ore.discardChanceOnAirExposure + && isAdjacentToAir(x, y, z)) { + return false; + } + BlockState output = selectedOutput == null + ? ore.outputAt(y, random) : selectedOutput.outputAt(y, random); + if (world == null) chunk.setBlockState(cursor, output, false); + else world.setBlockState(cursor, output, 2); + return true; + } + + @Override + public boolean tryPlace(int x, int y, int z, long outputIdentity) { + if (!hasIdentifiedOutput || identifiedOutputIdentity != outputIdentity) { + identifiedOutputIdentity = outputIdentity; + identifiedOutput = ore.outputSource(worldSeed, outputIdentity, 0); + hasIdentifiedOutput = true; + } + return tryPlaceWithOutput(x, y, z, identifiedOutput); + } + + private boolean tryPlaceWithOutput(int x, int y, int z, + BakedOutputBundle outputSource) { if (!inside(x, y, z)) return false; cursor.setPos(x, y, z); BlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor); @@ -843,7 +1139,8 @@ public boolean tryPlace(int x, int y, int z) { && isAdjacentToAir(x, y, z)) { return false; } - BlockState output = ore.outputAt(y, random); + BlockState output = outputSource == null + ? ore.outputAt(y, random) : outputSource.outputAt(y, random); if (world == null) chunk.setBlockState(cursor, output, false); else world.setBlockState(cursor, output, 2); return true; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java index 691cd39f..b9b75180 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java @@ -26,8 +26,7 @@ public static void registerFeatures(IForgeRegistry> registry) { } static void register() { - // Configured features are inline objects in 1.14 and are wrapped when the - // registered biome stage lists are installed. + // Minecraft 1.14 stores configured features inline. Wrap them when installing the biome lists. } static boolean wrapFeatureList(List> features) { @@ -51,6 +50,7 @@ private static final class GateFeature extends ContextFeature { @Override boolean place(FeaturePlaceContext context) { + WorldGeologyProfileManager.resolveFreshPoliciesBeforeGeneration(); if (WorldGeologyProfileManager.activeProfile().suppressAllOreFeatures()) return false; if (OreSpawnOreGeneration.takesOverVanillaOre( WorldIds.dimension(context.level()), context.config().output)) return false; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java index 16199359..c7d01afb 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaSpringCompatibility.java @@ -22,7 +22,7 @@ import net.minecraft.world.gen.feature.SingleRandomFeature; import net.minecraft.world.gen.feature.TwoFeatureChoiceConfig; -/** Rewrites only vanilla spring leaves to accept baked provider rocks. */ +/** Lets vanilla springs generate inside provider rocks without changing other features. */ public final class VanillaSpringCompatibility extends ContextFeature { public static final VanillaSpringCompatibility FEATURE = new VanillaSpringCompatibility(); private static volatile Set providerRocks = Collections.emptySet(); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index c326da3e..370c3252 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -22,7 +22,7 @@ /** A complete, self-contained snapshot of the geology settings for one world. */ public final class WorldGeologyProfile { - public static final int SCHEMA_VERSION = 5; + public static final int SCHEMA_VERSION = 7; private static final Logger LOGGER = LogManager.getLogger(); @@ -39,6 +39,11 @@ public final class WorldGeologyProfile { private WorldGeologyProfile(JsonObject root, GeologyMode fallbackMode, boolean fallbackFluidDeposits) { this.root = JsonCopies.copy(root); FluidDepositMigration.normalize(this.root); + OreMaterialGroups.initialize(this.root); + if (!this.root.has(OreSourcePolicies.SECTION) + || !this.root.get(OreSourcePolicies.SECTION).isJsonObject()) { + this.root.add(OreSourcePolicies.SECTION, new JsonObject()); + } this.root.addProperty("schema_version", SCHEMA_VERSION); geologyMode = enumValue(this.root, "geology_mode", GeologyMode.class, fallbackMode); placeFluidDeposits = booleanValue(this.root, "place_fluid_deposits", fallbackFluidDeposits); @@ -67,6 +72,7 @@ public static WorldGeologyProfile recommended(boolean placeFluidDeposits) { public static WorldGeologyProfile fromGlobalConfig(JsonObject globalRoot, GeologyMode geologyMode, boolean placeFluidDeposits) { JsonObject root = JsonCopies.copy(globalRoot); + OreSourcePolicies.initialize(root, false); if (!root.has("geology_mode")) { root.addProperty("geology_mode", geologyMode.name().toLowerCase(Locale.ROOT)); } @@ -81,23 +87,27 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile if (schema >= SCHEMA_VERSION) { return new WorldGeologyProfile(json, fallback.geologyMode, fallback.placeFluidDeposits); } - if (schema == 2 || schema == 3 || schema == 4) { + if (schema >= 2) { JsonObject migrated = JsonCopies.copy(json); for (String key : new String[] { "terrain_dimensions", "providers", - "biome_palettes", "dimension_materials" }) { + "biome_palettes", "dimension_materials", OreMaterialGroups.SECTION }) { if (!migrated.has(key) && fallback.root.has(key)) { migrated.add(key, JsonCopies.copy(fallback.root.get(key))); } } + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } - // Schema 1 contained only mode, oil and formations. Overlay those fields on - // the currently effective pack profile so the resulting profile is complete. + // Schema 1 stored only mode, oil and formations. + // Fill the missing fields from the active pack profile. JsonObject migrated = fallback.rootCopy(); copyIfPresent(json, migrated, "geology_mode"); copyIfPresent(json, migrated, "place_crude_oil"); copyIfPresent(json, migrated, "formations"); + // An existing world must not inherit a new install's automatic choices. + migrated.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java index 47937909..405c7374 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -9,6 +9,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.Map; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -23,6 +25,7 @@ import net.minecraft.server.MinecraftServer; import net.minecraftforge.fml.event.server.FMLServerAboutToStartEvent; +import net.minecraftforge.fml.event.server.FMLServerStartingEvent; import net.minecraftforge.fml.event.server.FMLServerStoppedEvent; import net.minecraftforge.fml.loading.FMLPaths; import net.minecraftforge.event.world.WorldEvent; @@ -40,6 +43,9 @@ public final class WorldGeologyProfileManager { private static volatile Object pendingNewWorldSession; private static volatile WorldGeologyProfile activeProfile; private static volatile MinecraftServer activeServer; + private static final Map deferredFreshPolicies = new LinkedHashMap<>(); + private static volatile boolean freshPolicyDiscoveryPending; + private static volatile boolean tagRulesPending; private WorldGeologyProfileManager() { throw new IllegalAccessError("Not an instantiable class"); @@ -105,7 +111,9 @@ public static synchronized boolean reloadActiveProfile() { .resolve("serverconfig").resolve(PROFILE_FILE_NAME); WorldGeologyProfile profile = readProfile(profilePath, globalProfile()); JsonObject merged = profile.rootCopy(); - if (OreSpawnOreIntegration.mergeProviderOres(merged)) { + boolean changed = OreSpawnOreIntegration.mergeProviderOres(merged); + changed |= OreSourcePolicies.initialize(merged, true); + if (changed) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); } @@ -116,6 +124,8 @@ public static synchronized boolean reloadActiveProfile() { public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { activeServer = event.getServer(); + deferredFreshPolicies.clear(); + freshPolicyDiscoveryPending = false; Path worldRoot = worldRoot(event.getServer()); Path profilePath = worldRoot.resolve("serverconfig").resolve(PROFILE_FILE_NAME); WorldGeologyProfile fallback = globalProfile(); @@ -128,6 +138,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { JsonObject merged = profile.rootCopy(); String beforeMerge = merged.toString(); OreSpawnOreIntegration.mergeProviderOres(merged); + OreSourcePolicies.initialize(merged, true); if (!beforeMerge.equals(merged.toString())) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); @@ -154,12 +165,18 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { profile = fallback.copy(); source = "installed-pack fresh-world"; } + profile = initializeNewWorldPolicies(profile, generatedWorld); + if (!generatedWorld) { + rememberUnreviewedDefaults(profile, fallback); + freshPolicyDiscoveryPending = true; + } writeProfile(profilePath, profile); LOGGER.info("Created OreSpawn world geology profile '{}' from {} settings", profilePath, source); } - activateProfile(profile); + // Keep existing block/family rules stable, then retry tag-only hosts on WorldEvent.Load. + activateProfile(profile, true); BiomeWorldgenManager.apply(event.getServer(), profile); LOGGER.info("Activated OreSpawn world geology profile: mode={}, formations={}, horizontal={}, thickness={}, waviness={}, edge={}, continuity={}, fluidDeposits={}/{}", profile.geologyMode(), profile.algorithm().configName(), profile.horizontalSize().configName(), @@ -168,7 +185,91 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) { profile.enabledFluidDepositCount(), profile.fluidDepositCount()); } + private static void rememberUnreviewedDefaults(WorldGeologyProfile profile, + WorldGeologyProfile defaults) { + JsonObject current = profile.rootCopy().getAsJsonObject(OreSourcePolicies.SECTION); + JsonObject baseline = defaults.rootCopy().getAsJsonObject(OreSourcePolicies.SECTION); + if (current == null || baseline == null) return; + for (Map.Entry entry : current.entrySet()) { + if (!entry.getValue().isJsonObject() || !baseline.has(entry.getKey()) + || !baseline.get(entry.getKey()).isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + if (!"keep_separate".equals(policy.has("mode") ? policy.get("mode").getAsString() : "") + || !policy.has("review_required") || !policy.get("review_required").getAsBoolean() + || !policy.equals(baseline.get(entry.getKey()))) continue; + deferredFreshPolicies.put(entry.getKey(), policy.toString()); + } + } + + /** Forge 28 reads block tags after AboutToStart but before it decorates spawn chunks. */ + public static void onServerStarting(FMLServerStartingEvent event) { + if (event.getServer() == activeServer) { + resolveFreshPoliciesBeforeGeneration(); + resolveTagRulesBeforeGeneration(); + } + } + + public static void resolveFreshPoliciesBeforeGeneration() { + if (freshPolicyDiscoveryPending) resolveFreshPoliciesOnce(); + } + + private static synchronized void resolveFreshPoliciesOnce() { + if (!freshPolicyDiscoveryPending || activeServer == null) return; + JsonObject refreshed = activeProfile().rootCopy(); + JsonObject policies = refreshed.getAsJsonObject(OreSourcePolicies.SECTION); + boolean hadConsolidatedPolicies = hasConsolidatedPolicies(policies); + if (policies != null) { + for (Map.Entry entry : deferredFreshPolicies.entrySet()) { + if (policies.has(entry.getKey()) + && entry.getValue().equals(policies.get(entry.getKey()).toString())) { + policies.remove(entry.getKey()); + } + } + } + deferredFreshPolicies.clear(); + OreSourcePolicies.initialize(refreshed, false); + boolean hasConsolidatedPolicies = hasConsolidatedPolicies( + refreshed.getAsJsonObject(OreSourcePolicies.SECTION)); + WorldGeologyProfile next = activeProfile().withRoot(refreshed); + if (!next.rootCopy().equals(activeProfile().rootCopy())) { + Path path = worldRoot(activeServer).resolve("serverconfig").resolve(PROFILE_FILE_NAME); + if (writeProfile(path, next)) { + activeProfile = next; + // Keep Original changes only editor metadata. + // Rebuilding ore rules here would change the first generated chunks. + if (hadConsolidatedPolicies || hasConsolidatedPolicies) + OreSpawnOreGeneration.refreshWorldConfig(); + LOGGER.info("Resolved fresh-world ore-source defaults before managed ore placement"); + } + } + freshPolicyDiscoveryPending = false; + } + + private static boolean hasConsolidatedPolicies(JsonObject policies) { + if (policies == null) return false; + for (Map.Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + if (policy.has("mode") && policy.get("mode").isJsonPrimitive() + && "consolidated".equals(policy.get("mode").getAsString())) + return true; + } + return false; + } + + static WorldGeologyProfile initializeNewWorldPolicies(WorldGeologyProfile profile, + boolean generatedWorld) { + JsonObject initialized = profile.rootCopy(); + // A world with generated chunks keeps its original ore budgets on upgrade. + if (generatedWorld) initialized.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(initialized, generatedWorld); + return profile.withRoot(initialized); + } + public static void onServerStopped(FMLServerStoppedEvent event) { + deferredFreshPolicies.clear(); + freshPolicyDiscoveryPending = false; + tagRulesPending = false; activeServer = null; activeProfile = null; GeomeConfig.applyWorldProfile(globalProfile()); @@ -184,16 +285,35 @@ public static void onWorldLoad(WorldEvent.Load event) { if (!(event.getWorld() instanceof ServerWorld)) return; WorldGeologyProfile profile = activeProfile; if (profile != null) { - BiomeWorldgenManager.apply((ServerWorld) event.getWorld(), profile); + resolveFreshPoliciesBeforeGeneration(); + resolveTagRulesBeforeGeneration(); + BiomeWorldgenManager.apply((ServerWorld) event.getWorld(), activeProfile()); } } + private static synchronized void resolveTagRulesBeforeGeneration() { + if (!tagRulesPending) return; + // World tags are now available, before biome feature lists or spawn chunks are prepared. + tagRulesPending = false; + OreSpawnOreGeneration.refreshTagOnlyWorldConfig(); + FluidDepositFeature.refreshTagOnlyWorldConfig(); + } + + static boolean worldTagsPending() { + return tagRulesPending; + } + static void applyBenchmarkProfile(WorldGeologyProfile profile) { - activateProfile(profile); + activateProfile(profile, tagRulesPending); } private static void activateProfile(WorldGeologyProfile profile) { + activateProfile(profile, false); + } + + private static void activateProfile(WorldGeologyProfile profile, boolean deferTags) { activeProfile = profile; + tagRulesPending = deferTags; GeomeConfig.applyWorldProfile(profile); StoneReplacer.refreshWorldConfig(); OreSpawnOreGeneration.refreshWorldConfig(); @@ -261,8 +381,10 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback) if (oreDefaultsRefreshed) { root = GeomeConfig.refreshWorldOreDefaults(root); } + boolean sourcePoliciesRefreshed = OreSourcePolicies.initialize(root, true); WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, fallback); - if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed) { + if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed + || sourcePoliciesRefreshed) { boolean canWrite = schema >= WorldGeologyProfile.SCHEMA_VERSION || preserveBeforeSchemaMigration(path, schema); canWrite &= !oreDefaultsRefreshed || preserveBeforeOreDefaultsRefresh(path); @@ -275,6 +397,9 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback) LOGGER.info("Updated untouched managed-ore rules in world geology profile '{}' to revision {}", path, GeomeConfig.oreDefaultsRevision()); } + if (sourcePoliciesRefreshed) { + LOGGER.info("Refreshed OreSpawn ore-source policies in world geology profile '{}'", path); + } } else if (oreDefaultsRefreshed) { LOGGER.warn("Could not persist updated OreSpawn ore defaults for world profile '{}'; " + "using refreshed settings in memory", path); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldIds.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldIds.java index f3947ea1..b9a726a2 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldIds.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldIds.java @@ -7,7 +7,7 @@ import net.minecraft.world.server.ServerWorld; import net.minecraftforge.registries.ForgeRegistries; -/** Static-registry identity bridge for the pre-RegistryKey 1.14 runtime. */ +/** Looks up dimension and biome IDs in Minecraft 1.14's registries. */ public final class WorldIds { static final ResourceLocation OVERWORLD = id("overworld"); static final ResourceLocation NETHER = id("the_nether"); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java index 4ccdc557..3316a36a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldMaterialWeather.java @@ -14,8 +14,8 @@ import net.minecraftforge.event.world.ChunkEvent; /** - * Converts vanilla weather products in loaded columns to configured materials. - * Aquifer fluids are handled directly by the chunk generator. + * Replaces vanilla snow and ice with the configured materials in loaded columns. + * The chunk generator handles aquifer fluids separately. */ public final class WorldMaterialWeather { private WorldMaterialWeather() { @@ -54,8 +54,8 @@ private static void convertChunk(IChunk chunk, DimensionMaterials materials) { for (int localX = 0; localX < 16; localX++) { for (int localZ = 0; localZ < 16; localZ++) { int top = chunk.getTopBlockY(Heightmap.Type.MOTION_BLOCKING, localX, localZ); - // A one-layer Snow block is non-motion-blocking and therefore occupies - // the first free cell immediately above this heightmap's surface. + // A single snow layer does not raise this heightmap. + // Look in the first free block above the surface. if (materials.snow != null && top + 1 < 256) { cursor.setPos(minX + localX, top + 1, minZ + localZ); if (chunk.getBlockState(cursor).getBlock() == Blocks.SNOW) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java index 0055ef26..5e347d4a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldgenBenchmark.java @@ -27,7 +27,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -/** Opt-in integrated benchmark used to keep worldgen overhead measurable. */ +/** Measures chunk generation when a benchmark is explicitly requested. */ public final class WorldgenBenchmark { private static final Logger LOGGER = LogManager.getLogger(); private static final String PROPERTY = "orespawn.worldgenBenchmarkMode"; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/math/CubicInterpolator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/math/CubicInterpolator.java index 16e627b1..c0b8e5f8 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/math/CubicInterpolator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/math/CubicInterpolator.java @@ -1,61 +1,47 @@ package zone.moddev.mc.orespawn.worldgen.math; /** - * This is a convenience class that provides multidimensional cubic - * interpolation methods. This class is abstract and not meant to be extended + * Cubic interpolation helpers for one-, two- and three-dimensional grids. * * @author Cyanobacterium */ public abstract class CubicInterpolator { /** - * Interpolate with cubic approximation for a point X on a grid. X - * must lie between the X values of the yn1 and yp1 control points. + * Interpolates between the two middle samples of a four-point cubic curve. * - * @param x - * x coordinate to interpolate - * @param yn2 - * Y value at f(floor(x)-1) - * @param yn1 - * Y value at f(floor(x)-0) - * @param yp1 - * Y value at f(floor(x)+1) - * @param yp2 - * Y value at f(floor(x)+2) - * @return A cubic-interpolated value from the given control points. + * @param x x coordinate to interpolate + * @param yn2 sample at f(floor(x) - 1) + * @param yn1 sample at f(floor(x)) + * @param yp1 sample at f(floor(x) + 1) + * @param yp2 sample at f(floor(x) + 2) + * @return the interpolated value */ public static double interpolate(double x, double yn2, double yn1, double yp1, double yp2) { return interpolate1d(x, yn2, yn1, yp1, yp2); } /** - * Interpolate with cubic approximation for a point X on a grid. X - * must lie between the X values of the yn1 and yp1 control points. + * Interpolates between the two middle samples of a four-point cubic curve. * - * @param x - * x coordinate to interpolate - * @param yn2 - * Y value at f(floor(x)-1) - * @param yn1 - * Y value at f(floor(x)-0) - * @param yp1 - * Y value at f(floor(x)+1) - * @param yp2 - * Y value at f(floor(x)+2) - * @return A cubic-interpolated value from the given control points. + * @param x x coordinate to interpolate + * @param yn2 sample at f(floor(x) - 1) + * @param yn1 sample at f(floor(x)) + * @param yp1 sample at f(floor(x) + 1) + * @param yp2 sample at f(floor(x) + 2) + * @return the interpolated value */ public static double interpolate1d(double x, double yn2, double yn1, double yp1, double yp2) { double w = x - Math.floor(x); if (w == 0 && x != 0) - return yp1; // w should be 1, but the way this is calcluated it doesn't work right - // prevent precision-loss artifacts + return yp1; // Snap near the endpoints to avoid floating-point artifacts. if (w < 0.000976563) { return yn1; } if (w > 0.999023438) { return yp1; } - // adapted from http://www.paulinternet.nl/?page=bicubic + // Cubic coefficients adapted from http://www.paulinternet.nl/?page=bicubic double A = -0.5 * yn2 + 1.5 * yn1 - 1.5 * yp1 + 0.5 * yp2; double B = yn2 - 2.5 * yn1 + 2 * yp1 - 0.5 * yp2; double C = -0.5 * yn2 + 0.5 * yp1; @@ -64,17 +50,12 @@ public static double interpolate1d(double x, double yn2, double yn1, double yp1, } /** - * Returns the bi-cubic interpolation of the (x,y) coordinate inide - * the provided grid of control points. (x,y) is assumed to be in the - * center square of the unit grid. + * Interpolates within the center square of a 4-by-4 grid of samples. * - * @param x - * x coordinate between local16[1][y] and local16[2][y] - * @param y - * y coordinate between local16[x][1] and local16[x][2] - * @param local16 - * Array [x][y] of the 4x4 grid around the coordinate - * @return Returns the bi-cubic interpolation of the (x,y) coordinate. + * @param x x coordinate between the two middle columns + * @param y y coordinate between the two middle rows + * @param local16 surrounding samples, indexed by x and y + * @return the bicubic interpolated value at (x, y) */ public static double interpolate2d(double x, double y, double[][] local16) { double[] section = new double[4]; @@ -85,18 +66,13 @@ public static double interpolate2d(double x, double y, double[][] local16) { } /** - * Performs a tri-cubic interpolation of the (x,y,z) coordinate near - * the center of the provided unit grid of surrounding control points. + * Interpolates within the center cube of a 4-by-4-by-4 grid of samples. * - * @param x - * x coordinate in the middle of the array space - * @param y - * y coordinate in the middle of the array space - * @param z - * z coordinate in the middle of the array space - * @param local64 - * Array [x][y][z] of the 4x4x4 grid around the coordinate - * @return Returns the tri-cubic interpolation of the given coordinate. + * @param x x coordinate between the two middle sample planes + * @param y y coordinate between the two middle sample planes + * @param z z coordinate between the two middle sample planes + * @param local64 surrounding samples, indexed by x, y and z + * @return the tricubic interpolated value at (x, y, z) */ public static double interpolate3d(double x, double y, double z, double[][][] local64) { double[] section = new double[4]; @@ -107,53 +83,40 @@ public static double interpolate3d(double x, double y, double z, double[][][] lo } /** - * Interpolate with cubic approximation for a point X on a grid. X - * must lie between the X values of the yn1 and yp1 control points. + * Interpolates between the two middle samples of a four-point cubic curve. * - * @param x - * x coordinate to interpolate - * @param yn2 - * Y value at f(floor(x)-1) - * @param yn1 - * Y value at f(floor(x)-0) - * @param yp1 - * Y value at f(floor(x)+1) - * @param yp2 - * Y value at f(floor(x)+2) - * @return A cubic-interpolated value from the given control points. + * @param x x coordinate to interpolate + * @param yn2 sample at f(floor(x) - 1) + * @param yn1 sample at f(floor(x)) + * @param yp1 sample at f(floor(x) + 1) + * @param yp2 sample at f(floor(x) + 2) + * @return the interpolated value */ public static float interpolate(double x, float yn2, float yn1, float yp1, float yp2) { return interpolate1d(x, yn2, yn1, yp1, yp2); } /** - * Interpolate with cubic approximation for a point X on a grid. X - * must lie between the X values of the yn1 and yp1 control points. + * Interpolates between the two middle samples of a four-point cubic curve. * - * @param x - * x coordinate to interpolate - * @param yn2 - * Y value at f(floor(x)-1) - * @param yn1 - * Y value at f(floor(x)-0) - * @param yp1 - * Y value at f(floor(x)+1) - * @param yp2 - * Y value at f(floor(x)+2) - * @return A cubic-interpolated value from the given control points. + * @param x x coordinate to interpolate + * @param yn2 sample at f(floor(x) - 1) + * @param yn1 sample at f(floor(x)) + * @param yp1 sample at f(floor(x) + 1) + * @param yp2 sample at f(floor(x) + 2) + * @return the interpolated value */ public static float interpolate1d(double x, float yn2, float yn1, float yp1, float yp2) { float w = (float) (x - floor(x)); if (w == 0 && x != 0) - return yn1; // w should be 1, but the way this is calcluated it doesn't work right - // prevent precision-loss artifacts + return yn1; // Snap near the endpoints to avoid floating-point artifacts. if (w < 0.0001) { return yn1; } if (w > 0.999) { return yp1; } - // adapted from http://www.paulinternet.nl/?page=bicubic + // Cubic coefficients adapted from http://www.paulinternet.nl/?page=bicubic float A = -0.5f * yn2 + 1.5f * yn1 - 1.5f * yp1 + 0.5f * yp2; float B = yn2 - 2.5f * yn1 + 2f * yp1 - 0.5f * yp2; float C = -0.5f * yn2 + 0.5f * yp1; @@ -162,17 +125,12 @@ public static float interpolate1d(double x, float yn2, float yn1, float yp1, flo } /** - * Returns the bi-cubic interpolation of the (x,y) coordinate inside - * the provided grid of control points. (x,y) is assumed to be in the - * center square of the unit grid. + * Interpolates within the center square of a 4-by-4 grid of samples. * - * @param x - * x coordinate between local16[1][y] and local16[2][y] - * @param y - * y coordinate between local16[x][1] and local16[x][2] - * @param local16 - * Array [x][y] of the 4x4 grid around the coordinate - * @return Returns the bi-cubic interpolation of the (x,y) coordinate. + * @param x x coordinate between the two middle columns + * @param y y coordinate between the two middle rows + * @param local16 surrounding samples, indexed by x and y + * @return the bicubic interpolated value at (x, y) */ public static float interpolate2d(double x, double y, float[][] local16) { float[] section = new float[4]; @@ -183,18 +141,13 @@ public static float interpolate2d(double x, double y, float[][] local16) { } /** - * Performs a tri-cubic interpolation of the (x,y,z) coordinate near - * the center of the provided unit grid of surrounding control points. + * Interpolates within the center cube of a 4-by-4-by-4 grid of samples. * - * @param x - * x coordinate in the middle of the array space - * @param y - * y coordinate in the middle of the array space - * @param z - * z coordinate in the middle of the array space - * @param local64 - * Array [x][y][z] of the 4x4x4 grid around the coordinate - * @return Returns the tri-cubic interpolation of the given coordinate. + * @param x x coordinate between the two middle sample planes + * @param y y coordinate between the two middle sample planes + * @param z z coordinate between the two middle sample planes + * @param local64 surrounding samples, indexed by x, y and z + * @return the tricubic interpolated value at (x, y, z) */ public static float interpolate3d(double x, double y, double z, float[][][] local64) { float[] section = new float[4]; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/math/NoiseLayer2D.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/math/NoiseLayer2D.java index 45927a1d..ecff7534 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/math/NoiseLayer2D.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/math/NoiseLayer2D.java @@ -1,20 +1,17 @@ package zone.moddev.mc.orespawn.worldgen.math; /** - * Perlin Noise Octave + * One octave of interpolated Perlin noise. * * @author Cyanobacterium - * */ public class NoiseLayer2D { private final long seed; - /** from java.util.Random implementation */ + // Use the same linear congruential constants as java.util.Random. private static final long RAND_MULTIPLIER = 0x5DEECE66DL; - /** from java.util.Random implementation */ private static final long RAND_ADDEND = 0xBL; - /** from java.util.Random implementation */ private static final long RAND_MASK = (1L << 48) - 1; private final float multiplier; @@ -22,8 +19,8 @@ public class NoiseLayer2D { private final float magnitude; private static final int PRECISION_MASK = 0x0FFFFF; - private static final float INT_CONVERSION_MULTIPLIER = 2.0f / (float) PRECISION_MASK; // 2.0 because we will be subtracting 1 - // to make it range from -1 to 1 + // Scale to [0, 2], then subtract one when sampling to get [-1, 1]. + private static final float INT_CONVERSION_MULTIPLIER = 2.0f / (float) PRECISION_MASK; public NoiseLayer2D(long seed, float size, float magnitude) { this.seed = seed; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/math/PerlinNoise2D.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/math/PerlinNoise2D.java index 4821dc20..0a5fdcb0 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/math/PerlinNoise2D.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/math/PerlinNoise2D.java @@ -4,11 +4,9 @@ public class PerlinNoise2D { private final NoiseLayer2D[] layers; - /** from java.util.Random implementation */ + // Use the same linear congruential constants as java.util.Random. private static final long RAND_MULTIPLIER = 0x5DEECE66DL; - /** from java.util.Random implementation */ private static final long RAND_ADDEND = 0xBL; - /** from java.util.Random implementation */ private static final long RAND_MASK = (1L << 48) - 1; private static final int BROAD_STRATUM_LAYERS = 2; diff --git a/src/main/resources/assets/orespawn/lang/de_au.json b/src/main/resources/assets/orespawn/lang/de_au.json index f9209f27..1cfed7ef 100644 --- a/src/main/resources/assets/orespawn/lang/de_au.json +++ b/src/main/resources/assets/orespawn/lang/de_au.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Ersetzen Sie den freiliegenden Unterwasseroberflächenblock des Bioms. Nicht gesetzt behält den normalen Block bei.", "tooltip.orespawn.biome.ceiling_block": "Ersetzen Sie den Deckenflächenblock des Bioms in Abmessungen, die Decken erzeugen. Nicht festgelegt behält den normalen Block bei.", "tooltip.orespawn.biome.filler_depth": "Anzahl der Ebenen unter dem oberen Block, die den Füllblock verwenden. Gültiger Bereich: 0 bis 16.", - "tooltip.orespawn.material.default_fluid": "Wählen Sie die normale Grundwasserleiterflüssigkeit, die unterhalb des Meeresspiegels verwendet wird. Nicht festgelegt behält die ursprüngliche Flüssigkeit von Minecraft bei.", + "tooltip.orespawn.material.default_fluid": "Ersetzt die normale Grundwasserleiterflüssigkeit unter dem Meeresspiegel in jedem Biom dieser Dimension. Nicht festgelegt behält die ursprüngliche Generatorflüssigkeit bei. Bestehende Chunks bleiben unverändert; Flüssigkeiten mit ungewöhnlicher Beleuchtung können langsamer generieren.", "tooltip.orespawn.material.deep_aquifer_fluid": "In Minecraft 1.14.4 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten.", "tooltip.orespawn.material.deep_aquifer_y": "In Minecraft 1.14.4 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten.", "tooltip.orespawn.material.snow_block": "Ersetzen Sie Vanilleschnee, der in dieser Dimension in der Nähe der Oberfläche platziert wird. Nicht gesetzt hält normalen Schnee.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Installiertes Biom auswählen", "screen.orespawn.biome_placement": "Biomeinstellungen", "screen.orespawn.biome_references": "Ähnliche Biome", - "screen.orespawn.dimension_materials": "Weltmaterialien", + "screen.orespawn.dimension_materials": "%s-Materialien", "button.orespawn.biomes_world_materials": "Biome und Weltmaterialien...", "button.orespawn.biome_palette_count": "Biome: %s", "button.orespawn.dimension_materials": "Weltmaterialien...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Weltmaterialien", "guide.orespawn.materials.1": "Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.", "guide.orespawn.materials.2": "Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.", - "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert." + "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.", + "button.orespawn.dimension_materials_named": "%s-Materialien...", + "label.orespawn.dimension_materials.scope": "Gilt für jedes Biom im neu erzeugten Terrain von %s.", + "button.orespawn.mods": "Mod-Verzeichnis", + "button.orespawn.mod.configure": "Konfigurieren", + "tooltip.orespawn.main.mods": "Geladene Mods anzeigen, die in OreSpawn integriert sind.", + "screen.orespawn.mods": "OreSpawn-Mods", + "tooltip.orespawn.mod.configure": "Konfiguration von %s öffnen.", + "tooltip.orespawn.mod.unavailable": "%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.", + "label.orespawn.mod.id_version": "%s - Mod-Version %s", + "label.orespawn.mod.additional_lineage": "Auch über OreSpawn-%s-Kompatibilität erkannt.", + "label.orespawn.mod.status": "Zustand: %s", + "label.orespawn.mod.os4_version": "Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s", + "label.orespawn.mod.os4": "Nativer OS4-Anbieter", + "label.orespawn.mod.legacy_version": "OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s", + "label.orespawn.mod.legacy": "OreSpawn-%s-Kompatibilität -> OS4", + "label.orespawn.mod.config_only": "Client-Konfigurationsintegration", + "label.orespawn.mod.none": "Keine geladenen OreSpawn-Integrationen gefunden.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Aktiv", + "status.orespawn.mod.pending": "Ausstehend", + "status.orespawn.mod.inactive": "Inaktiv", + "status.orespawn.mod.rejected": "Abgelehnt", + "status.orespawn.mod.config_only": "Nur Konfigurationsbildschirm", + "error.orespawn.mod.screen_missing": "%s hat keinen Konfigurationsbildschirm erstellt.", + "error.orespawn.mod.screen_failed": "Die Konfiguration von %s konnte nicht geöffnet werden (%s).", + "button.orespawn.ore_sources": "Erzquellen...", + "tooltip.orespawn.ore_sources": "Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen.", + "screen.orespawn.ore_sources": "Erzquellen", + "screen.orespawn.ore_source_detail": "Erzquellenrichtlinie", + "label.orespawn.ore_source.none": "Keine gleichwertigen verwalteten Erzquellen gefunden.", + "label.orespawn.ore_source.row": "%s - %s - %s Kandidaten - %s", + "label.orespawn.ore_source.intro": "Wähle, wie gleichwertige Erze generiert werden.", + "label.orespawn.ore_source.summary": "%s Kandidaten - %s", + "button.orespawn.ore_source.review": "Prüfen...", + "button.orespawn.ore_source.configure": "Konfigurieren...", + "label.orespawn.ore_source.material": "Rohstoff: %s", + "label.orespawn.ore_source.domain": "Dimension/Bereich: %s", + "label.orespawn.ore_source.channels": "Platzierungskanäle: %s", + "label.orespawn.ore_source.missing": "Fehlende Platzierungsquelle", + "label.orespawn.ore_source.owner": "Besitzer: %s, Version %s", + "label.orespawn.ore_source.registry": "Registrierte Ausgabe: %s, Metadaten %s", + "label.orespawn.ore_source.dictionary": "Block-Tags: %s", + "option.orespawn.ore_source.mode": "Modus: %s", + "option.orespawn.ore_source.channel": "Kanal: %s", + "option.orespawn.ore_source.placement": "Platzierungsquelle: %s", + "option.orespawn.ore_source.weight": "Gewicht", + "mode.orespawn.ore_source.consolidated": "Zusammengeführt", + "mode.orespawn.ore_source.keep_separate": "Getrennt halten", + "status.orespawn.ore_source.consolidated": "Zusammengeführt", + "status.orespawn.ore_source.separate": "Getrennt", + "status.orespawn.ore_source.review_required": "Prüfung erforderlich", + "status.orespawn.ore_source.missing_source": "Quelle fehlt", + "status.orespawn.ore_source.external_generation": "Externe Generierung", + "tooltip.orespawn.ore_source.open": "Diese Materialrichtlinie öffnen.", + "tooltip.orespawn.ore_source.mode": "Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel.", + "tooltip.orespawn.ore_source.channel": "Platzierungs-Engines werden unabhängig verwaltet.", + "tooltip.orespawn.ore_source.placement": "Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt.", + "tooltip.orespawn.ore_source.weight": "Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen.", + "tooltip.orespawn.ore_source.external": "Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle.", + "tooltip.orespawn.ore_source.enrichment": "Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle.", + "tooltip.orespawn.ore_source.missing": "Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt.", + "error.orespawn.ore_source.weight": "Ausgabegewichte müssen positive Zahlen sein.", + "button.orespawn.back": "Zurück", + "button.orespawn.ore_source.all_groups": "Alle Gruppen", + "button.orespawn.ore_source.add_group": "Gruppe hinzufügen", + "button.orespawn.ore_source.accept": "Akzeptieren", + "tooltip.orespawn.ore_source.accept": "Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor.", + "button.orespawn.ore_source.delete_group": "Löschen", + "button.orespawn.ore_source.dissolve_group": "Gruppe auflösen", + "button.orespawn.ore_source.confirm_dissolve": "Auflösen bestätigen", + "button.orespawn.ore_source.move": "Verschieben", + "button.orespawn.ore_source.advanced": "Erweitert...", + "button.orespawn.ore_source.hide_advanced": "Erweitert ausblenden", + "option.orespawn.ore_source.group_name": "Gruppenname", + "option.orespawn.ore_source.alias": "Block-Tag-Eintrag", + "option.orespawn.ore_source.output_mode": "Ausgabe: %s", + "mode.orespawn.ore_source.keep_original": "Original beibehalten", + "mode.orespawn.ore_source.balanced": "Ausgewogen", + "mode.orespawn.ore_source.single": "Einzeln", + "mode.orespawn.ore_source.custom": "Benutzerdefiniert", + "label.orespawn.ore_source.no_aliases": "Keine Block-Tag-Einträge", + "status.orespawn.ore_source.external_short": "extern", + "status.orespawn.ore_source.missing_short": "fehlt", + "status.orespawn.ore_source.output_only": "nur Ausgabe", + "tooltip.orespawn.ore_source.output_only": "Diese Anbieterausgabe ist verfügbar, aber ihre Platzierungsregel ist inaktiv.", + "tooltip.orespawn.ore_source.output_mode": "Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen.", + "tooltip.orespawn.ore_source.alias": "Füge einen exakten Block-Tag-Tag-Namen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen.", + "tooltip.orespawn.ore_source.reset_group": "Stelle den Namen und die Block-Tag-Einträge dieser kuratierten Gruppe wieder her.", + "tooltip.orespawn.ore_source.delete_group": "Lösche diese leere benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben.", + "tooltip.orespawn.ore_source.dissolve_group": "Ordnet jeden Eintrag wieder seiner ermittelten oder kuratierten Gruppe zu. Zum Bestätigen zweimal drücken.", + "error.orespawn.ore_source.confirm_dissolve": "Drücke Auflösen bestätigen, um jeden Block-Tags-Eintrag seiner ursprünglichen Gruppe zuzuordnen.", + "error.orespawn.ore_source.alias": "Verwende einen exakten Block-Tag-Tag-Namen wie forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen.", + "error.orespawn.ore_source.manage_vanilla_first": "Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst.", + "error.orespawn.ore_source.defaults_write": "Die Standardwerte der Materialgruppen konnten nicht gespeichert werden.", + "screen.orespawn.ore_source.group_settings": "Erzquellen-Gruppeneinstellungen", + "label.orespawn.ore_source.groups": "Gruppen", + "label.orespawn.ore_source.aliases": "Block-Tag-Einträge", + "label.orespawn.ore_source.placement_rules": "Platzierungsregeln", + "label.orespawn.ore_source.placement_explanation": "Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte.", + "label.orespawn.ore_source.standard_veins": "Standardadern", + "label.orespawn.ore_source.placement_choice_count": "%s (%s Möglichkeiten)", + "label.orespawn.ore_source.no_placement_rules": "Keine verwalteten Platzierungsregeln", + "tooltip.orespawn.ore_source.group_settings": "Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten.", + "tooltip.orespawn.ore_source.remove_alias": "%s aus dieser Gruppe entfernen.", + "tooltip.orespawn.ore_source.placement_rules": "Ausgaben wählen den Erzblock; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirtsbeschränkungen.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Platzierungsbesitz gilt nur für konsolidierte Modi Ausgeglichen, Einzeln oder Benutzerdefiniert.", + "tooltip.orespawn.ore_source.placement_missing": "Die gespeicherte Platzierungsquelle fehlt; dieser Kanal bleibt deaktiviert.", + "tooltip.orespawn.ore_source.placement_readonly": "Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt.", + "tooltip.orespawn.ore_source.placement_select": "Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen.", + "tooltip.orespawn.ore_source.external_excluded": "Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden.", + "button.orespawn.ore_source.hide_single": "Einzelne ausblenden", + "tooltip.orespawn.ore_source.group_colours": "Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein.", + "button.orespawn.ore_source.reset_all": "Alles zurücksetzen", + "button.orespawn.ore_source.confirm_reset_all": "Zurücksetzen bestätigen", + "tooltip.orespawn.ore_source.reset_all": "Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend.", + "screen.orespawn.biomes": "Biome", + "screen.orespawn.biome_palettes": "Biompaletten", + "screen.orespawn.biome_palette_settings": "Biompaletten-Einstellungen", + "button.orespawn.biome.hide_routine": "Gewöhnliche ausblenden", + "button.orespawn.biome.replace_new_terrain": "In neuem Gelände ersetzen durch...", + "button.orespawn.biome.replace_with": "In neuem Gelände ersetzen durch %s...", + "button.orespawn.biome.leave_original": "Ursprüngliches Verhalten beibehalten", + "button.orespawn.biome.palettes": "Paletten: %s", + "button.orespawn.biome.reset_selected": "Biom zurücksetzen", + "button.orespawn.biome.confirm_reset": "Zurücksetzen bestätigen", + "button.orespawn.biome.reset_palette": "Palette zurücksetzen", + "button.orespawn.biome.confirm_reset_palette": "Paletten-Reset bestätigen", + "button.orespawn.biome.reset_dimension": "Dimension zurücksetzen", + "button.orespawn.biome.confirm_reset_dimension": "Dimensions-Reset bestätigen", + "button.orespawn.biome.reset_all": "Alle Biome zurücksetzen", + "button.orespawn.biome.confirm_reset_all": "Gesamten Reset bestätigen", + "value.orespawn.biome.status.user_replaced": "In neuem Gelände ersetzt", + "value.orespawn.biome.status.layered": "Mehrere Platzierungsregeln", + "value.orespawn.biome.status.disabled": "Platzierung deaktiviert", + "value.orespawn.biome.status.missing": "Biom oder Anbieter fehlt", + "value.orespawn.biome.status.provider_managed": "Vom Anbieter verwaltet", + "value.orespawn.biome.status.unmanaged": "Registriert, nicht verwaltet", + "label.orespawn.biome.rule_count": "Platzierungsregeln: %s", + "label.orespawn.biome.replaced_by": "Ersatz in neuem Gelände: %s", + "label.orespawn.biome.palette_owner_order": "Besitzer: %s; wirksame Reihenfolge: %s", + "label.orespawn.biome.surface_effective": "Diese Regel liefert die wirksame Oberfläche.", + "label.orespawn.biome.surface_overridden": "Eine spätere aktive Regel überschreibt diese Oberfläche.", + "label.orespawn.biome.placement_rules": "Platzierungsregeln", + "label.orespawn.biome.palette_entries": "Platzierungseinträge: %s", + "tooltip.orespawn.biome.no_retrogen": "Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben.", + "tooltip.orespawn.biome.override_palette": "Reservierte abschließende Ersatzebene. Bearbeitung über die Ersatzsteuerung des Bioms.", + "tooltip.orespawn.biome.palette_entries_read_only": "Die Palettenzugehörigkeit wird vom Anbieter oder Profil festgelegt. Wähle ein Biom im Verzeichnis, um eine vorhandene Regel zu bearbeiten.", + "option.orespawn.biome.coverage": "Abdeckung", + "option.orespawn.biome.fallback_weight": "Gewicht des ursprünglichen Bioms", + "option.orespawn.biome.include_namespaces": "Eingeschlossene Namensräume (kommagetrennt)", + "option.orespawn.biome.exclude_namespaces": "Ausgeschlossene Namensräume (kommagetrennt)" } diff --git a/src/main/resources/assets/orespawn/lang/de_de.json b/src/main/resources/assets/orespawn/lang/de_de.json index f9209f27..1cfed7ef 100644 --- a/src/main/resources/assets/orespawn/lang/de_de.json +++ b/src/main/resources/assets/orespawn/lang/de_de.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Ersetzen Sie den freiliegenden Unterwasseroberflächenblock des Bioms. Nicht gesetzt behält den normalen Block bei.", "tooltip.orespawn.biome.ceiling_block": "Ersetzen Sie den Deckenflächenblock des Bioms in Abmessungen, die Decken erzeugen. Nicht festgelegt behält den normalen Block bei.", "tooltip.orespawn.biome.filler_depth": "Anzahl der Ebenen unter dem oberen Block, die den Füllblock verwenden. Gültiger Bereich: 0 bis 16.", - "tooltip.orespawn.material.default_fluid": "Wählen Sie die normale Grundwasserleiterflüssigkeit, die unterhalb des Meeresspiegels verwendet wird. Nicht festgelegt behält die ursprüngliche Flüssigkeit von Minecraft bei.", + "tooltip.orespawn.material.default_fluid": "Ersetzt die normale Grundwasserleiterflüssigkeit unter dem Meeresspiegel in jedem Biom dieser Dimension. Nicht festgelegt behält die ursprüngliche Generatorflüssigkeit bei. Bestehende Chunks bleiben unverändert; Flüssigkeiten mit ungewöhnlicher Beleuchtung können langsamer generieren.", "tooltip.orespawn.material.deep_aquifer_fluid": "In Minecraft 1.14.4 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten.", "tooltip.orespawn.material.deep_aquifer_y": "In Minecraft 1.14.4 nicht verfügbar; der gespeicherte Wert bleibt für spätere Versionen erhalten.", "tooltip.orespawn.material.snow_block": "Ersetzen Sie Vanilleschnee, der in dieser Dimension in der Nähe der Oberfläche platziert wird. Nicht gesetzt hält normalen Schnee.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Installiertes Biom auswählen", "screen.orespawn.biome_placement": "Biomeinstellungen", "screen.orespawn.biome_references": "Ähnliche Biome", - "screen.orespawn.dimension_materials": "Weltmaterialien", + "screen.orespawn.dimension_materials": "%s-Materialien", "button.orespawn.biomes_world_materials": "Biome und Weltmaterialien...", "button.orespawn.biome_palette_count": "Biome: %s", "button.orespawn.dimension_materials": "Weltmaterialien...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Weltmaterialien", "guide.orespawn.materials.1": "Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.", "guide.orespawn.materials.2": "Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.", - "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert." + "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.", + "button.orespawn.dimension_materials_named": "%s-Materialien...", + "label.orespawn.dimension_materials.scope": "Gilt für jedes Biom im neu erzeugten Terrain von %s.", + "button.orespawn.mods": "Mod-Verzeichnis", + "button.orespawn.mod.configure": "Konfigurieren", + "tooltip.orespawn.main.mods": "Geladene Mods anzeigen, die in OreSpawn integriert sind.", + "screen.orespawn.mods": "OreSpawn-Mods", + "tooltip.orespawn.mod.configure": "Konfiguration von %s öffnen.", + "tooltip.orespawn.mod.unavailable": "%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.", + "label.orespawn.mod.id_version": "%s - Mod-Version %s", + "label.orespawn.mod.additional_lineage": "Auch über OreSpawn-%s-Kompatibilität erkannt.", + "label.orespawn.mod.status": "Zustand: %s", + "label.orespawn.mod.os4_version": "Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s", + "label.orespawn.mod.os4": "Nativer OS4-Anbieter", + "label.orespawn.mod.legacy_version": "OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s", + "label.orespawn.mod.legacy": "OreSpawn-%s-Kompatibilität -> OS4", + "label.orespawn.mod.config_only": "Client-Konfigurationsintegration", + "label.orespawn.mod.none": "Keine geladenen OreSpawn-Integrationen gefunden.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Aktiv", + "status.orespawn.mod.pending": "Ausstehend", + "status.orespawn.mod.inactive": "Inaktiv", + "status.orespawn.mod.rejected": "Abgelehnt", + "status.orespawn.mod.config_only": "Nur Konfigurationsbildschirm", + "error.orespawn.mod.screen_missing": "%s hat keinen Konfigurationsbildschirm erstellt.", + "error.orespawn.mod.screen_failed": "Die Konfiguration von %s konnte nicht geöffnet werden (%s).", + "button.orespawn.ore_sources": "Erzquellen...", + "tooltip.orespawn.ore_sources": "Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen.", + "screen.orespawn.ore_sources": "Erzquellen", + "screen.orespawn.ore_source_detail": "Erzquellenrichtlinie", + "label.orespawn.ore_source.none": "Keine gleichwertigen verwalteten Erzquellen gefunden.", + "label.orespawn.ore_source.row": "%s - %s - %s Kandidaten - %s", + "label.orespawn.ore_source.intro": "Wähle, wie gleichwertige Erze generiert werden.", + "label.orespawn.ore_source.summary": "%s Kandidaten - %s", + "button.orespawn.ore_source.review": "Prüfen...", + "button.orespawn.ore_source.configure": "Konfigurieren...", + "label.orespawn.ore_source.material": "Rohstoff: %s", + "label.orespawn.ore_source.domain": "Dimension/Bereich: %s", + "label.orespawn.ore_source.channels": "Platzierungskanäle: %s", + "label.orespawn.ore_source.missing": "Fehlende Platzierungsquelle", + "label.orespawn.ore_source.owner": "Besitzer: %s, Version %s", + "label.orespawn.ore_source.registry": "Registrierte Ausgabe: %s, Metadaten %s", + "label.orespawn.ore_source.dictionary": "Block-Tags: %s", + "option.orespawn.ore_source.mode": "Modus: %s", + "option.orespawn.ore_source.channel": "Kanal: %s", + "option.orespawn.ore_source.placement": "Platzierungsquelle: %s", + "option.orespawn.ore_source.weight": "Gewicht", + "mode.orespawn.ore_source.consolidated": "Zusammengeführt", + "mode.orespawn.ore_source.keep_separate": "Getrennt halten", + "status.orespawn.ore_source.consolidated": "Zusammengeführt", + "status.orespawn.ore_source.separate": "Getrennt", + "status.orespawn.ore_source.review_required": "Prüfung erforderlich", + "status.orespawn.ore_source.missing_source": "Quelle fehlt", + "status.orespawn.ore_source.external_generation": "Externe Generierung", + "tooltip.orespawn.ore_source.open": "Diese Materialrichtlinie öffnen.", + "tooltip.orespawn.ore_source.mode": "Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel.", + "tooltip.orespawn.ore_source.channel": "Platzierungs-Engines werden unabhängig verwaltet.", + "tooltip.orespawn.ore_source.placement": "Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt.", + "tooltip.orespawn.ore_source.weight": "Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen.", + "tooltip.orespawn.ore_source.external": "Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle.", + "tooltip.orespawn.ore_source.enrichment": "Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle.", + "tooltip.orespawn.ore_source.missing": "Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt.", + "error.orespawn.ore_source.weight": "Ausgabegewichte müssen positive Zahlen sein.", + "button.orespawn.back": "Zurück", + "button.orespawn.ore_source.all_groups": "Alle Gruppen", + "button.orespawn.ore_source.add_group": "Gruppe hinzufügen", + "button.orespawn.ore_source.accept": "Akzeptieren", + "tooltip.orespawn.ore_source.accept": "Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor.", + "button.orespawn.ore_source.delete_group": "Löschen", + "button.orespawn.ore_source.dissolve_group": "Gruppe auflösen", + "button.orespawn.ore_source.confirm_dissolve": "Auflösen bestätigen", + "button.orespawn.ore_source.move": "Verschieben", + "button.orespawn.ore_source.advanced": "Erweitert...", + "button.orespawn.ore_source.hide_advanced": "Erweitert ausblenden", + "option.orespawn.ore_source.group_name": "Gruppenname", + "option.orespawn.ore_source.alias": "Block-Tag-Eintrag", + "option.orespawn.ore_source.output_mode": "Ausgabe: %s", + "mode.orespawn.ore_source.keep_original": "Original beibehalten", + "mode.orespawn.ore_source.balanced": "Ausgewogen", + "mode.orespawn.ore_source.single": "Einzeln", + "mode.orespawn.ore_source.custom": "Benutzerdefiniert", + "label.orespawn.ore_source.no_aliases": "Keine Block-Tag-Einträge", + "status.orespawn.ore_source.external_short": "extern", + "status.orespawn.ore_source.missing_short": "fehlt", + "status.orespawn.ore_source.output_only": "nur Ausgabe", + "tooltip.orespawn.ore_source.output_only": "Diese Anbieterausgabe ist verfügbar, aber ihre Platzierungsregel ist inaktiv.", + "tooltip.orespawn.ore_source.output_mode": "Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen.", + "tooltip.orespawn.ore_source.alias": "Füge einen exakten Block-Tag-Tag-Namen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen.", + "tooltip.orespawn.ore_source.reset_group": "Stelle den Namen und die Block-Tag-Einträge dieser kuratierten Gruppe wieder her.", + "tooltip.orespawn.ore_source.delete_group": "Lösche diese leere benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben.", + "tooltip.orespawn.ore_source.dissolve_group": "Ordnet jeden Eintrag wieder seiner ermittelten oder kuratierten Gruppe zu. Zum Bestätigen zweimal drücken.", + "error.orespawn.ore_source.confirm_dissolve": "Drücke Auflösen bestätigen, um jeden Block-Tags-Eintrag seiner ursprünglichen Gruppe zuzuordnen.", + "error.orespawn.ore_source.alias": "Verwende einen exakten Block-Tag-Tag-Namen wie forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen.", + "error.orespawn.ore_source.manage_vanilla_first": "Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst.", + "error.orespawn.ore_source.defaults_write": "Die Standardwerte der Materialgruppen konnten nicht gespeichert werden.", + "screen.orespawn.ore_source.group_settings": "Erzquellen-Gruppeneinstellungen", + "label.orespawn.ore_source.groups": "Gruppen", + "label.orespawn.ore_source.aliases": "Block-Tag-Einträge", + "label.orespawn.ore_source.placement_rules": "Platzierungsregeln", + "label.orespawn.ore_source.placement_explanation": "Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte.", + "label.orespawn.ore_source.standard_veins": "Standardadern", + "label.orespawn.ore_source.placement_choice_count": "%s (%s Möglichkeiten)", + "label.orespawn.ore_source.no_placement_rules": "Keine verwalteten Platzierungsregeln", + "tooltip.orespawn.ore_source.group_settings": "Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten.", + "tooltip.orespawn.ore_source.remove_alias": "%s aus dieser Gruppe entfernen.", + "tooltip.orespawn.ore_source.placement_rules": "Ausgaben wählen den Erzblock; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirtsbeschränkungen.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Platzierungsbesitz gilt nur für konsolidierte Modi Ausgeglichen, Einzeln oder Benutzerdefiniert.", + "tooltip.orespawn.ore_source.placement_missing": "Die gespeicherte Platzierungsquelle fehlt; dieser Kanal bleibt deaktiviert.", + "tooltip.orespawn.ore_source.placement_readonly": "Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt.", + "tooltip.orespawn.ore_source.placement_select": "Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen.", + "tooltip.orespawn.ore_source.external_excluded": "Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden.", + "button.orespawn.ore_source.hide_single": "Einzelne ausblenden", + "tooltip.orespawn.ore_source.group_colours": "Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein.", + "button.orespawn.ore_source.reset_all": "Alles zurücksetzen", + "button.orespawn.ore_source.confirm_reset_all": "Zurücksetzen bestätigen", + "tooltip.orespawn.ore_source.reset_all": "Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend.", + "screen.orespawn.biomes": "Biome", + "screen.orespawn.biome_palettes": "Biompaletten", + "screen.orespawn.biome_palette_settings": "Biompaletten-Einstellungen", + "button.orespawn.biome.hide_routine": "Gewöhnliche ausblenden", + "button.orespawn.biome.replace_new_terrain": "In neuem Gelände ersetzen durch...", + "button.orespawn.biome.replace_with": "In neuem Gelände ersetzen durch %s...", + "button.orespawn.biome.leave_original": "Ursprüngliches Verhalten beibehalten", + "button.orespawn.biome.palettes": "Paletten: %s", + "button.orespawn.biome.reset_selected": "Biom zurücksetzen", + "button.orespawn.biome.confirm_reset": "Zurücksetzen bestätigen", + "button.orespawn.biome.reset_palette": "Palette zurücksetzen", + "button.orespawn.biome.confirm_reset_palette": "Paletten-Reset bestätigen", + "button.orespawn.biome.reset_dimension": "Dimension zurücksetzen", + "button.orespawn.biome.confirm_reset_dimension": "Dimensions-Reset bestätigen", + "button.orespawn.biome.reset_all": "Alle Biome zurücksetzen", + "button.orespawn.biome.confirm_reset_all": "Gesamten Reset bestätigen", + "value.orespawn.biome.status.user_replaced": "In neuem Gelände ersetzt", + "value.orespawn.biome.status.layered": "Mehrere Platzierungsregeln", + "value.orespawn.biome.status.disabled": "Platzierung deaktiviert", + "value.orespawn.biome.status.missing": "Biom oder Anbieter fehlt", + "value.orespawn.biome.status.provider_managed": "Vom Anbieter verwaltet", + "value.orespawn.biome.status.unmanaged": "Registriert, nicht verwaltet", + "label.orespawn.biome.rule_count": "Platzierungsregeln: %s", + "label.orespawn.biome.replaced_by": "Ersatz in neuem Gelände: %s", + "label.orespawn.biome.palette_owner_order": "Besitzer: %s; wirksame Reihenfolge: %s", + "label.orespawn.biome.surface_effective": "Diese Regel liefert die wirksame Oberfläche.", + "label.orespawn.biome.surface_overridden": "Eine spätere aktive Regel überschreibt diese Oberfläche.", + "label.orespawn.biome.placement_rules": "Platzierungsregeln", + "label.orespawn.biome.palette_entries": "Platzierungseinträge: %s", + "tooltip.orespawn.biome.no_retrogen": "Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben.", + "tooltip.orespawn.biome.override_palette": "Reservierte abschließende Ersatzebene. Bearbeitung über die Ersatzsteuerung des Bioms.", + "tooltip.orespawn.biome.palette_entries_read_only": "Die Palettenzugehörigkeit wird vom Anbieter oder Profil festgelegt. Wähle ein Biom im Verzeichnis, um eine vorhandene Regel zu bearbeiten.", + "option.orespawn.biome.coverage": "Abdeckung", + "option.orespawn.biome.fallback_weight": "Gewicht des ursprünglichen Bioms", + "option.orespawn.biome.include_namespaces": "Eingeschlossene Namensräume (kommagetrennt)", + "option.orespawn.biome.exclude_namespaces": "Ausgeschlossene Namensräume (kommagetrennt)" } diff --git a/src/main/resources/assets/orespawn/lang/en_ca.json b/src/main/resources/assets/orespawn/lang/en_ca.json index 425d6dea..e495e4a4 100644 --- a/src/main/resources/assets/orespawn/lang/en_ca.json +++ b/src/main/resources/assets/orespawn/lang/en_ca.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Replace the biome's exposed underwater surface block. Not set keeps the normal block.", "tooltip.orespawn.biome.ceiling_block": "Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block.", "tooltip.orespawn.biome.filler_depth": "Number of layers below the top block that use Filler Block. Valid range: 0 to 16.", - "tooltip.orespawn.material.default_fluid": "Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid.", + "tooltip.orespawn.material.default_fluid": "Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly.", "tooltip.orespawn.material.deep_aquifer_fluid": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.deep_aquifer_y": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.snow_block": "Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_en.json b/src/main/resources/assets/orespawn/lang/en_en.json index 425d6dea..e495e4a4 100644 --- a/src/main/resources/assets/orespawn/lang/en_en.json +++ b/src/main/resources/assets/orespawn/lang/en_en.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Replace the biome's exposed underwater surface block. Not set keeps the normal block.", "tooltip.orespawn.biome.ceiling_block": "Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block.", "tooltip.orespawn.biome.filler_depth": "Number of layers below the top block that use Filler Block. Valid range: 0 to 16.", - "tooltip.orespawn.material.default_fluid": "Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid.", + "tooltip.orespawn.material.default_fluid": "Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly.", "tooltip.orespawn.material.deep_aquifer_fluid": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.deep_aquifer_y": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.snow_block": "Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_gb.json b/src/main/resources/assets/orespawn/lang/en_gb.json index 425d6dea..e495e4a4 100644 --- a/src/main/resources/assets/orespawn/lang/en_gb.json +++ b/src/main/resources/assets/orespawn/lang/en_gb.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Replace the biome's exposed underwater surface block. Not set keeps the normal block.", "tooltip.orespawn.biome.ceiling_block": "Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block.", "tooltip.orespawn.biome.filler_depth": "Number of layers below the top block that use Filler Block. Valid range: 0 to 16.", - "tooltip.orespawn.material.default_fluid": "Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid.", + "tooltip.orespawn.material.default_fluid": "Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly.", "tooltip.orespawn.material.deep_aquifer_fluid": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.deep_aquifer_y": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.snow_block": "Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_pt.json b/src/main/resources/assets/orespawn/lang/en_pt.json index 425d6dea..e495e4a4 100644 --- a/src/main/resources/assets/orespawn/lang/en_pt.json +++ b/src/main/resources/assets/orespawn/lang/en_pt.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Replace the biome's exposed underwater surface block. Not set keeps the normal block.", "tooltip.orespawn.biome.ceiling_block": "Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block.", "tooltip.orespawn.biome.filler_depth": "Number of layers below the top block that use Filler Block. Valid range: 0 to 16.", - "tooltip.orespawn.material.default_fluid": "Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid.", + "tooltip.orespawn.material.default_fluid": "Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly.", "tooltip.orespawn.material.deep_aquifer_fluid": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.deep_aquifer_y": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.snow_block": "Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_us.json b/src/main/resources/assets/orespawn/lang/en_us.json index e6c05959..7f70e8c4 100644 --- a/src/main/resources/assets/orespawn/lang/en_us.json +++ b/src/main/resources/assets/orespawn/lang/en_us.json @@ -57,7 +57,7 @@ "tooltip.orespawn.biome.underwater_block": "Replace the biome's exposed underwater surface block. Not set keeps the normal block.", "tooltip.orespawn.biome.ceiling_block": "Replace the biome's ceiling surface block in dimensions that generate ceilings. Not set keeps the normal block.", "tooltip.orespawn.biome.filler_depth": "Number of layers below the top block that use Filler Block. Valid range: 0 to 16.", - "tooltip.orespawn.material.default_fluid": "Choose the normal aquifer fluid used below sea level. Not set keeps Minecraft's original fluid.", + "tooltip.orespawn.material.default_fluid": "Replace the normal aquifer fluid below sea level across every biome in this dimension. Not set keeps the generator's original fluid. Existing chunks are unchanged; fluids with unusual lighting may generate more slowly.", "tooltip.orespawn.material.deep_aquifer_fluid": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.deep_aquifer_y": "Unavailable in Minecraft 1.14.4; the stored value is retained for later versions.", "tooltip.orespawn.material.snow_block": "Replace vanilla snow placed near the surface in this dimension. Not set keeps normal snow.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/es_es.json b/src/main/resources/assets/orespawn/lang/es_es.json index 7b86847b..4c297776 100644 --- a/src/main/resources/assets/orespawn/lang/es_es.json +++ b/src/main/resources/assets/orespawn/lang/es_es.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Reemplaza el bloque de superficie submarina expuesto del bioma. No establecido mantiene el bloqueo normal.", "tooltip.orespawn.biome.ceiling_block": "Reemplace el bloque de superficie del techo del bioma en dimensiones que generen techos. No establecido mantiene el bloque normal.", "tooltip.orespawn.biome.filler_depth": "Número de capas debajo del bloque superior que utilizan el bloque de relleno. Rango válido: 0 a 16.", - "tooltip.orespawn.material.default_fluid": "Elija el fluido normal del acuífero utilizado debajo del nivel del mar. No establecido mantiene el fluido original de Minecraft.", + "tooltip.orespawn.material.default_fluid": "Reemplaza el fluido acuífero normal bajo el nivel del mar en cada bioma de esta dimensión. Sin definir conserva el fluido original del generador. Los chunks existentes no cambian; los fluidos con iluminación inusual pueden generarse más lentamente.", "tooltip.orespawn.material.deep_aquifer_fluid": "No disponible en Minecraft 1.14.4; el valor guardado se conserva para versiones posteriores.", "tooltip.orespawn.material.deep_aquifer_y": "No disponible en Minecraft 1.14.4; el valor guardado se conserva para versiones posteriores.", "tooltip.orespawn.material.snow_block": "Reemplace la nieve vainilla colocada cerca de la superficie en esta dimensión. No fijado mantiene la nieve normal.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Elegir bioma instalado", "screen.orespawn.biome_placement": "Ajustes del bioma", "screen.orespawn.biome_references": "Biomas similares", - "screen.orespawn.dimension_materials": "Materiales del mundo", + "screen.orespawn.dimension_materials": "Materiales de %s", "button.orespawn.biomes_world_materials": "Biomas y materiales del mundo...", "button.orespawn.biome_palette_count": "Biomas: %s", "button.orespawn.dimension_materials": "Materiales del mundo...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Materiales del mundo", "guide.orespawn.materials.1": "Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.", "guide.orespawn.materials.2": "Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.", - "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft." + "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Materiales de %s...", + "label.orespawn.dimension_materials.scope": "Se aplica a cada bioma del terreno nuevo de %s.", + "button.orespawn.mods": "Directorio de mods", + "button.orespawn.mod.configure": "Configurar", + "tooltip.orespawn.main.mods": "Ver los mods cargados integrados con OreSpawn.", + "screen.orespawn.mods": "Mods de OreSpawn", + "tooltip.orespawn.mod.configure": "Abrir la configuración de %s.", + "tooltip.orespawn.mod.unavailable": "%s no proporciona una pantalla de configuración vinculada con OreSpawn.", + "label.orespawn.mod.id_version": "%s - Versión del mod %s", + "label.orespawn.mod.additional_lineage": "También detectado mediante compatibilidad con OreSpawn %s.", + "label.orespawn.mod.status": "Estado: %s", + "label.orespawn.mod.os4_version": "Proveedor OS4 nativo - esquema %s, revisión del proveedor %s", + "label.orespawn.mod.os4": "Proveedor OS4 nativo", + "label.orespawn.mod.legacy_version": "Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s", + "label.orespawn.mod.legacy": "Compatibilidad con OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Integración de configuración del cliente", + "label.orespawn.mod.none": "No se encontraron integraciones de OreSpawn cargadas.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Activo", + "status.orespawn.mod.pending": "Pendiente", + "status.orespawn.mod.inactive": "Inactivo", + "status.orespawn.mod.rejected": "Rechazado", + "status.orespawn.mod.config_only": "Solo pantalla de configuración", + "error.orespawn.mod.screen_missing": "%s no creó una pantalla de configuración.", + "error.orespawn.mod.screen_failed": "No se pudo abrir la configuración de %s (%s).", + "button.orespawn.ore_sources": "Fuentes de mineral...", + "tooltip.orespawn.ore_sources": "Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación.", + "screen.orespawn.ore_sources": "Fuentes de mineral", + "screen.orespawn.ore_source_detail": "Política de fuente de mineral", + "label.orespawn.ore_source.none": "No se encontraron fuentes de mineral equivalentes gestionadas.", + "label.orespawn.ore_source.row": "%s - %s - %s candidatos - %s", + "label.orespawn.ore_source.intro": "Elige cómo se generan los minerales equivalentes.", + "label.orespawn.ore_source.summary": "%s candidatos - %s", + "button.orespawn.ore_source.review": "Revisar...", + "button.orespawn.ore_source.configure": "Configurar...", + "label.orespawn.ore_source.material": "Materia: %s", + "label.orespawn.ore_source.domain": "Dimensión/dominio: %s", + "label.orespawn.ore_source.channels": "Canales de colocación: %s", + "label.orespawn.ore_source.missing": "Falta la fuente de colocación", + "label.orespawn.ore_source.owner": "Propietario: %s, versión %s", + "label.orespawn.ore_source.registry": "Salida registrada: %s, metadatos %s", + "label.orespawn.ore_source.dictionary": "Etiquetas de bloques: %s", + "option.orespawn.ore_source.mode": "Modo: %s", + "option.orespawn.ore_source.channel": "Canal: %s", + "option.orespawn.ore_source.placement": "Fuente de colocación: %s", + "option.orespawn.ore_source.weight": "Peso", + "mode.orespawn.ore_source.consolidated": "Consolidado", + "mode.orespawn.ore_source.keep_separate": "Mantener separado", + "status.orespawn.ore_source.consolidated": "Consolidado", + "status.orespawn.ore_source.separate": "Separado", + "status.orespawn.ore_source.review_required": "Revisión necesaria", + "status.orespawn.ore_source.missing_source": "Falta la fuente", + "status.orespawn.ore_source.external_generation": "Generación externa", + "tooltip.orespawn.ore_source.open": "Abre esta política de material.", + "tooltip.orespawn.ore_source.mode": "Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada.", + "tooltip.orespawn.ore_source.channel": "Los motores de colocación se arbitran de forma independiente.", + "tooltip.orespawn.ore_source.placement": "Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal.", + "tooltip.orespawn.ore_source.weight": "Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado.", + "tooltip.orespawn.ore_source.external": "Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controla esta colocación y fuente de salida configuradas.", + "tooltip.orespawn.ore_source.enrichment": "Esta entrada es enriquecimiento, no una fuente de mineral intercambiable.", + "tooltip.orespawn.ore_source.missing": "Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario.", + "error.orespawn.ore_source.weight": "Los pesos de salida deben ser números positivos.", + "button.orespawn.back": "Atrás", + "button.orespawn.ore_source.all_groups": "Todos los grupos", + "button.orespawn.ore_source.add_group": "Añadir grupo", + "button.orespawn.ore_source.accept": "Aceptar", + "tooltip.orespawn.ore_source.accept": "Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal.", + "button.orespawn.ore_source.delete_group": "Eliminar", + "button.orespawn.ore_source.dissolve_group": "Disolver grupo", + "button.orespawn.ore_source.confirm_dissolve": "Confirmar disolución", + "button.orespawn.ore_source.move": "Mover", + "button.orespawn.ore_source.advanced": "Avanzado...", + "button.orespawn.ore_source.hide_advanced": "Ocultar avanzado", + "option.orespawn.ore_source.group_name": "Nombre del grupo", + "option.orespawn.ore_source.alias": "Entrada del sistema de etiquetas de bloques", + "option.orespawn.ore_source.output_mode": "Salida: %s", + "mode.orespawn.ore_source.keep_original": "Mantener original", + "mode.orespawn.ore_source.balanced": "Equilibrado", + "mode.orespawn.ore_source.single": "Único", + "mode.orespawn.ore_source.custom": "Personalizado", + "label.orespawn.ore_source.no_aliases": "Sin entradas del sistema de etiquetas de bloques", + "status.orespawn.ore_source.external_short": "externo", + "status.orespawn.ore_source.missing_short": "ausente", + "status.orespawn.ore_source.output_only": "solo salida", + "tooltip.orespawn.ore_source.output_only": "Esta salida del proveedor está disponible, pero su regla de colocación está inactiva.", + "tooltip.orespawn.ore_source.output_mode": "Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas.", + "tooltip.orespawn.ore_source.alias": "Añade un nombre exacto de mineral del sistema de etiquetas de bloques. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo.", + "tooltip.orespawn.ore_source.reset_group": "Restaura el nombre y las entradas del sistema de etiquetas de bloques de este grupo seleccionado.", + "tooltip.orespawn.ore_source.delete_group": "Elimina este grupo personalizado vacío sin reescribir los chunks generados.", + "tooltip.orespawn.ore_source.dissolve_group": "Devuelve cada entrada a su grupo inferido o revisado. Pulsa dos veces para confirmar.", + "error.orespawn.ore_source.confirm_dissolve": "Pulsa Confirmar disolución para devolver cada entrada del sistema de etiquetas de bloques a su grupo original.", + "error.orespawn.ore_source.alias": "Usa un nombre exacto del sistema de etiquetas de bloques, como forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s ya posee esta entrada. Pulsa Mover para reasignarla.", + "error.orespawn.ore_source.manage_vanilla_first": "Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo.", + "error.orespawn.ore_source.defaults_write": "No se pudieron guardar los valores predeterminados de los grupos de materiales.", + "screen.orespawn.ore_source.group_settings": "Configuración del grupo de fuentes de mena", + "label.orespawn.ore_source.groups": "Grupos", + "label.orespawn.ore_source.aliases": "Entradas del sistema de etiquetas de bloques de menas", + "label.orespawn.ore_source.placement_rules": "Reglas de colocación", + "label.orespawn.ore_source.placement_explanation": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona.", + "label.orespawn.ore_source.standard_veins": "Vetas estándar", + "label.orespawn.ore_source.placement_choice_count": "%s (%s opciones)", + "label.orespawn.ore_source.no_placement_rules": "Sin reglas de colocación administradas", + "tooltip.orespawn.ore_source.group_settings": "Edita el nombre, los alias y las reglas de colocación de este grupo.", + "tooltip.orespawn.ore_source.remove_alias": "Eliminar %s de este grupo.", + "tooltip.orespawn.ore_source.placement_rules": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y restricciones de roca anfitriona.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "La selección de colocación solo se aplica a los modos consolidados Equilibrado, Único o Personalizado.", + "tooltip.orespawn.ore_source.placement_missing": "Falta la fuente de colocación guardada; este canal permanece desactivado.", + "tooltip.orespawn.ore_source.placement_readonly": "Solo hay una regla administrada activa; esta fuente es de solo lectura.", + "tooltip.orespawn.ore_source.placement_select": "Haz clic para elegir entre fuentes de colocación administradas activas.", + "tooltip.orespawn.ore_source.external_excluded": "Los generadores externos se informan, pero no pueden elegirse como reglas de colocación.", + "button.orespawn.ore_source.hide_single": "Ocultar individuales", + "tooltip.orespawn.ore_source.group_colours": "Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes.", + "button.orespawn.ore_source.reset_all": "Restablecer todo", + "button.orespawn.ore_source.confirm_reset_all": "Confirmar restablecimiento", + "tooltip.orespawn.ore_source.reset_all": "Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal.", + "screen.orespawn.biomes": "Biomas", + "screen.orespawn.biome_palettes": "Paletas de biomas", + "screen.orespawn.biome_palette_settings": "Ajustes de paleta de biomas", + "button.orespawn.biome.hide_routine": "Ocultar normales", + "button.orespawn.biome.replace_new_terrain": "Reemplazar en terreno nuevo por...", + "button.orespawn.biome.replace_with": "Reemplazar en terreno nuevo por %s...", + "button.orespawn.biome.leave_original": "Mantener el comportamiento original", + "button.orespawn.biome.palettes": "Paletas: %s", + "button.orespawn.biome.reset_selected": "Restablecer bioma", + "button.orespawn.biome.confirm_reset": "Confirmar restablecimiento", + "button.orespawn.biome.reset_palette": "Restablecer paleta", + "button.orespawn.biome.confirm_reset_palette": "Confirmar paleta", + "button.orespawn.biome.reset_dimension": "Restablecer dimensión", + "button.orespawn.biome.confirm_reset_dimension": "Confirmar dimensión", + "button.orespawn.biome.reset_all": "Restablecer todos los biomas", + "button.orespawn.biome.confirm_reset_all": "Confirmar todo", + "value.orespawn.biome.status.user_replaced": "Reemplazado en terreno nuevo", + "value.orespawn.biome.status.layered": "Varias reglas de colocación", + "value.orespawn.biome.status.disabled": "Colocación desactivada", + "value.orespawn.biome.status.missing": "Falta el bioma o proveedor", + "value.orespawn.biome.status.provider_managed": "Gestionado por proveedor", + "value.orespawn.biome.status.unmanaged": "Registrado, no gestionado", + "label.orespawn.biome.rule_count": "Reglas de colocación: %s", + "label.orespawn.biome.replaced_by": "Reemplazo en terreno nuevo: %s", + "label.orespawn.biome.palette_owner_order": "Propietario: %s; orden efectivo: %s", + "label.orespawn.biome.surface_effective": "Esta regla aporta la superficie efectiva.", + "label.orespawn.biome.surface_overridden": "Una regla activa posterior reemplaza esta superficie.", + "label.orespawn.biome.placement_rules": "Reglas de colocación", + "label.orespawn.biome.palette_entries": "Entradas de colocación: %s", + "tooltip.orespawn.biome.no_retrogen": "Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben.", + "tooltip.orespawn.biome.override_palette": "Capa final reservada para reemplazos. Edítala desde el control de reemplazo de cada bioma.", + "tooltip.orespawn.biome.palette_entries_read_only": "La pertenencia a la paleta la define el proveedor o perfil. Selecciona un bioma para editar una regla existente.", + "option.orespawn.biome.coverage": "Cobertura", + "option.orespawn.biome.fallback_weight": "Peso del bioma original", + "option.orespawn.biome.include_namespaces": "Espacios de nombres incluidos (separados por comas)", + "option.orespawn.biome.exclude_namespaces": "Espacios de nombres excluidos (separados por comas)" } diff --git a/src/main/resources/assets/orespawn/lang/es_mx.json b/src/main/resources/assets/orespawn/lang/es_mx.json index 7b86847b..4c297776 100644 --- a/src/main/resources/assets/orespawn/lang/es_mx.json +++ b/src/main/resources/assets/orespawn/lang/es_mx.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Reemplaza el bloque de superficie submarina expuesto del bioma. No establecido mantiene el bloqueo normal.", "tooltip.orespawn.biome.ceiling_block": "Reemplace el bloque de superficie del techo del bioma en dimensiones que generen techos. No establecido mantiene el bloque normal.", "tooltip.orespawn.biome.filler_depth": "Número de capas debajo del bloque superior que utilizan el bloque de relleno. Rango válido: 0 a 16.", - "tooltip.orespawn.material.default_fluid": "Elija el fluido normal del acuífero utilizado debajo del nivel del mar. No establecido mantiene el fluido original de Minecraft.", + "tooltip.orespawn.material.default_fluid": "Reemplaza el fluido acuífero normal bajo el nivel del mar en cada bioma de esta dimensión. Sin definir conserva el fluido original del generador. Los chunks existentes no cambian; los fluidos con iluminación inusual pueden generarse más lentamente.", "tooltip.orespawn.material.deep_aquifer_fluid": "No disponible en Minecraft 1.14.4; el valor guardado se conserva para versiones posteriores.", "tooltip.orespawn.material.deep_aquifer_y": "No disponible en Minecraft 1.14.4; el valor guardado se conserva para versiones posteriores.", "tooltip.orespawn.material.snow_block": "Reemplace la nieve vainilla colocada cerca de la superficie en esta dimensión. No fijado mantiene la nieve normal.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Elegir bioma instalado", "screen.orespawn.biome_placement": "Ajustes del bioma", "screen.orespawn.biome_references": "Biomas similares", - "screen.orespawn.dimension_materials": "Materiales del mundo", + "screen.orespawn.dimension_materials": "Materiales de %s", "button.orespawn.biomes_world_materials": "Biomas y materiales del mundo...", "button.orespawn.biome_palette_count": "Biomas: %s", "button.orespawn.dimension_materials": "Materiales del mundo...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Materiales del mundo", "guide.orespawn.materials.1": "Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.", "guide.orespawn.materials.2": "Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.", - "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft." + "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Materiales de %s...", + "label.orespawn.dimension_materials.scope": "Se aplica a cada bioma del terreno nuevo de %s.", + "button.orespawn.mods": "Directorio de mods", + "button.orespawn.mod.configure": "Configurar", + "tooltip.orespawn.main.mods": "Ver los mods cargados integrados con OreSpawn.", + "screen.orespawn.mods": "Mods de OreSpawn", + "tooltip.orespawn.mod.configure": "Abrir la configuración de %s.", + "tooltip.orespawn.mod.unavailable": "%s no proporciona una pantalla de configuración vinculada con OreSpawn.", + "label.orespawn.mod.id_version": "%s - Versión del mod %s", + "label.orespawn.mod.additional_lineage": "También detectado mediante compatibilidad con OreSpawn %s.", + "label.orespawn.mod.status": "Estado: %s", + "label.orespawn.mod.os4_version": "Proveedor OS4 nativo - esquema %s, revisión del proveedor %s", + "label.orespawn.mod.os4": "Proveedor OS4 nativo", + "label.orespawn.mod.legacy_version": "Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s", + "label.orespawn.mod.legacy": "Compatibilidad con OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Integración de configuración del cliente", + "label.orespawn.mod.none": "No se encontraron integraciones de OreSpawn cargadas.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Activo", + "status.orespawn.mod.pending": "Pendiente", + "status.orespawn.mod.inactive": "Inactivo", + "status.orespawn.mod.rejected": "Rechazado", + "status.orespawn.mod.config_only": "Solo pantalla de configuración", + "error.orespawn.mod.screen_missing": "%s no creó una pantalla de configuración.", + "error.orespawn.mod.screen_failed": "No se pudo abrir la configuración de %s (%s).", + "button.orespawn.ore_sources": "Fuentes de mineral...", + "tooltip.orespawn.ore_sources": "Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación.", + "screen.orespawn.ore_sources": "Fuentes de mineral", + "screen.orespawn.ore_source_detail": "Política de fuente de mineral", + "label.orespawn.ore_source.none": "No se encontraron fuentes de mineral equivalentes gestionadas.", + "label.orespawn.ore_source.row": "%s - %s - %s candidatos - %s", + "label.orespawn.ore_source.intro": "Elige cómo se generan los minerales equivalentes.", + "label.orespawn.ore_source.summary": "%s candidatos - %s", + "button.orespawn.ore_source.review": "Revisar...", + "button.orespawn.ore_source.configure": "Configurar...", + "label.orespawn.ore_source.material": "Materia: %s", + "label.orespawn.ore_source.domain": "Dimensión/dominio: %s", + "label.orespawn.ore_source.channels": "Canales de colocación: %s", + "label.orespawn.ore_source.missing": "Falta la fuente de colocación", + "label.orespawn.ore_source.owner": "Propietario: %s, versión %s", + "label.orespawn.ore_source.registry": "Salida registrada: %s, metadatos %s", + "label.orespawn.ore_source.dictionary": "Etiquetas de bloques: %s", + "option.orespawn.ore_source.mode": "Modo: %s", + "option.orespawn.ore_source.channel": "Canal: %s", + "option.orespawn.ore_source.placement": "Fuente de colocación: %s", + "option.orespawn.ore_source.weight": "Peso", + "mode.orespawn.ore_source.consolidated": "Consolidado", + "mode.orespawn.ore_source.keep_separate": "Mantener separado", + "status.orespawn.ore_source.consolidated": "Consolidado", + "status.orespawn.ore_source.separate": "Separado", + "status.orespawn.ore_source.review_required": "Revisión necesaria", + "status.orespawn.ore_source.missing_source": "Falta la fuente", + "status.orespawn.ore_source.external_generation": "Generación externa", + "tooltip.orespawn.ore_source.open": "Abre esta política de material.", + "tooltip.orespawn.ore_source.mode": "Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada.", + "tooltip.orespawn.ore_source.channel": "Los motores de colocación se arbitran de forma independiente.", + "tooltip.orespawn.ore_source.placement": "Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal.", + "tooltip.orespawn.ore_source.weight": "Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado.", + "tooltip.orespawn.ore_source.external": "Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controla esta colocación y fuente de salida configuradas.", + "tooltip.orespawn.ore_source.enrichment": "Esta entrada es enriquecimiento, no una fuente de mineral intercambiable.", + "tooltip.orespawn.ore_source.missing": "Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario.", + "error.orespawn.ore_source.weight": "Los pesos de salida deben ser números positivos.", + "button.orespawn.back": "Atrás", + "button.orespawn.ore_source.all_groups": "Todos los grupos", + "button.orespawn.ore_source.add_group": "Añadir grupo", + "button.orespawn.ore_source.accept": "Aceptar", + "tooltip.orespawn.ore_source.accept": "Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal.", + "button.orespawn.ore_source.delete_group": "Eliminar", + "button.orespawn.ore_source.dissolve_group": "Disolver grupo", + "button.orespawn.ore_source.confirm_dissolve": "Confirmar disolución", + "button.orespawn.ore_source.move": "Mover", + "button.orespawn.ore_source.advanced": "Avanzado...", + "button.orespawn.ore_source.hide_advanced": "Ocultar avanzado", + "option.orespawn.ore_source.group_name": "Nombre del grupo", + "option.orespawn.ore_source.alias": "Entrada del sistema de etiquetas de bloques", + "option.orespawn.ore_source.output_mode": "Salida: %s", + "mode.orespawn.ore_source.keep_original": "Mantener original", + "mode.orespawn.ore_source.balanced": "Equilibrado", + "mode.orespawn.ore_source.single": "Único", + "mode.orespawn.ore_source.custom": "Personalizado", + "label.orespawn.ore_source.no_aliases": "Sin entradas del sistema de etiquetas de bloques", + "status.orespawn.ore_source.external_short": "externo", + "status.orespawn.ore_source.missing_short": "ausente", + "status.orespawn.ore_source.output_only": "solo salida", + "tooltip.orespawn.ore_source.output_only": "Esta salida del proveedor está disponible, pero su regla de colocación está inactiva.", + "tooltip.orespawn.ore_source.output_mode": "Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas.", + "tooltip.orespawn.ore_source.alias": "Añade un nombre exacto de mineral del sistema de etiquetas de bloques. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo.", + "tooltip.orespawn.ore_source.reset_group": "Restaura el nombre y las entradas del sistema de etiquetas de bloques de este grupo seleccionado.", + "tooltip.orespawn.ore_source.delete_group": "Elimina este grupo personalizado vacío sin reescribir los chunks generados.", + "tooltip.orespawn.ore_source.dissolve_group": "Devuelve cada entrada a su grupo inferido o revisado. Pulsa dos veces para confirmar.", + "error.orespawn.ore_source.confirm_dissolve": "Pulsa Confirmar disolución para devolver cada entrada del sistema de etiquetas de bloques a su grupo original.", + "error.orespawn.ore_source.alias": "Usa un nombre exacto del sistema de etiquetas de bloques, como forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s ya posee esta entrada. Pulsa Mover para reasignarla.", + "error.orespawn.ore_source.manage_vanilla_first": "Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo.", + "error.orespawn.ore_source.defaults_write": "No se pudieron guardar los valores predeterminados de los grupos de materiales.", + "screen.orespawn.ore_source.group_settings": "Configuración del grupo de fuentes de mena", + "label.orespawn.ore_source.groups": "Grupos", + "label.orespawn.ore_source.aliases": "Entradas del sistema de etiquetas de bloques de menas", + "label.orespawn.ore_source.placement_rules": "Reglas de colocación", + "label.orespawn.ore_source.placement_explanation": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona.", + "label.orespawn.ore_source.standard_veins": "Vetas estándar", + "label.orespawn.ore_source.placement_choice_count": "%s (%s opciones)", + "label.orespawn.ore_source.no_placement_rules": "Sin reglas de colocación administradas", + "tooltip.orespawn.ore_source.group_settings": "Edita el nombre, los alias y las reglas de colocación de este grupo.", + "tooltip.orespawn.ore_source.remove_alias": "Eliminar %s de este grupo.", + "tooltip.orespawn.ore_source.placement_rules": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y restricciones de roca anfitriona.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "La selección de colocación solo se aplica a los modos consolidados Equilibrado, Único o Personalizado.", + "tooltip.orespawn.ore_source.placement_missing": "Falta la fuente de colocación guardada; este canal permanece desactivado.", + "tooltip.orespawn.ore_source.placement_readonly": "Solo hay una regla administrada activa; esta fuente es de solo lectura.", + "tooltip.orespawn.ore_source.placement_select": "Haz clic para elegir entre fuentes de colocación administradas activas.", + "tooltip.orespawn.ore_source.external_excluded": "Los generadores externos se informan, pero no pueden elegirse como reglas de colocación.", + "button.orespawn.ore_source.hide_single": "Ocultar individuales", + "tooltip.orespawn.ore_source.group_colours": "Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes.", + "button.orespawn.ore_source.reset_all": "Restablecer todo", + "button.orespawn.ore_source.confirm_reset_all": "Confirmar restablecimiento", + "tooltip.orespawn.ore_source.reset_all": "Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal.", + "screen.orespawn.biomes": "Biomas", + "screen.orespawn.biome_palettes": "Paletas de biomas", + "screen.orespawn.biome_palette_settings": "Ajustes de paleta de biomas", + "button.orespawn.biome.hide_routine": "Ocultar normales", + "button.orespawn.biome.replace_new_terrain": "Reemplazar en terreno nuevo por...", + "button.orespawn.biome.replace_with": "Reemplazar en terreno nuevo por %s...", + "button.orespawn.biome.leave_original": "Mantener el comportamiento original", + "button.orespawn.biome.palettes": "Paletas: %s", + "button.orespawn.biome.reset_selected": "Restablecer bioma", + "button.orespawn.biome.confirm_reset": "Confirmar restablecimiento", + "button.orespawn.biome.reset_palette": "Restablecer paleta", + "button.orespawn.biome.confirm_reset_palette": "Confirmar paleta", + "button.orespawn.biome.reset_dimension": "Restablecer dimensión", + "button.orespawn.biome.confirm_reset_dimension": "Confirmar dimensión", + "button.orespawn.biome.reset_all": "Restablecer todos los biomas", + "button.orespawn.biome.confirm_reset_all": "Confirmar todo", + "value.orespawn.biome.status.user_replaced": "Reemplazado en terreno nuevo", + "value.orespawn.biome.status.layered": "Varias reglas de colocación", + "value.orespawn.biome.status.disabled": "Colocación desactivada", + "value.orespawn.biome.status.missing": "Falta el bioma o proveedor", + "value.orespawn.biome.status.provider_managed": "Gestionado por proveedor", + "value.orespawn.biome.status.unmanaged": "Registrado, no gestionado", + "label.orespawn.biome.rule_count": "Reglas de colocación: %s", + "label.orespawn.biome.replaced_by": "Reemplazo en terreno nuevo: %s", + "label.orespawn.biome.palette_owner_order": "Propietario: %s; orden efectivo: %s", + "label.orespawn.biome.surface_effective": "Esta regla aporta la superficie efectiva.", + "label.orespawn.biome.surface_overridden": "Una regla activa posterior reemplaza esta superficie.", + "label.orespawn.biome.placement_rules": "Reglas de colocación", + "label.orespawn.biome.palette_entries": "Entradas de colocación: %s", + "tooltip.orespawn.biome.no_retrogen": "Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben.", + "tooltip.orespawn.biome.override_palette": "Capa final reservada para reemplazos. Edítala desde el control de reemplazo de cada bioma.", + "tooltip.orespawn.biome.palette_entries_read_only": "La pertenencia a la paleta la define el proveedor o perfil. Selecciona un bioma para editar una regla existente.", + "option.orespawn.biome.coverage": "Cobertura", + "option.orespawn.biome.fallback_weight": "Peso del bioma original", + "option.orespawn.biome.include_namespaces": "Espacios de nombres incluidos (separados por comas)", + "option.orespawn.biome.exclude_namespaces": "Espacios de nombres excluidos (separados por comas)" } diff --git a/src/main/resources/assets/orespawn/lang/fr_ca.json b/src/main/resources/assets/orespawn/lang/fr_ca.json index 748da466..08d8bdb9 100644 --- a/src/main/resources/assets/orespawn/lang/fr_ca.json +++ b/src/main/resources/assets/orespawn/lang/fr_ca.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Remplacez le bloc de surface sous-marine exposé du biome. Non défini conserve le bloc normal.", "tooltip.orespawn.biome.ceiling_block": "Remplacez le bloc de surface de plafond du biome dans les dimensions qui génèrent des plafonds. Non défini conserve le bloc normal.", "tooltip.orespawn.biome.filler_depth": "Nombre de calques sous le bloc supérieur qui utilisent le bloc de remplissage. Plage valide : 0 à 16.", - "tooltip.orespawn.material.default_fluid": "Choisissez le fluide aquifère normal utilisé sous le niveau de la mer. Non défini, conserve le fluide d'origine de Minecraft.", + "tooltip.orespawn.material.default_fluid": "Remplace le fluide aquifère normal sous le niveau de la mer dans chaque biome de cette dimension. Non défini conserve le fluide original du générateur. Les chunks existants restent inchangés; les fluides à éclairage inhabituel peuvent être générés plus lentement.", "tooltip.orespawn.material.deep_aquifer_fluid": "Indisponible dans Minecraft 1.14.4 ; la valeur enregistrée est conservée pour les versions ultérieures.", "tooltip.orespawn.material.deep_aquifer_y": "Indisponible dans Minecraft 1.14.4 ; la valeur enregistrée est conservée pour les versions ultérieures.", "tooltip.orespawn.material.snow_block": "Remplacez la neige vanille placée près de la surface dans cette dimension. Non pris, garde la neige normale.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choisir un biome installé", "screen.orespawn.biome_placement": "Paramètres du biome", "screen.orespawn.biome_references": "Biomes similaires", - "screen.orespawn.dimension_materials": "Matériaux du monde", + "screen.orespawn.dimension_materials": "Matériaux de %s", "button.orespawn.biomes_world_materials": "Biomes et matériaux du monde...", "button.orespawn.biome_palette_count": "Biomes : %s", "button.orespawn.dimension_materials": "Matériaux du monde...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Matériaux du monde", "guide.orespawn.materials.1": "Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.", "guide.orespawn.materials.2": "Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.", - "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft." + "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Matériaux de %s...", + "label.orespawn.dimension_materials.scope": "S'applique à chaque biome du nouveau terrain de %s.", + "button.orespawn.mods": "Répertoire des mods", + "button.orespawn.mod.configure": "Configurer", + "tooltip.orespawn.main.mods": "Afficher les mods chargés intégrés à OreSpawn.", + "screen.orespawn.mods": "Mods OreSpawn", + "tooltip.orespawn.mod.configure": "Ouvrir la configuration de %s.", + "tooltip.orespawn.mod.unavailable": "%s ne fournit pas d'écran de configuration lié à OreSpawn.", + "label.orespawn.mod.id_version": "%s - Version du mod %s", + "label.orespawn.mod.additional_lineage": "Également détecté via la compatibilité OreSpawn %s.", + "label.orespawn.mod.status": "État : %s", + "label.orespawn.mod.os4_version": "Fournisseur OS4 natif - schéma %s, révision du fournisseur %s", + "label.orespawn.mod.os4": "Fournisseur OS4 natif", + "label.orespawn.mod.legacy_version": "Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s", + "label.orespawn.mod.legacy": "Compatibilité OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Intégration de configuration client", + "label.orespawn.mod.none": "Aucune intégration OreSpawn chargée n'a été trouvée.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Actif", + "status.orespawn.mod.pending": "En attente", + "status.orespawn.mod.inactive": "Inactif", + "status.orespawn.mod.rejected": "Rejeté", + "status.orespawn.mod.config_only": "Écran de configuration uniquement", + "error.orespawn.mod.screen_missing": "%s n'a pas créé d'écran de configuration.", + "error.orespawn.mod.screen_failed": "Impossible d'ouvrir la configuration de %s (%s).", + "button.orespawn.ore_sources": "Sources de minerai...", + "tooltip.orespawn.ore_sources": "Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement.", + "screen.orespawn.ore_sources": "Sources de minerai", + "screen.orespawn.ore_source_detail": "Politique de source de minerai", + "label.orespawn.ore_source.none": "Aucune source de minerai gérée équivalente n'a été trouvée.", + "label.orespawn.ore_source.row": "%s - %s - %s candidats - %s", + "label.orespawn.ore_source.intro": "Choisissez comment les minerais équivalents sont générés.", + "label.orespawn.ore_source.summary": "%s candidats - %s", + "button.orespawn.ore_source.review": "Examiner...", + "button.orespawn.ore_source.configure": "Configurer...", + "label.orespawn.ore_source.material": "Matériau : %s", + "label.orespawn.ore_source.domain": "Dimension/domaine : %s", + "label.orespawn.ore_source.channels": "Canaux de placement : %s", + "label.orespawn.ore_source.missing": "Source de placement manquante", + "label.orespawn.ore_source.owner": "Propriétaire : %s, version %s", + "label.orespawn.ore_source.registry": "Sortie enregistrée : %s, métadonnées %s", + "label.orespawn.ore_source.dictionary": "Tags de blocs : %s", + "option.orespawn.ore_source.mode": "Mode : %s", + "option.orespawn.ore_source.channel": "Canal : %s", + "option.orespawn.ore_source.placement": "Source de placement : %s", + "option.orespawn.ore_source.weight": "Poids", + "mode.orespawn.ore_source.consolidated": "Consolidé", + "mode.orespawn.ore_source.keep_separate": "Garder séparé", + "status.orespawn.ore_source.consolidated": "Consolidé", + "status.orespawn.ore_source.separate": "Séparé", + "status.orespawn.ore_source.review_required": "Révision requise", + "status.orespawn.ore_source.missing_source": "Source manquante", + "status.orespawn.ore_source.external_generation": "Génération externe", + "tooltip.orespawn.ore_source.open": "Ouvrir cette politique de matériau.", + "tooltip.orespawn.ore_source.mode": "Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée.", + "tooltip.orespawn.ore_source.channel": "Les moteurs de placement sont arbitrés indépendamment.", + "tooltip.orespawn.ore_source.placement": "Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal.", + "tooltip.orespawn.ore_source.weight": "Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié.", + "tooltip.orespawn.ore_source.external": "Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn contrôle cette source de placement et de sortie configurée.", + "tooltip.orespawn.ore_source.enrichment": "Cette entrée est un enrichissement, pas une source de minerai interchangeable.", + "tooltip.orespawn.ore_source.missing": "Cette source est indisponible. Le choix enregistré sera restauré si son mod revient.", + "error.orespawn.ore_source.weight": "Les poids de sortie doivent être des nombres positifs.", + "button.orespawn.back": "Retour", + "button.orespawn.ore_source.all_groups": "Tous les groupes", + "button.orespawn.ore_source.add_group": "Ajouter un groupe", + "button.orespawn.ore_source.accept": "Accepter", + "tooltip.orespawn.ore_source.accept": "Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal.", + "button.orespawn.ore_source.delete_group": "Supprimer", + "button.orespawn.ore_source.dissolve_group": "Dissoudre le groupe", + "button.orespawn.ore_source.confirm_dissolve": "Confirmer la dissolution", + "button.orespawn.ore_source.move": "Déplacer", + "button.orespawn.ore_source.advanced": "Avancé...", + "button.orespawn.ore_source.hide_advanced": "Masquer les options avancées", + "option.orespawn.ore_source.group_name": "Nom du groupe", + "option.orespawn.ore_source.alias": "Entrée du système de tags de blocs", + "option.orespawn.ore_source.output_mode": "Sortie : %s", + "mode.orespawn.ore_source.keep_original": "Conserver l'origine", + "mode.orespawn.ore_source.balanced": "Équilibré", + "mode.orespawn.ore_source.single": "Unique", + "mode.orespawn.ore_source.custom": "Personnalisé", + "label.orespawn.ore_source.no_aliases": "Aucune entrée du système de tags de blocs", + "status.orespawn.ore_source.external_short": "externe", + "status.orespawn.ore_source.missing_short": "absente", + "status.orespawn.ore_source.output_only": "sortie uniquement", + "tooltip.orespawn.ore_source.output_only": "Cette sortie de fournisseur est disponible, mais sa règle de placement est inactive.", + "tooltip.orespawn.ore_source.output_mode": "Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés.", + "tooltip.orespawn.ore_source.alias": "Ajoutez un nom exact de minerai du système de tags de blocs. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe.", + "tooltip.orespawn.ore_source.reset_group": "Restaure le nom et les entrées du système de tags de blocs de ce groupe sélectionné.", + "tooltip.orespawn.ore_source.delete_group": "Supprime ce groupe personnalisé vide sans réécrire les chunks générés.", + "tooltip.orespawn.ore_source.dissolve_group": "Replace chaque entrée dans son groupe déduit ou prédéfini. Appuyez deux fois pour confirmer.", + "error.orespawn.ore_source.confirm_dissolve": "Appuyez sur Confirmer la dissolution pour replacer chaque entrée du système de tags de blocs dans son groupe d'origine.", + "error.orespawn.ore_source.alias": "Utilisez un nom exact du système de tags de blocs, comme forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter.", + "error.orespawn.ore_source.manage_vanilla_first": "Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe.", + "error.orespawn.ore_source.defaults_write": "Impossible d'enregistrer les valeurs par défaut des groupes de matériaux.", + "screen.orespawn.ore_source.group_settings": "Paramètres du groupe de sources de minerai", + "label.orespawn.ore_source.groups": "Groupes", + "label.orespawn.ore_source.aliases": "Entrées du système de tags de blocs", + "label.orespawn.ore_source.placement_rules": "Règles de placement", + "label.orespawn.ore_source.placement_explanation": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes.", + "label.orespawn.ore_source.standard_veins": "Veines standard", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choix)", + "label.orespawn.ore_source.no_placement_rules": "Aucune règle de placement gérée", + "tooltip.orespawn.ore_source.group_settings": "Modifier le nom, les alias et les règles de placement de ce groupe.", + "tooltip.orespawn.ore_source.remove_alias": "Retirer %s de ce groupe.", + "tooltip.orespawn.ore_source.placement_rules": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et restrictions d'hôte.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Le propriétaire du placement s'applique seulement aux modes consolidés Équilibré, Unique ou Personnalisé.", + "tooltip.orespawn.ore_source.placement_missing": "La source de placement enregistrée manque; ce canal reste désactivé.", + "tooltip.orespawn.ore_source.placement_readonly": "Une seule règle gérée active est disponible; cette source est en lecture seule.", + "tooltip.orespawn.ore_source.placement_select": "Cliquez pour choisir entre les sources de placement gérées actives.", + "tooltip.orespawn.ore_source.external_excluded": "Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement.", + "button.orespawn.ore_source.hide_single": "Masquer les entrées simples", + "tooltip.orespawn.ore_source.group_colours": "Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes.", + "button.orespawn.ore_source.reset_all": "Tout réinitialiser", + "button.orespawn.ore_source.confirm_reset_all": "Confirmer la réinitialisation", + "tooltip.orespawn.ore_source.reset_all": "Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Palettes de biomes", + "screen.orespawn.biome_palette_settings": "Paramètres de palette de biomes", + "button.orespawn.biome.hide_routine": "Masquer les ordinaires", + "button.orespawn.biome.replace_new_terrain": "Remplacer dans le nouveau terrain par...", + "button.orespawn.biome.replace_with": "Remplacer dans le nouveau terrain par %s...", + "button.orespawn.biome.leave_original": "Conserver le comportement d'origine", + "button.orespawn.biome.palettes": "Palettes : %s", + "button.orespawn.biome.reset_selected": "Réinitialiser le biome", + "button.orespawn.biome.confirm_reset": "Confirmer la réinitialisation", + "button.orespawn.biome.reset_palette": "Réinitialiser la palette", + "button.orespawn.biome.confirm_reset_palette": "Confirmer la palette", + "button.orespawn.biome.reset_dimension": "Réinitialiser la dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirmer la dimension", + "button.orespawn.biome.reset_all": "Réinitialiser tous les biomes", + "button.orespawn.biome.confirm_reset_all": "Tout confirmer", + "value.orespawn.biome.status.user_replaced": "Remplacé dans le nouveau terrain", + "value.orespawn.biome.status.layered": "Plusieurs règles de placement", + "value.orespawn.biome.status.disabled": "Placement désactivé", + "value.orespawn.biome.status.missing": "Biome ou fournisseur manquant", + "value.orespawn.biome.status.provider_managed": "Géré par le fournisseur", + "value.orespawn.biome.status.unmanaged": "Enregistré, non géré", + "label.orespawn.biome.rule_count": "Règles de placement : %s", + "label.orespawn.biome.replaced_by": "Remplacement du nouveau terrain : %s", + "label.orespawn.biome.palette_owner_order": "Propriétaire : %s ; ordre effectif : %s", + "label.orespawn.biome.surface_effective": "Cette règle fournit la surface effective.", + "label.orespawn.biome.surface_overridden": "Une règle active ultérieure remplace cette surface.", + "label.orespawn.biome.placement_rules": "Règles de placement", + "label.orespawn.biome.palette_entries": "Entrées de placement : %s", + "tooltip.orespawn.biome.no_retrogen": "Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits.", + "tooltip.orespawn.biome.override_palette": "Couche finale réservée aux remplacements. Modifiez-la avec le contrôle de remplacement de chaque biome.", + "tooltip.orespawn.biome.palette_entries_read_only": "L'appartenance à la palette est définie par le fournisseur ou le profil. Sélectionnez un biome pour modifier une règle existante.", + "option.orespawn.biome.coverage": "Couverture", + "option.orespawn.biome.fallback_weight": "Poids du biome d'origine", + "option.orespawn.biome.include_namespaces": "Espaces de noms inclus (séparés par des virgules)", + "option.orespawn.biome.exclude_namespaces": "Espaces de noms exclus (séparés par des virgules)" } diff --git a/src/main/resources/assets/orespawn/lang/fr_fr.json b/src/main/resources/assets/orespawn/lang/fr_fr.json index 748da466..08d8bdb9 100644 --- a/src/main/resources/assets/orespawn/lang/fr_fr.json +++ b/src/main/resources/assets/orespawn/lang/fr_fr.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Remplacez le bloc de surface sous-marine exposé du biome. Non défini conserve le bloc normal.", "tooltip.orespawn.biome.ceiling_block": "Remplacez le bloc de surface de plafond du biome dans les dimensions qui génèrent des plafonds. Non défini conserve le bloc normal.", "tooltip.orespawn.biome.filler_depth": "Nombre de calques sous le bloc supérieur qui utilisent le bloc de remplissage. Plage valide : 0 à 16.", - "tooltip.orespawn.material.default_fluid": "Choisissez le fluide aquifère normal utilisé sous le niveau de la mer. Non défini, conserve le fluide d'origine de Minecraft.", + "tooltip.orespawn.material.default_fluid": "Remplace le fluide aquifère normal sous le niveau de la mer dans chaque biome de cette dimension. Non défini conserve le fluide original du générateur. Les chunks existants restent inchangés; les fluides à éclairage inhabituel peuvent être générés plus lentement.", "tooltip.orespawn.material.deep_aquifer_fluid": "Indisponible dans Minecraft 1.14.4 ; la valeur enregistrée est conservée pour les versions ultérieures.", "tooltip.orespawn.material.deep_aquifer_y": "Indisponible dans Minecraft 1.14.4 ; la valeur enregistrée est conservée pour les versions ultérieures.", "tooltip.orespawn.material.snow_block": "Remplacez la neige vanille placée près de la surface dans cette dimension. Non pris, garde la neige normale.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choisir un biome installé", "screen.orespawn.biome_placement": "Paramètres du biome", "screen.orespawn.biome_references": "Biomes similaires", - "screen.orespawn.dimension_materials": "Matériaux du monde", + "screen.orespawn.dimension_materials": "Matériaux de %s", "button.orespawn.biomes_world_materials": "Biomes et matériaux du monde...", "button.orespawn.biome_palette_count": "Biomes : %s", "button.orespawn.dimension_materials": "Matériaux du monde...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Matériaux du monde", "guide.orespawn.materials.1": "Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.", "guide.orespawn.materials.2": "Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.", - "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft." + "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Matériaux de %s...", + "label.orespawn.dimension_materials.scope": "S'applique à chaque biome du nouveau terrain de %s.", + "button.orespawn.mods": "Répertoire des mods", + "button.orespawn.mod.configure": "Configurer", + "tooltip.orespawn.main.mods": "Afficher les mods chargés intégrés à OreSpawn.", + "screen.orespawn.mods": "Mods OreSpawn", + "tooltip.orespawn.mod.configure": "Ouvrir la configuration de %s.", + "tooltip.orespawn.mod.unavailable": "%s ne fournit pas d'écran de configuration lié à OreSpawn.", + "label.orespawn.mod.id_version": "%s - Version du mod %s", + "label.orespawn.mod.additional_lineage": "Également détecté via la compatibilité OreSpawn %s.", + "label.orespawn.mod.status": "État : %s", + "label.orespawn.mod.os4_version": "Fournisseur OS4 natif - schéma %s, révision du fournisseur %s", + "label.orespawn.mod.os4": "Fournisseur OS4 natif", + "label.orespawn.mod.legacy_version": "Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s", + "label.orespawn.mod.legacy": "Compatibilité OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Intégration de configuration client", + "label.orespawn.mod.none": "Aucune intégration OreSpawn chargée n'a été trouvée.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Actif", + "status.orespawn.mod.pending": "En attente", + "status.orespawn.mod.inactive": "Inactif", + "status.orespawn.mod.rejected": "Rejeté", + "status.orespawn.mod.config_only": "Écran de configuration uniquement", + "error.orespawn.mod.screen_missing": "%s n'a pas créé d'écran de configuration.", + "error.orespawn.mod.screen_failed": "Impossible d'ouvrir la configuration de %s (%s).", + "button.orespawn.ore_sources": "Sources de minerai...", + "tooltip.orespawn.ore_sources": "Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement.", + "screen.orespawn.ore_sources": "Sources de minerai", + "screen.orespawn.ore_source_detail": "Politique de source de minerai", + "label.orespawn.ore_source.none": "Aucune source de minerai gérée équivalente n'a été trouvée.", + "label.orespawn.ore_source.row": "%s - %s - %s candidats - %s", + "label.orespawn.ore_source.intro": "Choisissez comment les minerais équivalents sont générés.", + "label.orespawn.ore_source.summary": "%s candidats - %s", + "button.orespawn.ore_source.review": "Examiner...", + "button.orespawn.ore_source.configure": "Configurer...", + "label.orespawn.ore_source.material": "Matériau : %s", + "label.orespawn.ore_source.domain": "Dimension/domaine : %s", + "label.orespawn.ore_source.channels": "Canaux de placement : %s", + "label.orespawn.ore_source.missing": "Source de placement manquante", + "label.orespawn.ore_source.owner": "Propriétaire : %s, version %s", + "label.orespawn.ore_source.registry": "Sortie enregistrée : %s, métadonnées %s", + "label.orespawn.ore_source.dictionary": "Tags de blocs : %s", + "option.orespawn.ore_source.mode": "Mode : %s", + "option.orespawn.ore_source.channel": "Canal : %s", + "option.orespawn.ore_source.placement": "Source de placement : %s", + "option.orespawn.ore_source.weight": "Poids", + "mode.orespawn.ore_source.consolidated": "Consolidé", + "mode.orespawn.ore_source.keep_separate": "Garder séparé", + "status.orespawn.ore_source.consolidated": "Consolidé", + "status.orespawn.ore_source.separate": "Séparé", + "status.orespawn.ore_source.review_required": "Révision requise", + "status.orespawn.ore_source.missing_source": "Source manquante", + "status.orespawn.ore_source.external_generation": "Génération externe", + "tooltip.orespawn.ore_source.open": "Ouvrir cette politique de matériau.", + "tooltip.orespawn.ore_source.mode": "Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée.", + "tooltip.orespawn.ore_source.channel": "Les moteurs de placement sont arbitrés indépendamment.", + "tooltip.orespawn.ore_source.placement": "Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal.", + "tooltip.orespawn.ore_source.weight": "Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié.", + "tooltip.orespawn.ore_source.external": "Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn contrôle cette source de placement et de sortie configurée.", + "tooltip.orespawn.ore_source.enrichment": "Cette entrée est un enrichissement, pas une source de minerai interchangeable.", + "tooltip.orespawn.ore_source.missing": "Cette source est indisponible. Le choix enregistré sera restauré si son mod revient.", + "error.orespawn.ore_source.weight": "Les poids de sortie doivent être des nombres positifs.", + "button.orespawn.back": "Retour", + "button.orespawn.ore_source.all_groups": "Tous les groupes", + "button.orespawn.ore_source.add_group": "Ajouter un groupe", + "button.orespawn.ore_source.accept": "Accepter", + "tooltip.orespawn.ore_source.accept": "Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal.", + "button.orespawn.ore_source.delete_group": "Supprimer", + "button.orespawn.ore_source.dissolve_group": "Dissoudre le groupe", + "button.orespawn.ore_source.confirm_dissolve": "Confirmer la dissolution", + "button.orespawn.ore_source.move": "Déplacer", + "button.orespawn.ore_source.advanced": "Avancé...", + "button.orespawn.ore_source.hide_advanced": "Masquer les options avancées", + "option.orespawn.ore_source.group_name": "Nom du groupe", + "option.orespawn.ore_source.alias": "Entrée du système de tags de blocs", + "option.orespawn.ore_source.output_mode": "Sortie : %s", + "mode.orespawn.ore_source.keep_original": "Conserver l'origine", + "mode.orespawn.ore_source.balanced": "Équilibré", + "mode.orespawn.ore_source.single": "Unique", + "mode.orespawn.ore_source.custom": "Personnalisé", + "label.orespawn.ore_source.no_aliases": "Aucune entrée du système de tags de blocs", + "status.orespawn.ore_source.external_short": "externe", + "status.orespawn.ore_source.missing_short": "absente", + "status.orespawn.ore_source.output_only": "sortie uniquement", + "tooltip.orespawn.ore_source.output_only": "Cette sortie de fournisseur est disponible, mais sa règle de placement est inactive.", + "tooltip.orespawn.ore_source.output_mode": "Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés.", + "tooltip.orespawn.ore_source.alias": "Ajoutez un nom exact de minerai du système de tags de blocs. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe.", + "tooltip.orespawn.ore_source.reset_group": "Restaure le nom et les entrées du système de tags de blocs de ce groupe sélectionné.", + "tooltip.orespawn.ore_source.delete_group": "Supprime ce groupe personnalisé vide sans réécrire les chunks générés.", + "tooltip.orespawn.ore_source.dissolve_group": "Replace chaque entrée dans son groupe déduit ou prédéfini. Appuyez deux fois pour confirmer.", + "error.orespawn.ore_source.confirm_dissolve": "Appuyez sur Confirmer la dissolution pour replacer chaque entrée du système de tags de blocs dans son groupe d'origine.", + "error.orespawn.ore_source.alias": "Utilisez un nom exact du système de tags de blocs, comme forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter.", + "error.orespawn.ore_source.manage_vanilla_first": "Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe.", + "error.orespawn.ore_source.defaults_write": "Impossible d'enregistrer les valeurs par défaut des groupes de matériaux.", + "screen.orespawn.ore_source.group_settings": "Paramètres du groupe de sources de minerai", + "label.orespawn.ore_source.groups": "Groupes", + "label.orespawn.ore_source.aliases": "Entrées du système de tags de blocs", + "label.orespawn.ore_source.placement_rules": "Règles de placement", + "label.orespawn.ore_source.placement_explanation": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes.", + "label.orespawn.ore_source.standard_veins": "Veines standard", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choix)", + "label.orespawn.ore_source.no_placement_rules": "Aucune règle de placement gérée", + "tooltip.orespawn.ore_source.group_settings": "Modifier le nom, les alias et les règles de placement de ce groupe.", + "tooltip.orespawn.ore_source.remove_alias": "Retirer %s de ce groupe.", + "tooltip.orespawn.ore_source.placement_rules": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et restrictions d'hôte.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Le propriétaire du placement s'applique seulement aux modes consolidés Équilibré, Unique ou Personnalisé.", + "tooltip.orespawn.ore_source.placement_missing": "La source de placement enregistrée manque; ce canal reste désactivé.", + "tooltip.orespawn.ore_source.placement_readonly": "Une seule règle gérée active est disponible; cette source est en lecture seule.", + "tooltip.orespawn.ore_source.placement_select": "Cliquez pour choisir entre les sources de placement gérées actives.", + "tooltip.orespawn.ore_source.external_excluded": "Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement.", + "button.orespawn.ore_source.hide_single": "Masquer les entrées simples", + "tooltip.orespawn.ore_source.group_colours": "Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes.", + "button.orespawn.ore_source.reset_all": "Tout réinitialiser", + "button.orespawn.ore_source.confirm_reset_all": "Confirmer la réinitialisation", + "tooltip.orespawn.ore_source.reset_all": "Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Palettes de biomes", + "screen.orespawn.biome_palette_settings": "Paramètres de palette de biomes", + "button.orespawn.biome.hide_routine": "Masquer les ordinaires", + "button.orespawn.biome.replace_new_terrain": "Remplacer dans le nouveau terrain par...", + "button.orespawn.biome.replace_with": "Remplacer dans le nouveau terrain par %s...", + "button.orespawn.biome.leave_original": "Conserver le comportement d'origine", + "button.orespawn.biome.palettes": "Palettes : %s", + "button.orespawn.biome.reset_selected": "Réinitialiser le biome", + "button.orespawn.biome.confirm_reset": "Confirmer la réinitialisation", + "button.orespawn.biome.reset_palette": "Réinitialiser la palette", + "button.orespawn.biome.confirm_reset_palette": "Confirmer la palette", + "button.orespawn.biome.reset_dimension": "Réinitialiser la dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirmer la dimension", + "button.orespawn.biome.reset_all": "Réinitialiser tous les biomes", + "button.orespawn.biome.confirm_reset_all": "Tout confirmer", + "value.orespawn.biome.status.user_replaced": "Remplacé dans le nouveau terrain", + "value.orespawn.biome.status.layered": "Plusieurs règles de placement", + "value.orespawn.biome.status.disabled": "Placement désactivé", + "value.orespawn.biome.status.missing": "Biome ou fournisseur manquant", + "value.orespawn.biome.status.provider_managed": "Géré par le fournisseur", + "value.orespawn.biome.status.unmanaged": "Enregistré, non géré", + "label.orespawn.biome.rule_count": "Règles de placement : %s", + "label.orespawn.biome.replaced_by": "Remplacement du nouveau terrain : %s", + "label.orespawn.biome.palette_owner_order": "Propriétaire : %s ; ordre effectif : %s", + "label.orespawn.biome.surface_effective": "Cette règle fournit la surface effective.", + "label.orespawn.biome.surface_overridden": "Une règle active ultérieure remplace cette surface.", + "label.orespawn.biome.placement_rules": "Règles de placement", + "label.orespawn.biome.palette_entries": "Entrées de placement : %s", + "tooltip.orespawn.biome.no_retrogen": "Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits.", + "tooltip.orespawn.biome.override_palette": "Couche finale réservée aux remplacements. Modifiez-la avec le contrôle de remplacement de chaque biome.", + "tooltip.orespawn.biome.palette_entries_read_only": "L'appartenance à la palette est définie par le fournisseur ou le profil. Sélectionnez un biome pour modifier une règle existante.", + "option.orespawn.biome.coverage": "Couverture", + "option.orespawn.biome.fallback_weight": "Poids du biome d'origine", + "option.orespawn.biome.include_namespaces": "Espaces de noms inclus (séparés par des virgules)", + "option.orespawn.biome.exclude_namespaces": "Espaces de noms exclus (séparés par des virgules)" } diff --git a/src/main/resources/assets/orespawn/lang/ja_jp.json b/src/main/resources/assets/orespawn/lang/ja_jp.json index d6c95e79..aea08a9e 100644 --- a/src/main/resources/assets/orespawn/lang/ja_jp.json +++ b/src/main/resources/assets/orespawn/lang/ja_jp.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "バイオームの露出した水中表面ブロックを交換します。設定しない場合は、通常のブロックが維持されます。", "tooltip.orespawn.biome.ceiling_block": "バイオームの天井面ブロックを天井を生成する寸法に置き換えます。設定しない場合は、通常のブロックが保持されます。", "tooltip.orespawn.biome.filler_depth": "フィラー ブロックを使用する最上位ブロックの下のレイヤーの数。有効な範囲: 0 ~ 16。", - "tooltip.orespawn.material.default_fluid": "海面以下で使用される通常の帯水層流体を選択します。設定しない場合は、Minecraft の元の流体が保持されます。", + "tooltip.orespawn.material.default_fluid": "このディメンションの全バイオームで海面下の通常の帯水層流体を置換します。未設定ではジェネレーター本来の流体を維持します。既存チャンクは変更されず、特殊な照明の流体は生成が遅くなる場合があります。", "tooltip.orespawn.material.deep_aquifer_fluid": "Minecraft 1.14.4 では使用できません。保存済みの値は新しいバージョン向けに保持されます。", "tooltip.orespawn.material.deep_aquifer_y": "Minecraft 1.14.4 では使用できません。保存済みの値は新しいバージョン向けに保持されます。", "tooltip.orespawn.material.snow_block": "このディメンションのサーフェス近くに配置されたバニラ スノーを置き換えます。設定しない場合は通常の雪のままです。", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "導入済みバイオームを選択", "screen.orespawn.biome_placement": "バイオーム設定", "screen.orespawn.biome_references": "類似バイオーム", - "screen.orespawn.dimension_materials": "ワールド素材", + "screen.orespawn.dimension_materials": "%s の素材", "button.orespawn.biomes_world_materials": "バイオームとワールド素材...", "button.orespawn.biome_palette_count": "バイオーム: %s", "button.orespawn.dimension_materials": "ワールド素材...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "ワールド素材", "guide.orespawn.materials.1": "ワールド素材はディメンション全体の基本素材を変更します。ときどき現れる地下ポケットだけが欲しい場合は流体鉱床を使います。", "guide.orespawn.materials.2": "帯水層の流体は海面より下で使う通常の流体を変更します。深部帯水層は選んだYより下で2種類目の流体を使います。", - "guide.orespawn.materials.3": "雪と氷は地表付近のバニラの雪と通常の氷を置き換えます。「未設定」ならMinecraft標準の素材を変えません。" + "guide.orespawn.materials.3": "雪と氷は地表付近のバニラの雪と通常の氷を置き換えます。「未設定」ならMinecraft標準の素材を変えません。", + "button.orespawn.dimension_materials_named": "%s の素材...", + "label.orespawn.dimension_materials.scope": "新しく生成される %s 地形のすべてのバイオームに適用されます。", + "button.orespawn.mods": "MOD一覧", + "button.orespawn.mod.configure": "設定", + "tooltip.orespawn.main.mods": "OreSpawnと連携している読み込み済みMODを表示します。", + "screen.orespawn.mods": "OreSpawn MOD", + "tooltip.orespawn.mod.configure": "%sの設定を開きます。", + "tooltip.orespawn.mod.unavailable": "%sにはOreSpawn連携設定画面がありません。", + "label.orespawn.mod.id_version": "%s - MODバージョン %s", + "label.orespawn.mod.additional_lineage": "OreSpawn %s互換としても検出されました。", + "label.orespawn.mod.status": "状態:%s", + "label.orespawn.mod.os4_version": "ネイティブOS4 - スキーマ%s、プロバイダーリビジョン%s", + "label.orespawn.mod.os4": "ネイティブOS4プロバイダー", + "label.orespawn.mod.legacy_version": "OreSpawn %s互換 -> OS4スキーマ%s、プロバイダーリビジョン%s", + "label.orespawn.mod.legacy": "OreSpawn %s互換 -> OS4", + "label.orespawn.mod.config_only": "クライアント設定連携", + "label.orespawn.mod.none": "読み込み済みのOreSpawn連携は見つかりませんでした。", + "label.orespawn.mod.integration_status": "%s|%s", + "status.orespawn.mod.active": "有効", + "status.orespawn.mod.pending": "保留中", + "status.orespawn.mod.inactive": "無効", + "status.orespawn.mod.rejected": "拒否", + "status.orespawn.mod.config_only": "設定画面のみ", + "error.orespawn.mod.screen_missing": "%sは設定画面を作成しませんでした。", + "error.orespawn.mod.screen_failed": "%sの設定を開けませんでした(%s)。", + "button.orespawn.ore_sources": "鉱石ソース...", + "tooltip.orespawn.ore_sources": "配置頻度を増やさずに同等の鉱石出力を確認します。", + "screen.orespawn.ore_sources": "鉱石ソース", + "screen.orespawn.ore_source_detail": "鉱石ソース方針", + "label.orespawn.ore_source.none": "同等の管理対象鉱石ソースは見つかりませんでした。", + "label.orespawn.ore_source.row": "%s - %s - 候補 %s 件 - %s", + "label.orespawn.ore_source.intro": "同等の鉱石の生成方法を選択します。", + "label.orespawn.ore_source.summary": "候補 %s 件 - %s", + "button.orespawn.ore_source.review": "確認...", + "button.orespawn.ore_source.configure": "設定...", + "label.orespawn.ore_source.material": "素材: %s", + "label.orespawn.ore_source.domain": "ディメンション/領域: %s", + "label.orespawn.ore_source.channels": "配置チャンネル: %s", + "label.orespawn.ore_source.missing": "配置ソースがありません", + "label.orespawn.ore_source.owner": "所有元: %s、バージョン %s", + "label.orespawn.ore_source.registry": "登録出力: %s、メタデータ %s", + "label.orespawn.ore_source.dictionary": "ブロックタグ: %s", + "option.orespawn.ore_source.mode": "モード: %s", + "option.orespawn.ore_source.channel": "チャンネル: %s", + "option.orespawn.ore_source.placement": "配置ソース: %s", + "option.orespawn.ore_source.weight": "重み", + "mode.orespawn.ore_source.consolidated": "統合", + "mode.orespawn.ore_source.keep_separate": "個別に保持", + "status.orespawn.ore_source.consolidated": "統合", + "status.orespawn.ore_source.separate": "個別", + "status.orespawn.ore_source.review_required": "確認が必要", + "status.orespawn.ore_source.missing_source": "ソースがありません", + "status.orespawn.ore_source.external_generation": "外部生成", + "tooltip.orespawn.ore_source.open": "この素材の方針を開きます。", + "tooltip.orespawn.ore_source.mode": "統合はチャンネルごとに1つの配置枠を使います。個別に保持は設定済みの各ルールを維持します。", + "tooltip.orespawn.ore_source.channel": "配置エンジンは個別に調整されます。", + "tooltip.orespawn.ore_source.placement": "このチャンネルの頻度、形状、母岩、フィルター、高さを決めるルールを選びます。", + "tooltip.orespawn.ore_source.weight": "鉱脈全体または識別済み鉱床全体でこの出力を選ぶ正の相対確率です。", + "tooltip.orespawn.ore_source.external": "このModはOreSpawn外で生成を登録しています。OreSpawnは表示できますが抑止できません。", + "tooltip.orespawn.ore_source.controlled": "OreSpawnがこの設定済み配置と出力ソースを制御します。", + "tooltip.orespawn.ore_source.enrichment": "この項目は富化用であり、交換可能な鉱石ソースではありません。", + "tooltip.orespawn.ore_source.missing": "このソースは利用できません。所有Modが戻ると保存済み選択が復元されます。", + "error.orespawn.ore_source.weight": "出力の重みは正の数である必要があります。", + "button.orespawn.back": "戻る", + "button.orespawn.ore_source.all_groups": "すべてのグループ", + "button.orespawn.ore_source.add_group": "グループを追加", + "button.orespawn.ore_source.accept": "承認", + "tooltip.orespawn.ore_source.accept": "「元のまま」を変更せず、このグループを確認済みにします。保存されるのはメインエディターで「完了」を押したときだけです。", + "button.orespawn.ore_source.delete_group": "削除", + "button.orespawn.ore_source.dissolve_group": "グループを解散", + "button.orespawn.ore_source.confirm_dissolve": "解散を確認", + "button.orespawn.ore_source.move": "移動", + "button.orespawn.ore_source.advanced": "詳細...", + "button.orespawn.ore_source.hide_advanced": "詳細を隠す", + "option.orespawn.ore_source.group_name": "グループ名", + "option.orespawn.ore_source.alias": "ブロックタグエントリ", + "option.orespawn.ore_source.output_mode": "出力: %s", + "mode.orespawn.ore_source.keep_original": "元の設定を維持", + "mode.orespawn.ore_source.balanced": "均等", + "mode.orespawn.ore_source.single": "単一", + "mode.orespawn.ore_source.custom": "カスタム", + "label.orespawn.ore_source.no_aliases": "ブロックタグエントリなし", + "status.orespawn.ore_source.external_short": "外部", + "status.orespawn.ore_source.missing_short": "不足", + "status.orespawn.ore_source.output_only": "出力のみ", + "tooltip.orespawn.ore_source.output_only": "このプロバイダー出力は利用できますが、配置ルールは無効です。", + "tooltip.orespawn.ore_source.output_mode": "元の設定を維持は各ルールを保持します。均等は同じ比率、単一は1ブロック、カスタムは重み付き選択を使用します。", + "tooltip.orespawn.ore_source.alias": "ブロックタグの正確な鉱石名を追加します。別グループからの再割り当てを確定するには、もう一度「移動」を押します。", + "tooltip.orespawn.ore_source.reset_group": "この既定グループの名前とブロックタグエントリを復元します。", + "tooltip.orespawn.ore_source.delete_group": "生成済みチャンクを書き換えずに、この空のカスタムグループを削除します。", + "tooltip.orespawn.ore_source.dissolve_group": "各項目を推定または既定のグループへ戻します。2回押すと確定します。", + "error.orespawn.ore_source.confirm_dissolve": "「解散を確認」を押すと、すべてのブロックタグ項目を元のグループへ戻します。", + "error.orespawn.ore_source.alias": "forge:ores/sulfur のような正確なブロックタグ名を使用してください。", + "error.orespawn.ore_source.alias_owned": "%s がこのエントリを既に所有しています。再割り当てするには「移動」を押してください。", + "error.orespawn.ore_source.manage_vanilla_first": "バニラ鉱石を別のグループへ移動する前に、「バニラ鉱石を管理」を有効にしてください。", + "error.orespawn.ore_source.defaults_write": "素材グループの既定値を保存できませんでした。", + "screen.orespawn.ore_source.group_settings": "鉱石ソースグループ設定", + "label.orespawn.ore_source.groups": "グループ", + "label.orespawn.ore_source.aliases": "ブロックタグエントリ", + "label.orespawn.ore_source.placement_rules": "配置ルール", + "label.orespawn.ore_source.placement_explanation": "出力はブロックを、配置ルールは頻度、形状、深さ、母岩を選びます。", + "label.orespawn.ore_source.standard_veins": "標準鉱脈", + "label.orespawn.ore_source.placement_choice_count": "%s(%s 個の候補)", + "label.orespawn.ore_source.no_placement_rules": "管理対象の配置ルールはありません", + "tooltip.orespawn.ore_source.group_settings": "このグループの名前、別名、配置ルールを編集します。", + "tooltip.orespawn.ore_source.remove_alias": "%s をこのグループから削除します。", + "tooltip.orespawn.ore_source.placement_rules": "出力は鉱石ブロックを、配置ルールは頻度、形状、深さ、母岩制限を選びます。", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "配置元の選択は統合された均等、単一、カスタムモードだけに適用されます。", + "tooltip.orespawn.ore_source.placement_missing": "保存された配置元がありません。このチャンネルは無効のままです。", + "tooltip.orespawn.ore_source.placement_readonly": "有効な管理ルールは1つだけなので、このソースは読み取り専用です。", + "tooltip.orespawn.ore_source.placement_select": "クリックして有効な管理対象配置元を選択します。", + "tooltip.orespawn.ore_source.external_excluded": "独立した外部生成は報告されますが、配置ルールには選択できません。", + "button.orespawn.ore_source.hide_single": "単一項目を隠す", + "tooltip.orespawn.ore_source.group_colours": "緑は別名と出力が各1つで操作不要です。黄は保存済みルールがあるグループです。赤は確認が必要です。すべて表示で緑の項目も表示します。", + "button.orespawn.ore_source.reset_all": "すべてリセット", + "button.orespawn.ore_source.confirm_reset_all": "リセットを確認", + "tooltip.orespawn.ore_source.reset_all": "組み込みの素材グループを復元し、現在読み込まれている鉱石からソースポリシーを再作成します。変更はメイン画面で完了を押すまで保留されます。", + "screen.orespawn.biomes": "バイオーム", + "screen.orespawn.biome_palettes": "バイオームパレット", + "screen.orespawn.biome_palette_settings": "バイオームパレット設定", + "button.orespawn.biome.hide_routine": "通常項目を隠す", + "button.orespawn.biome.replace_new_terrain": "新しい地形で置き換える...", + "button.orespawn.biome.replace_with": "新しい地形で %s に置き換える...", + "button.orespawn.biome.leave_original": "元の動作を維持", + "button.orespawn.biome.palettes": "パレット: %s", + "button.orespawn.biome.reset_selected": "バイオームをリセット", + "button.orespawn.biome.confirm_reset": "リセットを確認", + "button.orespawn.biome.reset_palette": "パレットをリセット", + "button.orespawn.biome.confirm_reset_palette": "パレットのリセットを確認", + "button.orespawn.biome.reset_dimension": "ディメンションをリセット", + "button.orespawn.biome.confirm_reset_dimension": "ディメンションのリセットを確認", + "button.orespawn.biome.reset_all": "全バイオームをリセット", + "button.orespawn.biome.confirm_reset_all": "全リセットを確認", + "value.orespawn.biome.status.user_replaced": "新しい地形で置換", + "value.orespawn.biome.status.layered": "複数の配置ルール", + "value.orespawn.biome.status.disabled": "配置は無効", + "value.orespawn.biome.status.missing": "バイオームまたはプロバイダーが不足", + "value.orespawn.biome.status.provider_managed": "プロバイダー管理", + "value.orespawn.biome.status.unmanaged": "登録済み、未管理", + "label.orespawn.biome.rule_count": "配置ルール: %s", + "label.orespawn.biome.replaced_by": "新しい地形での置換: %s", + "label.orespawn.biome.palette_owner_order": "所有者: %s、適用順: %s", + "label.orespawn.biome.surface_effective": "このルールが有効な表面を提供します。", + "label.orespawn.biome.surface_overridden": "後の有効なルールがこの表面を上書きします。", + "label.orespawn.biome.placement_rules": "配置ルール", + "label.orespawn.biome.palette_entries": "配置項目: %s", + "tooltip.orespawn.biome.no_retrogen": "バイオーム置換は新しい地形の生成時のみ適用されます。既存チャンクは書き換えません。", + "tooltip.orespawn.biome.override_palette": "予約済みの最終置換レイヤーです。各バイオームの置換操作から編集します。", + "tooltip.orespawn.biome.palette_entries_read_only": "パレットの所属はプロバイダーまたはプロファイルで定義されます。既存ルールを編集するにはバイオームを選択してください。", + "option.orespawn.biome.coverage": "適用範囲", + "option.orespawn.biome.fallback_weight": "元のバイオームの重み", + "option.orespawn.biome.include_namespaces": "含める名前空間(カンマ区切り)", + "option.orespawn.biome.exclude_namespaces": "除外する名前空間(カンマ区切り)" } diff --git a/src/main/resources/assets/orespawn/lang/ko_kr.json b/src/main/resources/assets/orespawn/lang/ko_kr.json index 8b49cd16..921ada3c 100644 --- a/src/main/resources/assets/orespawn/lang/ko_kr.json +++ b/src/main/resources/assets/orespawn/lang/ko_kr.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "생물 군계의 노출된 수중 표면 블록을 교체합니다. 설정되지 않으면 일반 블록이 유지됩니다.", "tooltip.orespawn.biome.ceiling_block": "천장을 생성하는 차원에서 생물 군계의 천장 표면 블록을 교체합니다. 설정하지 않으면 일반 블록을 유지합니다.", "tooltip.orespawn.biome.filler_depth": "채우기 블록을 사용하는 상단 블록 아래의 레이어 수입니다. 유효 범위: 0 ~ 16.", - "tooltip.orespawn.material.default_fluid": "해수면 아래에서 사용되는 일반 대수층 유체를 선택합니다. 설정하지 않으면 Minecraft의 원래 유체가 유지됩니다.", + "tooltip.orespawn.material.default_fluid": "이 차원의 모든 생물 군계에서 해수면 아래의 일반 대수층 유체를 교체합니다. 설정하지 않으면 생성기의 원래 유체를 유지합니다. 기존 청크는 변경되지 않으며 특수한 조명의 유체는 더 느리게 생성될 수 있습니다.", "tooltip.orespawn.material.deep_aquifer_fluid": "Minecraft 1.14.4에서는 사용할 수 없습니다. 저장된 값은 이후 버전용으로 유지됩니다.", "tooltip.orespawn.material.deep_aquifer_y": "Minecraft 1.14.4에서는 사용할 수 없습니다. 저장된 값은 이후 버전용으로 유지됩니다.", "tooltip.orespawn.material.snow_block": "이 차원의 표면 근처에 배치된 바닐라 눈을 바꿉니다. 설정하지 않으면 정상적인 눈이 유지됩니다.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "설치된 생물군계 선택", "screen.orespawn.biome_placement": "생물군계 설정", "screen.orespawn.biome_references": "유사 생물군계", - "screen.orespawn.dimension_materials": "월드 재료", + "screen.orespawn.dimension_materials": "%s 재료", "button.orespawn.biomes_world_materials": "생물군계 및 월드 재료...", "button.orespawn.biome_palette_count": "생물군계: %s", "button.orespawn.dimension_materials": "월드 재료...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "월드 재료", "guide.orespawn.materials.1": "월드 재료는 차원 전체의 기본 재료를 바꿉니다. 가끔 나타나는 지하 주머니만 원한다면 유체 광상을 사용하세요.", "guide.orespawn.materials.2": "대수층 유체는 해수면 아래에서 쓰는 기본 유체를 바꿉니다. 깊은 대수층은 선택한 Y 아래에서 두 번째 유체를 사용합니다.", - "guide.orespawn.materials.3": "눈과 얼음은 지표 근처의 바닐라 눈과 일반 얼음을 바꿉니다. 설정 안 함은 Minecraft 기본 재료를 그대로 둡니다." + "guide.orespawn.materials.3": "눈과 얼음은 지표 근처의 바닐라 눈과 일반 얼음을 바꿉니다. 설정 안 함은 Minecraft 기본 재료를 그대로 둡니다.", + "button.orespawn.dimension_materials_named": "%s 재료...", + "label.orespawn.dimension_materials.scope": "새로 생성되는 %s 지형의 모든 생물 군계에 적용됩니다.", + "button.orespawn.mods": "모드 목록", + "button.orespawn.mod.configure": "설정", + "tooltip.orespawn.main.mods": "OreSpawn과 연동된 불러온 모드를 표시합니다.", + "screen.orespawn.mods": "OreSpawn 모드", + "tooltip.orespawn.mod.configure": "%s 설정을 엽니다.", + "tooltip.orespawn.mod.unavailable": "%s 모드는 OreSpawn 연동 설정 화면을 제공하지 않습니다.", + "label.orespawn.mod.id_version": "%s - 모드 버전 %s", + "label.orespawn.mod.additional_lineage": "OreSpawn %s 호환으로도 감지되었습니다.", + "label.orespawn.mod.status": "상태: %s", + "label.orespawn.mod.os4_version": "기본 OS4 공급자 - 스키마 %s, 공급자 리비전 %s", + "label.orespawn.mod.os4": "기본 OS4 공급자", + "label.orespawn.mod.legacy_version": "OreSpawn %s 호환 -> OS4 스키마 %s, 공급자 리비전 %s", + "label.orespawn.mod.legacy": "OreSpawn %s 호환 -> OS4", + "label.orespawn.mod.config_only": "클라이언트 설정 연동", + "label.orespawn.mod.none": "불러온 OreSpawn 연동 모드를 찾지 못했습니다.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "활성", + "status.orespawn.mod.pending": "대기 중", + "status.orespawn.mod.inactive": "비활성", + "status.orespawn.mod.rejected": "거부됨", + "status.orespawn.mod.config_only": "설정 화면만 제공", + "error.orespawn.mod.screen_missing": "%s 모드가 설정 화면을 만들지 않았습니다.", + "error.orespawn.mod.screen_failed": "%s 설정을 열 수 없습니다(%s).", + "button.orespawn.ore_sources": "광석 소스...", + "tooltip.orespawn.ore_sources": "배치 빈도를 늘리지 않고 동등한 광석 출력을 검토합니다.", + "screen.orespawn.ore_sources": "광석 소스", + "screen.orespawn.ore_source_detail": "광석 소스 정책", + "label.orespawn.ore_source.none": "동등한 관리 광석 소스를 찾지 못했습니다.", + "label.orespawn.ore_source.row": "%s - %s - 후보 %s개 - %s", + "label.orespawn.ore_source.intro": "동등한 광석의 생성 방식을 선택하세요.", + "label.orespawn.ore_source.summary": "후보 %s개 - %s", + "button.orespawn.ore_source.review": "검토...", + "button.orespawn.ore_source.configure": "설정...", + "label.orespawn.ore_source.material": "재료: %s", + "label.orespawn.ore_source.domain": "차원/영역: %s", + "label.orespawn.ore_source.channels": "배치 채널: %s", + "label.orespawn.ore_source.missing": "배치 소스 없음", + "label.orespawn.ore_source.owner": "소유자: %s, 버전 %s", + "label.orespawn.ore_source.registry": "등록 출력: %s, 메타데이터 %s", + "label.orespawn.ore_source.dictionary": "블록 태그: %s", + "option.orespawn.ore_source.mode": "모드: %s", + "option.orespawn.ore_source.channel": "채널: %s", + "option.orespawn.ore_source.placement": "배치 소스: %s", + "option.orespawn.ore_source.weight": "가중치", + "mode.orespawn.ore_source.consolidated": "통합", + "mode.orespawn.ore_source.keep_separate": "분리 유지", + "status.orespawn.ore_source.consolidated": "통합", + "status.orespawn.ore_source.separate": "분리", + "status.orespawn.ore_source.review_required": "검토 필요", + "status.orespawn.ore_source.missing_source": "소스 없음", + "status.orespawn.ore_source.external_generation": "외부 생성", + "tooltip.orespawn.ore_source.open": "이 재료 정책을 엽니다.", + "tooltip.orespawn.ore_source.mode": "통합은 채널마다 하나의 배치 예산을 사용합니다. 분리 유지는 설정된 모든 규칙을 보존합니다.", + "tooltip.orespawn.ore_source.channel": "배치 엔진은 서로 독립적으로 조정됩니다.", + "tooltip.orespawn.ore_source.placement": "이 채널의 빈도, 모양, 모암, 필터 및 높이를 제어하는 규칙을 선택합니다.", + "tooltip.orespawn.ore_source.weight": "전체 광맥 또는 식별된 광상에 이 출력을 선택할 양의 상대 확률입니다.", + "tooltip.orespawn.ore_source.external": "이 Mod는 OreSpawn 밖에서 생성을 등록합니다. OreSpawn은 표시할 수 있지만 억제할 수 없습니다.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn이 이 설정된 배치와 출력 소스를 제어합니다.", + "tooltip.orespawn.ore_source.enrichment": "이 항목은 농축용이며 교환 가능한 광석 소스가 아닙니다.", + "tooltip.orespawn.ore_source.missing": "이 소스를 사용할 수 없습니다. 소유 Mod가 돌아오면 저장된 선택이 복원됩니다.", + "error.orespawn.ore_source.weight": "출력 가중치는 양수여야 합니다.", + "button.orespawn.back": "뒤로", + "button.orespawn.ore_source.all_groups": "모든 그룹", + "button.orespawn.ore_source.add_group": "그룹 추가", + "button.orespawn.ore_source.accept": "승인", + "tooltip.orespawn.ore_source.accept": "'원래대로 유지' 설정은 바꾸지 않고 이 그룹을 검토 완료로 표시합니다. 기본 편집기에서 완료를 눌러야 저장됩니다.", + "button.orespawn.ore_source.delete_group": "삭제", + "button.orespawn.ore_source.dissolve_group": "그룹 해제", + "button.orespawn.ore_source.confirm_dissolve": "해제 확인", + "button.orespawn.ore_source.move": "이동", + "button.orespawn.ore_source.advanced": "고급...", + "button.orespawn.ore_source.hide_advanced": "고급 숨기기", + "option.orespawn.ore_source.group_name": "그룹 이름", + "option.orespawn.ore_source.alias": "블록 태그 항목", + "option.orespawn.ore_source.output_mode": "출력: %s", + "mode.orespawn.ore_source.keep_original": "원본 유지", + "mode.orespawn.ore_source.balanced": "균등", + "mode.orespawn.ore_source.single": "단일", + "mode.orespawn.ore_source.custom": "사용자 지정", + "label.orespawn.ore_source.no_aliases": "블록 태그 항목 없음", + "status.orespawn.ore_source.external_short": "외부", + "status.orespawn.ore_source.missing_short": "누락", + "status.orespawn.ore_source.output_only": "출력 전용", + "tooltip.orespawn.ore_source.output_only": "이 공급자 출력은 사용할 수 있지만 배치 규칙은 비활성 상태입니다.", + "tooltip.orespawn.ore_source.output_mode": "원본 유지는 각 규칙을 보존합니다. 균등은 같은 비율로, 단일은 한 블록을, 사용자 지정은 가중 선택을 사용합니다.", + "tooltip.orespawn.ore_source.alias": "정확한 블록 태그 이름을 추가합니다. 다른 그룹에서 재할당하려면 이동을 다시 누르세요.", + "tooltip.orespawn.ore_source.reset_group": "이 선별 그룹의 이름과 블록 태그 항목을 복원합니다.", + "tooltip.orespawn.ore_source.delete_group": "생성된 청크를 다시 쓰지 않고 비어 있는 사용자 지정 그룹을 삭제합니다.", + "tooltip.orespawn.ore_source.dissolve_group": "각 항목을 추론되었거나 선별된 그룹으로 되돌립니다. 두 번 눌러 확인합니다.", + "error.orespawn.ore_source.confirm_dissolve": "해제 확인을 눌러 모든 블록 태그 항목을 원래 그룹으로 되돌립니다.", + "error.orespawn.ore_source.alias": "forge:ores/sulfur 같은 정확한 블록 태그 이름을 사용하세요.", + "error.orespawn.ore_source.alias_owned": "%s에서 이미 이 항목을 소유합니다. 이동을 눌러 재할당하세요.", + "error.orespawn.ore_source.manage_vanilla_first": "바닐라 광석을 다른 그룹으로 이동하기 전에 바닐라 광석 관리를 활성화하세요.", + "error.orespawn.ore_source.defaults_write": "재료 그룹 기본값을 저장하지 못했습니다.", + "screen.orespawn.ore_source.group_settings": "광석 소스 그룹 설정", + "label.orespawn.ore_source.groups": "그룹", + "label.orespawn.ore_source.aliases": "블록 태그 항목", + "label.orespawn.ore_source.placement_rules": "배치 규칙", + "label.orespawn.ore_source.placement_explanation": "출력은 블록을, 배치 규칙은 빈도, 모양, 깊이와 모암을 선택합니다.", + "label.orespawn.ore_source.standard_veins": "표준 광맥", + "label.orespawn.ore_source.placement_choice_count": "%s (선택지 %s개)", + "label.orespawn.ore_source.no_placement_rules": "관리되는 배치 규칙 없음", + "tooltip.orespawn.ore_source.group_settings": "이 그룹의 이름, 별칭과 배치 규칙을 편집합니다.", + "tooltip.orespawn.ore_source.remove_alias": "이 그룹에서 %s 항목을 제거합니다.", + "tooltip.orespawn.ore_source.placement_rules": "출력은 광석 블록을, 배치 규칙은 빈도, 모양, 깊이와 모암 제한을 선택합니다.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "배치 소유권은 통합된 균형, 단일 또는 사용자 지정 모드에만 적용됩니다.", + "tooltip.orespawn.ore_source.placement_missing": "저장된 배치 소스가 없습니다. 이 채널은 비활성화 상태로 유지됩니다.", + "tooltip.orespawn.ore_source.placement_readonly": "활성 관리 규칙이 하나뿐이므로 이 소스는 읽기 전용입니다.", + "tooltip.orespawn.ore_source.placement_select": "활성 관리 배치 소스 중 하나를 선택하려면 클릭하세요.", + "tooltip.orespawn.ore_source.external_excluded": "독립 외부 생성기는 보고되지만 배치 규칙으로 선택할 수 없습니다.", + "button.orespawn.ore_source.hide_single": "단일 항목 숨기기", + "tooltip.orespawn.ore_source.group_colours": "녹색 항목은 별칭과 출력이 각각 하나라 조치가 필요 없습니다. 노란색 그룹에는 저장된 규칙이 있습니다. 빨간색 그룹은 확인이 필요합니다. 모두 표시를 누르면 녹색 항목도 표시됩니다.", + "button.orespawn.ore_source.reset_all": "모두 초기화", + "button.orespawn.ore_source.confirm_reset_all": "초기화 확인", + "tooltip.orespawn.ore_source.reset_all": "기본 재료 그룹을 복원하고 현재 불러온 광석에서 소스 정책을 다시 만듭니다. 변경 사항은 기본 편집기에서 완료를 누를 때까지 보류됩니다.", + "screen.orespawn.biomes": "생물군계", + "screen.orespawn.biome_palettes": "생물군계 팔레트", + "screen.orespawn.biome_palette_settings": "생물군계 팔레트 설정", + "button.orespawn.biome.hide_routine": "일반 항목 숨기기", + "button.orespawn.biome.replace_new_terrain": "새 지형에서 다음으로 교체...", + "button.orespawn.biome.replace_with": "새 지형에서 %s(으)로 교체...", + "button.orespawn.biome.leave_original": "원래 동작 유지", + "button.orespawn.biome.palettes": "팔레트: %s", + "button.orespawn.biome.reset_selected": "생물군계 초기화", + "button.orespawn.biome.confirm_reset": "초기화 확인", + "button.orespawn.biome.reset_palette": "팔레트 초기화", + "button.orespawn.biome.confirm_reset_palette": "팔레트 초기화 확인", + "button.orespawn.biome.reset_dimension": "차원 초기화", + "button.orespawn.biome.confirm_reset_dimension": "차원 초기화 확인", + "button.orespawn.biome.reset_all": "모든 생물군계 초기화", + "button.orespawn.biome.confirm_reset_all": "전체 초기화 확인", + "value.orespawn.biome.status.user_replaced": "새 지형에서 교체됨", + "value.orespawn.biome.status.layered": "여러 배치 규칙", + "value.orespawn.biome.status.disabled": "배치 비활성화", + "value.orespawn.biome.status.missing": "생물군계 또는 제공자 누락", + "value.orespawn.biome.status.provider_managed": "제공자 관리", + "value.orespawn.biome.status.unmanaged": "등록됨, 관리되지 않음", + "label.orespawn.biome.rule_count": "배치 규칙: %s", + "label.orespawn.biome.replaced_by": "새 지형 교체: %s", + "label.orespawn.biome.palette_owner_order": "소유자: %s; 적용 순서: %s", + "label.orespawn.biome.surface_effective": "이 규칙이 적용되는 표면을 제공합니다.", + "label.orespawn.biome.surface_overridden": "뒤의 활성 규칙이 이 표면을 덮어씁니다.", + "label.orespawn.biome.placement_rules": "배치 규칙", + "label.orespawn.biome.palette_entries": "배치 항목: %s", + "tooltip.orespawn.biome.no_retrogen": "생물군계 교체는 새 지형 생성 시에만 적용됩니다. 기존 청크는 다시 쓰지 않습니다.", + "tooltip.orespawn.biome.override_palette": "예약된 최종 교체 레이어입니다. 각 생물군계의 교체 컨트롤에서 편집하세요.", + "tooltip.orespawn.biome.palette_entries_read_only": "팔레트 구성은 제공자 또는 프로필에서 정의합니다. 기존 규칙을 편집하려면 생물군계를 선택하세요.", + "option.orespawn.biome.coverage": "적용 범위", + "option.orespawn.biome.fallback_weight": "원래 생물군계 가중치", + "option.orespawn.biome.include_namespaces": "포함할 네임스페이스(쉼표로 구분)", + "option.orespawn.biome.exclude_namespaces": "제외할 네임스페이스(쉼표로 구분)" } diff --git a/src/main/resources/assets/orespawn/lang/pt_br.json b/src/main/resources/assets/orespawn/lang/pt_br.json index 906683a9..b459efd5 100644 --- a/src/main/resources/assets/orespawn/lang/pt_br.json +++ b/src/main/resources/assets/orespawn/lang/pt_br.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Substitua o bloco de superfície subaquático exposto do bioma. Not set mantém o bloco normal.", "tooltip.orespawn.biome.ceiling_block": "Substitua o bloco de superfície do teto do bioma em dimensões que gerem tetos. Não definido mantém o bloco normal.", "tooltip.orespawn.biome.filler_depth": "Número de camadas abaixo do bloco superior que usam Bloco de Preenchimento. Faixa válida: 0 a 16.", - "tooltip.orespawn.material.default_fluid": "Escolha o fluido aquífero normal usado abaixo do nível do mar. Não definido mantém o fluido original de Minecraft.", + "tooltip.orespawn.material.default_fluid": "Substitui o fluido normal do aquífero abaixo do nível do mar em cada bioma desta dimensão. Não definido mantém o fluido original do gerador. Chunks existentes permanecem inalterados; fluidos com iluminação incomum podem gerar mais lentamente.", "tooltip.orespawn.material.deep_aquifer_fluid": "Indisponível no Minecraft 1.14.4; o valor salvo é mantido para versões posteriores.", "tooltip.orespawn.material.deep_aquifer_y": "Indisponível no Minecraft 1.14.4; o valor salvo é mantido para versões posteriores.", "tooltip.orespawn.material.snow_block": "Substitua a neve baunilha colocada perto da superfície nesta dimensão. Não definido mantém a neve normal.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Escolher bioma instalado", "screen.orespawn.biome_placement": "Configurações do bioma", "screen.orespawn.biome_references": "Biomas semelhantes", - "screen.orespawn.dimension_materials": "Materiais do mundo", + "screen.orespawn.dimension_materials": "Materiais de %s", "button.orespawn.biomes_world_materials": "Biomas e materiais do mundo...", "button.orespawn.biome_palette_count": "Biomas: %s", "button.orespawn.dimension_materials": "Materiais do mundo...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Materiais do mundo", "guide.orespawn.materials.1": "Materiais do mundo altera materiais de fundo em uma dimensão inteira. Use Depósitos de fluidos quando quiser apenas bolsas subterrâneas ocasionais.", "guide.orespawn.materials.2": "Fluido do aquífero altera o fluido normal abaixo do nível do mar. Aquífero profundo usa um segundo fluido abaixo do nível Y escolhido.", - "guide.orespawn.materials.3": "Neve e Gelo substituem a neve vanilla e o gelo comum perto da superfície. Não definido mantém o material normal do Minecraft." + "guide.orespawn.materials.3": "Neve e Gelo substituem a neve vanilla e o gelo comum perto da superfície. Não definido mantém o material normal do Minecraft.", + "button.orespawn.dimension_materials_named": "Materiais de %s...", + "label.orespawn.dimension_materials.scope": "Aplica-se a cada bioma no novo terreno de %s.", + "button.orespawn.mods": "Diretório de mods", + "button.orespawn.mod.configure": "Configurar", + "tooltip.orespawn.main.mods": "Ver os mods carregados integrados ao OreSpawn.", + "screen.orespawn.mods": "Mods do OreSpawn", + "tooltip.orespawn.mod.configure": "Abrir a configuração de %s.", + "tooltip.orespawn.mod.unavailable": "%s não fornece uma tela de configuração vinculada ao OreSpawn.", + "label.orespawn.mod.id_version": "%s - Versão do mod %s", + "label.orespawn.mod.additional_lineage": "Também detectado pela compatibilidade com OreSpawn %s.", + "label.orespawn.mod.status": "Situação: %s", + "label.orespawn.mod.os4_version": "Provedor OS4 nativo - esquema %s, revisão do provedor %s", + "label.orespawn.mod.os4": "Provedor OS4 nativo", + "label.orespawn.mod.legacy_version": "Compatibilidade com OreSpawn %s -> esquema OS4 %s, revisão do provedor %s", + "label.orespawn.mod.legacy": "Compatibilidade com OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Integração de configuração do cliente", + "label.orespawn.mod.none": "Nenhuma integração carregada do OreSpawn foi encontrada.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Ativo", + "status.orespawn.mod.pending": "Pendente", + "status.orespawn.mod.inactive": "Inativo", + "status.orespawn.mod.rejected": "Rejeitado", + "status.orespawn.mod.config_only": "Apenas tela de configuração", + "error.orespawn.mod.screen_missing": "%s não criou uma tela de configuração.", + "error.orespawn.mod.screen_failed": "Não foi possível abrir a configuração de %s (%s).", + "button.orespawn.ore_sources": "Fontes de minério...", + "tooltip.orespawn.ore_sources": "Revise saídas de minério equivalentes sem multiplicar a frequência de colocação.", + "screen.orespawn.ore_sources": "Fontes de minério", + "screen.orespawn.ore_source_detail": "Política de fonte de minério", + "label.orespawn.ore_source.none": "Nenhuma fonte de minério gerenciada equivalente foi encontrada.", + "label.orespawn.ore_source.row": "%s - %s - %s candidatos - %s", + "label.orespawn.ore_source.intro": "Escolha como minérios equivalentes são gerados.", + "label.orespawn.ore_source.summary": "%s candidatos - %s", + "button.orespawn.ore_source.review": "Revisar...", + "button.orespawn.ore_source.configure": "Configurar...", + "label.orespawn.ore_source.material": "Matéria-prima: %s", + "label.orespawn.ore_source.domain": "Dimensão/domínio: %s", + "label.orespawn.ore_source.channels": "Canais de colocação: %s", + "label.orespawn.ore_source.missing": "Fonte de colocação ausente", + "label.orespawn.ore_source.owner": "Proprietário: %s, versão %s", + "label.orespawn.ore_source.registry": "Saída registrada: %s, metadados %s", + "label.orespawn.ore_source.dictionary": "Tags de blocos: %s", + "option.orespawn.ore_source.mode": "Modo: %s", + "option.orespawn.ore_source.channel": "Canal: %s", + "option.orespawn.ore_source.placement": "Fonte de colocação: %s", + "option.orespawn.ore_source.weight": "Peso", + "mode.orespawn.ore_source.consolidated": "Consolidado", + "mode.orespawn.ore_source.keep_separate": "Manter separado", + "status.orespawn.ore_source.consolidated": "Consolidado", + "status.orespawn.ore_source.separate": "Separado", + "status.orespawn.ore_source.review_required": "Revisão necessária", + "status.orespawn.ore_source.missing_source": "Fonte ausente", + "status.orespawn.ore_source.external_generation": "Geração externa", + "tooltip.orespawn.ore_source.open": "Abrir esta política de material.", + "tooltip.orespawn.ore_source.mode": "Consolidado usa um orçamento de colocação por canal. Manter separado preserva cada regra configurada.", + "tooltip.orespawn.ore_source.channel": "Os mecanismos de colocação são arbitrados separadamente.", + "tooltip.orespawn.ore_source.placement": "Escolha a regra que controla frequência, forma, hospedeiros, filtros e altura neste canal.", + "tooltip.orespawn.ore_source.weight": "Chance relativa positiva de escolher esta saída para um veio inteiro ou corpo de depósito identificado.", + "tooltip.orespawn.ore_source.external": "Este mod registra geração fora do OreSpawn; o OreSpawn a informa, mas não pode impedi-la.", + "tooltip.orespawn.ore_source.controlled": "O OreSpawn controla esta colocação e fonte de saída configuradas.", + "tooltip.orespawn.ore_source.enrichment": "Esta entrada é um enriquecimento, não uma fonte de minério intercambiável.", + "tooltip.orespawn.ore_source.missing": "Esta fonte está indisponível. A seleção salva será restaurada se o mod proprietário retornar.", + "error.orespawn.ore_source.weight": "Os pesos de saída devem ser números positivos.", + "button.orespawn.back": "Voltar", + "button.orespawn.ore_source.all_groups": "Todos os grupos", + "button.orespawn.ore_source.add_group": "Adicionar grupo", + "button.orespawn.ore_source.accept": "Aceitar", + "tooltip.orespawn.ore_source.accept": "Mantém Conservar original sem alterações e marca o grupo como revisado. Só é salvo ao usar Concluído no editor principal.", + "button.orespawn.ore_source.delete_group": "Excluir", + "button.orespawn.ore_source.dissolve_group": "Dissolver grupo", + "button.orespawn.ore_source.confirm_dissolve": "Confirmar dissolução", + "button.orespawn.ore_source.move": "Mover", + "button.orespawn.ore_source.advanced": "Avançado...", + "button.orespawn.ore_source.hide_advanced": "Ocultar avançado", + "option.orespawn.ore_source.group_name": "Nome do grupo", + "option.orespawn.ore_source.alias": "Entrada do sistema de tags de blocos", + "option.orespawn.ore_source.output_mode": "Saída: %s", + "mode.orespawn.ore_source.keep_original": "Manter original", + "mode.orespawn.ore_source.balanced": "Equilibrado", + "mode.orespawn.ore_source.single": "Único", + "mode.orespawn.ore_source.custom": "Personalizado", + "label.orespawn.ore_source.no_aliases": "Sem entradas do sistema de tags de blocos", + "status.orespawn.ore_source.external_short": "externo", + "status.orespawn.ore_source.missing_short": "ausente", + "status.orespawn.ore_source.output_only": "somente saída", + "tooltip.orespawn.ore_source.output_only": "Esta saída do provedor está disponível, mas sua regra de posicionamento está inativa.", + "tooltip.orespawn.ore_source.output_mode": "Manter original preserva cada regra. Equilibrado divide igualmente, Único escolhe um bloco e Personalizado permite escolhas ponderadas.", + "tooltip.orespawn.ore_source.alias": "Adicione um nome exato de minério do sistema de tags de blocos. Pressione Mover novamente para confirmar a transferência de outro grupo.", + "tooltip.orespawn.ore_source.reset_group": "Restaura o nome e as entradas do sistema de tags de blocos deste grupo selecionado.", + "tooltip.orespawn.ore_source.delete_group": "Exclui este grupo personalizado vazio sem reescrever os chunks gerados.", + "tooltip.orespawn.ore_source.dissolve_group": "Devolve cada entrada ao grupo inferido ou revisado. Pressione duas vezes para confirmar.", + "error.orespawn.ore_source.confirm_dissolve": "Pressione Confirmar dissolução para devolver cada entrada do sistema de tags de blocos ao grupo original.", + "error.orespawn.ore_source.alias": "Use um nome exato do sistema de tags de blocos, como forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s já possui esta entrada. Pressione Mover para transferi-la.", + "error.orespawn.ore_source.manage_vanilla_first": "Ative Gerenciar minérios vanilla antes de mover um minério vanilla nativo para outro grupo.", + "error.orespawn.ore_source.defaults_write": "Não foi possível salvar os padrões dos grupos de materiais.", + "screen.orespawn.ore_source.group_settings": "Configurações do grupo de fontes de minério", + "label.orespawn.ore_source.groups": "Grupos", + "label.orespawn.ore_source.aliases": "Entradas do Dicionário de Minérios", + "label.orespawn.ore_source.placement_rules": "Regras de colocação", + "label.orespawn.ore_source.placement_explanation": "Saídas escolhem o bloco; regras escolhem frequência, forma, profundidade e hospedeiros.", + "label.orespawn.ore_source.standard_veins": "Veios padrão", + "label.orespawn.ore_source.placement_choice_count": "%s (%s opções)", + "label.orespawn.ore_source.no_placement_rules": "Nenhuma regra de colocação gerenciada", + "tooltip.orespawn.ore_source.group_settings": "Edite o nome, aliases e regras de colocação deste grupo.", + "tooltip.orespawn.ore_source.remove_alias": "Remover %s deste grupo.", + "tooltip.orespawn.ore_source.placement_rules": "Saídas escolhem o bloco; regras escolhem frequência, forma, profundidade e restrições de hospedeiro.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "A seleção de colocação vale apenas nos modos consolidados Balanceado, Único ou Personalizado.", + "tooltip.orespawn.ore_source.placement_missing": "A fonte de colocação salva está ausente; este canal permanece desativado.", + "tooltip.orespawn.ore_source.placement_readonly": "Há apenas uma regra gerenciada ativa; esta fonte é somente leitura.", + "tooltip.orespawn.ore_source.placement_select": "Clique para escolher entre fontes de colocação gerenciadas ativas.", + "tooltip.orespawn.ore_source.external_excluded": "Geradores externos independentes são informados, mas não podem ser escolhidos como regras de colocação.", + "button.orespawn.ore_source.hide_single": "Ocultar individuais", + "tooltip.orespawn.ore_source.group_colours": "Entradas verdes têm um alias e uma saída e não exigem ação. Grupos amarelos têm uma regra salva. Grupos vermelhos exigem atenção. Mostrar tudo inclui as entradas verdes.", + "button.orespawn.ore_source.reset_all": "Redefinir tudo", + "button.orespawn.ore_source.confirm_reset_all": "Confirmar redefinição", + "tooltip.orespawn.ore_source.reset_all": "Restaura os grupos de materiais internos e recria as políticas a partir dos minérios carregados. A mudança fica pendente até usar Concluído no editor principal.", + "screen.orespawn.biomes": "Biomas", + "screen.orespawn.biome_palettes": "Paletas de biomas", + "screen.orespawn.biome_palette_settings": "Configurações da paleta de biomas", + "button.orespawn.biome.hide_routine": "Ocultar comuns", + "button.orespawn.biome.replace_new_terrain": "Substituir em terreno novo por...", + "button.orespawn.biome.replace_with": "Substituir em terreno novo por %s...", + "button.orespawn.biome.leave_original": "Manter o comportamento original", + "button.orespawn.biome.palettes": "Paletas: %s", + "button.orespawn.biome.reset_selected": "Redefinir bioma", + "button.orespawn.biome.confirm_reset": "Confirmar redefinição", + "button.orespawn.biome.reset_palette": "Redefinir paleta", + "button.orespawn.biome.confirm_reset_palette": "Confirmar paleta", + "button.orespawn.biome.reset_dimension": "Redefinir dimensão", + "button.orespawn.biome.confirm_reset_dimension": "Confirmar dimensão", + "button.orespawn.biome.reset_all": "Redefinir todos os biomas", + "button.orespawn.biome.confirm_reset_all": "Confirmar tudo", + "value.orespawn.biome.status.user_replaced": "Substituído em terreno novo", + "value.orespawn.biome.status.layered": "Várias regras de colocação", + "value.orespawn.biome.status.disabled": "Colocação desativada", + "value.orespawn.biome.status.missing": "Bioma ou provedor ausente", + "value.orespawn.biome.status.provider_managed": "Gerenciado pelo provedor", + "value.orespawn.biome.status.unmanaged": "Registrado, não gerenciado", + "label.orespawn.biome.rule_count": "Regras de colocação: %s", + "label.orespawn.biome.replaced_by": "Substituição em terreno novo: %s", + "label.orespawn.biome.palette_owner_order": "Proprietário: %s; ordem efetiva: %s", + "label.orespawn.biome.surface_effective": "Esta regra fornece a superfície efetiva.", + "label.orespawn.biome.surface_overridden": "Uma regra ativa posterior substitui esta superfície.", + "label.orespawn.biome.placement_rules": "Regras de colocação", + "label.orespawn.biome.palette_entries": "Entradas de colocação: %s", + "tooltip.orespawn.biome.no_retrogen": "As substituições de bioma só se aplicam ao gerar terreno novo. Chunks existentes nunca são reescritos.", + "tooltip.orespawn.biome.override_palette": "Camada final reservada de substituição. Edite-a pelo controle de substituição de cada bioma.", + "tooltip.orespawn.biome.palette_entries_read_only": "A associação à paleta é definida pelo provedor ou perfil. Selecione um bioma para editar uma regra existente.", + "option.orespawn.biome.coverage": "Cobertura", + "option.orespawn.biome.fallback_weight": "Peso do bioma original", + "option.orespawn.biome.include_namespaces": "Namespaces incluídos (separados por vírgulas)", + "option.orespawn.biome.exclude_namespaces": "Namespaces excluídos (separados por vírgulas)" } diff --git a/src/main/resources/assets/orespawn/lang/ru_ru.json b/src/main/resources/assets/orespawn/lang/ru_ru.json index 20453e77..9085d8d0 100644 --- a/src/main/resources/assets/orespawn/lang/ru_ru.json +++ b/src/main/resources/assets/orespawn/lang/ru_ru.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "Замените блок обнаженной подводной поверхности биома. Если не установлено, сохраняется обычный блок.", "tooltip.orespawn.biome.ceiling_block": "Замените блок поверхности потолка биома в размерах, которые создают потолки. Если не установлено, сохраняется обычный блок.", "tooltip.orespawn.biome.filler_depth": "Количество слоев под верхним блоком, в которых используется блок-заполнитель. Допустимый диапазон: от 0 до 16.", - "tooltip.orespawn.material.default_fluid": "Выберите обычную жидкость водоносного горизонта, используемую ниже уровня моря. Не установлено, сохраняет исходную жидкость Minecraft.", + "tooltip.orespawn.material.default_fluid": "Заменяет обычную жидкость водоносного горизонта ниже уровня моря во всех биомах этого измерения. Если не задано, сохраняется исходная жидкость генератора. Существующие чанки не меняются; жидкости с необычным освещением могут генерироваться медленнее.", "tooltip.orespawn.material.deep_aquifer_fluid": "Недоступно в Minecraft 1.14.4; сохранённое значение остаётся для более новых версий.", "tooltip.orespawn.material.deep_aquifer_y": "Недоступно в Minecraft 1.14.4; сохранённое значение остаётся для более новых версий.", "tooltip.orespawn.material.snow_block": "Замените ванильный снег, расположенный рядом с поверхностью в этом измерении. Не установлено, сохраняет обычный снег.", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Выбрать установленный биом", "screen.orespawn.biome_placement": "Настройки биома", "screen.orespawn.biome_references": "Похожие биомы", - "screen.orespawn.dimension_materials": "Материалы мира", + "screen.orespawn.dimension_materials": "Материалы: %s", "button.orespawn.biomes_world_materials": "Биомы и материалы мира...", "button.orespawn.biome_palette_count": "Биомы: %s", "button.orespawn.dimension_materials": "Материалы мира...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Материалы мира", "guide.orespawn.materials.1": "Материалы мира меняют фоновые материалы во всём измерении. Для редких подземных карманов используйте Месторождения жидкостей.", "guide.orespawn.materials.2": "Жидкость водоносного слоя меняет обычную жидкость ниже уровня моря. Глубокий слой использует вторую жидкость ниже выбранной Y.", - "guide.orespawn.materials.3": "Снег и лёд заменяют ванильный снег и обычный лёд у поверхности. Не задано сохраняет обычный материал Minecraft." + "guide.orespawn.materials.3": "Снег и лёд заменяют ванильный снег и обычный лёд у поверхности. Не задано сохраняет обычный материал Minecraft.", + "button.orespawn.dimension_materials_named": "Материалы: %s...", + "label.orespawn.dimension_materials.scope": "Применяется ко всем биомам новой местности %s.", + "button.orespawn.mods": "Каталог модов", + "button.orespawn.mod.configure": "Настроить", + "tooltip.orespawn.main.mods": "Показать загруженные моды, интегрированные с OreSpawn.", + "screen.orespawn.mods": "Моды OreSpawn", + "tooltip.orespawn.mod.configure": "Открыть настройки %s.", + "tooltip.orespawn.mod.unavailable": "%s не предоставляет экран настроек для OreSpawn.", + "label.orespawn.mod.id_version": "%s - Версия мода %s", + "label.orespawn.mod.additional_lineage": "Также обнаружена совместимость с OreSpawn %s.", + "label.orespawn.mod.status": "Состояние: %s", + "label.orespawn.mod.os4_version": "Встроенный поставщик OS4 - схема %s, ревизия поставщика %s", + "label.orespawn.mod.os4": "Встроенный поставщик OS4", + "label.orespawn.mod.legacy_version": "Совместимость с OreSpawn %s -> схема OS4 %s, ревизия поставщика %s", + "label.orespawn.mod.legacy": "Совместимость с OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Интеграция клиентских настроек", + "label.orespawn.mod.none": "Загруженные интеграции OreSpawn не найдены.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Активно", + "status.orespawn.mod.pending": "Ожидание", + "status.orespawn.mod.inactive": "Неактивно", + "status.orespawn.mod.rejected": "Отклонено", + "status.orespawn.mod.config_only": "Только экран настроек", + "error.orespawn.mod.screen_missing": "%s не создал экран настроек.", + "error.orespawn.mod.screen_failed": "Не удалось открыть настройки %s (%s).", + "button.orespawn.ore_sources": "Источники руд...", + "tooltip.orespawn.ore_sources": "Проверить равнозначные руды без умножения частоты размещения.", + "screen.orespawn.ore_sources": "Источники руд", + "screen.orespawn.ore_source_detail": "Правило источника руды", + "label.orespawn.ore_source.none": "Равнозначные управляемые источники руд не найдены.", + "label.orespawn.ore_source.row": "%s - %s - кандидатов: %s - %s", + "label.orespawn.ore_source.intro": "Выберите способ генерации равнозначных руд.", + "label.orespawn.ore_source.summary": "Кандидатов: %s - %s", + "button.orespawn.ore_source.review": "Проверить...", + "button.orespawn.ore_source.configure": "Настроить...", + "label.orespawn.ore_source.material": "Материал: %s", + "label.orespawn.ore_source.domain": "Измерение/область: %s", + "label.orespawn.ore_source.channels": "Каналы размещения: %s", + "label.orespawn.ore_source.missing": "Нет источника размещения", + "label.orespawn.ore_source.owner": "Владелец: %s, версия %s", + "label.orespawn.ore_source.registry": "Зарегистрированный выход: %s, метаданные %s", + "label.orespawn.ore_source.dictionary": "Теги блоков: %s", + "option.orespawn.ore_source.mode": "Режим: %s", + "option.orespawn.ore_source.channel": "Канал: %s", + "option.orespawn.ore_source.placement": "Источник размещения: %s", + "option.orespawn.ore_source.weight": "Вес", + "mode.orespawn.ore_source.consolidated": "Объединено", + "mode.orespawn.ore_source.keep_separate": "Хранить отдельно", + "status.orespawn.ore_source.consolidated": "Объединено", + "status.orespawn.ore_source.separate": "Раздельно", + "status.orespawn.ore_source.review_required": "Требуется проверка", + "status.orespawn.ore_source.missing_source": "Источник отсутствует", + "status.orespawn.ore_source.external_generation": "Внешняя генерация", + "tooltip.orespawn.ore_source.open": "Открыть правило этого материала.", + "tooltip.orespawn.ore_source.mode": "Объединение использует один бюджет размещения на канал. Раздельный режим сохраняет каждое настроенное правило.", + "tooltip.orespawn.ore_source.channel": "Механизмы размещения регулируются независимо.", + "tooltip.orespawn.ore_source.placement": "Выберите правило частоты, формы, вмещающих пород, фильтров и высоты для этого канала.", + "tooltip.orespawn.ore_source.weight": "Положительный относительный шанс выбрать этот выход для всей жилы или определённого рудного тела.", + "tooltip.orespawn.ore_source.external": "Этот мод регистрирует генерацию вне OreSpawn; OreSpawn показывает её, но не может отключить.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn управляет этим настроенным размещением и источником выхода.", + "tooltip.orespawn.ore_source.enrichment": "Эта запись является обогащением, а не взаимозаменяемым источником руды.", + "tooltip.orespawn.ore_source.missing": "Источник недоступен. Сохранённый выбор восстановится, когда вернётся его мод.", + "error.orespawn.ore_source.weight": "Веса выходов должны быть положительными числами.", + "button.orespawn.back": "Назад", + "button.orespawn.ore_source.all_groups": "Все группы", + "button.orespawn.ore_source.add_group": "Добавить группу", + "button.orespawn.ore_source.accept": "Принять", + "tooltip.orespawn.ore_source.accept": "Сохраняет режим «Оставить исходное» без изменений и отмечает группу как проверенную. Изменение сохраняется только кнопкой «Готово» в главном редакторе.", + "button.orespawn.ore_source.delete_group": "Удалить", + "button.orespawn.ore_source.dissolve_group": "Расформировать группу", + "button.orespawn.ore_source.confirm_dissolve": "Подтвердить расформирование", + "button.orespawn.ore_source.move": "Переместить", + "button.orespawn.ore_source.advanced": "Расширенные...", + "button.orespawn.ore_source.hide_advanced": "Скрыть расширенные", + "option.orespawn.ore_source.group_name": "Название группы", + "option.orespawn.ore_source.alias": "Запись тегов блоков", + "option.orespawn.ore_source.output_mode": "Результат: %s", + "mode.orespawn.ore_source.keep_original": "Сохранить исходное", + "mode.orespawn.ore_source.balanced": "Равномерно", + "mode.orespawn.ore_source.single": "Один", + "mode.orespawn.ore_source.custom": "Настраиваемый", + "label.orespawn.ore_source.no_aliases": "Нет записей тегов блоков", + "status.orespawn.ore_source.external_short": "внешний", + "status.orespawn.ore_source.missing_short": "отсутствует", + "status.orespawn.ore_source.output_only": "только результат", + "tooltip.orespawn.ore_source.output_only": "Результат этого поставщика доступен, но его правило размещения неактивно.", + "tooltip.orespawn.ore_source.output_mode": "Сохранить исходное оставляет каждое правило. Равномерно делит поровну, Один выбирает один блок, а Настраиваемый использует веса.", + "tooltip.orespawn.ore_source.alias": "Добавьте точное имя руды из тегов блоков. Нажмите Переместить ещё раз, чтобы подтвердить перенос из другой группы.", + "tooltip.orespawn.ore_source.reset_group": "Восстанавливает название и записи тегов блоков этой подготовленной группы.", + "tooltip.orespawn.ore_source.delete_group": "Удаляет эту пустую настраиваемую группу без перезаписи созданных чанков.", + "tooltip.orespawn.ore_source.dissolve_group": "Возвращает каждую запись в вычисленную или подготовленную группу. Нажмите дважды для подтверждения.", + "error.orespawn.ore_source.confirm_dissolve": "Нажмите «Подтвердить расформирование», чтобы вернуть все записи тегов блоков в исходные группы.", + "error.orespawn.ore_source.alias": "Используйте точное имя из тегов блоков, например forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s уже владеет этой записью. Нажмите Переместить для переназначения.", + "error.orespawn.ore_source.manage_vanilla_first": "Включите управление ванильными рудами, прежде чем перемещать ванильную руду в другую группу.", + "error.orespawn.ore_source.defaults_write": "Не удалось сохранить значения групп материалов по умолчанию.", + "screen.orespawn.ore_source.group_settings": "Настройки группы источников руды", + "label.orespawn.ore_source.groups": "Группы", + "label.orespawn.ore_source.aliases": "Записи тегов блоков", + "label.orespawn.ore_source.placement_rules": "Правила размещения", + "label.orespawn.ore_source.placement_explanation": "Выходы выбирают блок; правила выбирают частоту, форму, глубину и вмещающие породы.", + "label.orespawn.ore_source.standard_veins": "Стандартные жилы", + "label.orespawn.ore_source.placement_choice_count": "%s (вариантов: %s)", + "label.orespawn.ore_source.no_placement_rules": "Нет управляемых правил размещения", + "tooltip.orespawn.ore_source.group_settings": "Изменить имя, псевдонимы и правила размещения этой группы.", + "tooltip.orespawn.ore_source.remove_alias": "Удалить %s из этой группы.", + "tooltip.orespawn.ore_source.placement_rules": "Выходы выбирают блок руды; правила выбирают частоту, форму, глубину и ограничения вмещающих пород.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Выбор размещения применяется только к объединённым режимам Равный, Один или Пользовательский.", + "tooltip.orespawn.ore_source.placement_missing": "Сохранённый источник размещения отсутствует; канал остаётся отключённым.", + "tooltip.orespawn.ore_source.placement_readonly": "Доступно только одно активное управляемое правило; источник доступен только для чтения.", + "tooltip.orespawn.ore_source.placement_select": "Нажмите для выбора активного управляемого источника размещения.", + "tooltip.orespawn.ore_source.external_excluded": "Независимые внешние генераторы отображаются, но не могут быть выбраны как правила размещения.", + "button.orespawn.ore_source.hide_single": "Скрыть одиночные", + "tooltip.orespawn.ore_source.group_colours": "Зелёные записи имеют один псевдоним и один выход и не требуют действий. Жёлтые группы имеют сохранённое правило. Красные группы требуют внимания. Показать все также показывает зелёные записи.", + "button.orespawn.ore_source.reset_all": "Сбросить всё", + "button.orespawn.ore_source.confirm_reset_all": "Подтвердить сброс", + "tooltip.orespawn.ore_source.reset_all": "Восстанавливает встроенные группы материалов и заново создаёт правила по загруженным рудам. Изменение остаётся ожидающим до нажатия Готово в главном редакторе.", + "screen.orespawn.biomes": "Биомы", + "screen.orespawn.biome_palettes": "Палитры биомов", + "screen.orespawn.biome_palette_settings": "Настройки палитры биомов", + "button.orespawn.biome.hide_routine": "Скрыть обычные", + "button.orespawn.biome.replace_new_terrain": "Заменять в новой местности на...", + "button.orespawn.biome.replace_with": "Заменять в новой местности на %s...", + "button.orespawn.biome.leave_original": "Оставить исходное поведение", + "button.orespawn.biome.palettes": "Палитры: %s", + "button.orespawn.biome.reset_selected": "Сбросить биом", + "button.orespawn.biome.confirm_reset": "Подтвердить сброс", + "button.orespawn.biome.reset_palette": "Сбросить палитру", + "button.orespawn.biome.confirm_reset_palette": "Подтвердить палитру", + "button.orespawn.biome.reset_dimension": "Сбросить измерение", + "button.orespawn.biome.confirm_reset_dimension": "Подтвердить измерение", + "button.orespawn.biome.reset_all": "Сбросить все биомы", + "button.orespawn.biome.confirm_reset_all": "Подтвердить всё", + "value.orespawn.biome.status.user_replaced": "Заменён в новой местности", + "value.orespawn.biome.status.layered": "Несколько правил размещения", + "value.orespawn.biome.status.disabled": "Размещение отключено", + "value.orespawn.biome.status.missing": "Биом или поставщик отсутствует", + "value.orespawn.biome.status.provider_managed": "Управляется поставщиком", + "value.orespawn.biome.status.unmanaged": "Зарегистрирован, не управляется", + "label.orespawn.biome.rule_count": "Правила размещения: %s", + "label.orespawn.biome.replaced_by": "Замена в новой местности: %s", + "label.orespawn.biome.palette_owner_order": "Владелец: %s; итоговый порядок: %s", + "label.orespawn.biome.surface_effective": "Эта правило задаёт итоговую поверхность.", + "label.orespawn.biome.surface_overridden": "Более позднее активное правило заменяет эту поверхность.", + "label.orespawn.biome.placement_rules": "Правила размещения", + "label.orespawn.biome.palette_entries": "Записи размещения: %s", + "tooltip.orespawn.biome.no_retrogen": "Замены биомов применяются только при создании новой местности. Существующие чанки не переписываются.", + "tooltip.orespawn.biome.override_palette": "Зарезервированный финальный слой замен. Изменяйте его через управление заменой каждого биома.", + "tooltip.orespawn.biome.palette_entries_read_only": "Состав палитры задаётся поставщиком или профилем. Выберите биом для изменения существующего правила.", + "option.orespawn.biome.coverage": "Покрытие", + "option.orespawn.biome.fallback_weight": "Вес исходного биома", + "option.orespawn.biome.include_namespaces": "Включённые пространства имён (через запятую)", + "option.orespawn.biome.exclude_namespaces": "Исключённые пространства имён (через запятую)" } diff --git a/src/main/resources/assets/orespawn/lang/zh_cn.json b/src/main/resources/assets/orespawn/lang/zh_cn.json index 6081a806..52e8838b 100644 --- a/src/main/resources/assets/orespawn/lang/zh_cn.json +++ b/src/main/resources/assets/orespawn/lang/zh_cn.json @@ -101,7 +101,7 @@ "tooltip.orespawn.biome.underwater_block": "替换生物群系暴露的水下表面块。未设置保留正常块。", "tooltip.orespawn.biome.ceiling_block": "将生物群落的天花板表面块替换为生成天花板的尺寸。未设置保留正常块。", "tooltip.orespawn.biome.filler_depth": "使用填充块的顶部块下方的层数。有效范围:0 到 16。", - "tooltip.orespawn.material.default_fluid": "选择海平面以下使用的正常含水层流体。未设置会保留 Minecraft 的原始流体。", + "tooltip.orespawn.material.default_fluid": "替换此维度每个生物群系中海平面以下的普通含水层流体。未设置时保留生成器原始流体。现有区块不变;具有特殊光照的流体生成速度可能较慢。", "tooltip.orespawn.material.deep_aquifer_fluid": "Minecraft 1.14.4 中不可用;已保存的值会保留供更高版本使用。", "tooltip.orespawn.material.deep_aquifer_y": "Minecraft 1.14.4 中不可用;已保存的值会保留供更高版本使用。", "tooltip.orespawn.material.snow_block": "替换放置在该维度表面附近的香草雪。未设置保留正常雪。", @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "选择已安装的生物群系", "screen.orespawn.biome_placement": "生物群系设置", "screen.orespawn.biome_references": "相似生物群系", - "screen.orespawn.dimension_materials": "世界材料", + "screen.orespawn.dimension_materials": "%s 材料", "button.orespawn.biomes_world_materials": "生物群系和世界材料...", "button.orespawn.biome_palette_count": "生物群系: %s", "button.orespawn.dimension_materials": "世界材料...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "世界材料", "guide.orespawn.materials.1": "世界材料会改变整个维度的基础材料。若只想要偶尔出现的地下囊体,请使用流体矿床。", "guide.orespawn.materials.2": "含水层流体会改变海平面以下使用的普通流体。深层含水层会在所选Y值以下使用第二种流体。", - "guide.orespawn.materials.3": "雪和冰会替换地表附近的原版雪与普通冰。“未设置”会保留Minecraft的普通材料。" + "guide.orespawn.materials.3": "雪和冰会替换地表附近的原版雪与普通冰。“未设置”会保留Minecraft的普通材料。", + "button.orespawn.dimension_materials_named": "%s 材料...", + "label.orespawn.dimension_materials.scope": "应用于新生成的 %s 地形中的每个生物群系。", + "button.orespawn.mods": "模组目录", + "button.orespawn.mod.configure": "配置", + "tooltip.orespawn.main.mods": "查看已加载并与OreSpawn集成的模组。", + "screen.orespawn.mods": "OreSpawn模组", + "tooltip.orespawn.mod.configure": "打开%s的配置。", + "tooltip.orespawn.mod.unavailable": "%s未提供与OreSpawn关联的配置界面。", + "label.orespawn.mod.id_version": "%s - 模组版本%s", + "label.orespawn.mod.additional_lineage": "同时检测到OreSpawn %s兼容方式。", + "label.orespawn.mod.status": "状态:%s", + "label.orespawn.mod.os4_version": "原生OS4提供方 - 架构%s,提供方修订%s", + "label.orespawn.mod.os4": "原生OS4提供方", + "label.orespawn.mod.legacy_version": "OreSpawn %s兼容 -> OS4架构%s,提供方修订%s", + "label.orespawn.mod.legacy": "OreSpawn %s兼容 -> OS4", + "label.orespawn.mod.config_only": "客户端配置集成", + "label.orespawn.mod.none": "未找到已加载的OreSpawn集成。", + "label.orespawn.mod.integration_status": "%s|%s", + "status.orespawn.mod.active": "已启用", + "status.orespawn.mod.pending": "待处理", + "status.orespawn.mod.inactive": "未启用", + "status.orespawn.mod.rejected": "已拒绝", + "status.orespawn.mod.config_only": "仅配置界面", + "error.orespawn.mod.screen_missing": "%s未创建配置界面。", + "error.orespawn.mod.screen_failed": "无法打开%s的配置(%s)。", + "button.orespawn.ore_sources": "矿石来源...", + "tooltip.orespawn.ore_sources": "检查等价矿石输出,同时避免生成频率叠加。", + "screen.orespawn.ore_sources": "矿石来源", + "screen.orespawn.ore_source_detail": "矿石来源策略", + "label.orespawn.ore_source.none": "未找到等价的受管理矿石来源。", + "label.orespawn.ore_source.row": "%s - %s - %s 个候选 - %s", + "label.orespawn.ore_source.intro": "选择等价矿石的生成方式。", + "label.orespawn.ore_source.summary": "%s 个候选 - %s", + "button.orespawn.ore_source.review": "审查...", + "button.orespawn.ore_source.configure": "配置...", + "label.orespawn.ore_source.material": "材料:%s", + "label.orespawn.ore_source.domain": "维度/范围:%s", + "label.orespawn.ore_source.channels": "放置通道:%s", + "label.orespawn.ore_source.missing": "缺少放置来源", + "label.orespawn.ore_source.owner": "所有者:%s,版本 %s", + "label.orespawn.ore_source.registry": "注册输出:%s,元数据 %s", + "label.orespawn.ore_source.dictionary": "矿石词典:%s", + "option.orespawn.ore_source.mode": "模式:%s", + "option.orespawn.ore_source.channel": "通道:%s", + "option.orespawn.ore_source.placement": "放置来源:%s", + "option.orespawn.ore_source.weight": "权重", + "mode.orespawn.ore_source.consolidated": "合并", + "mode.orespawn.ore_source.keep_separate": "保持独立", + "status.orespawn.ore_source.consolidated": "已合并", + "status.orespawn.ore_source.separate": "独立", + "status.orespawn.ore_source.review_required": "需要检查", + "status.orespawn.ore_source.missing_source": "来源缺失", + "status.orespawn.ore_source.external_generation": "外部生成", + "tooltip.orespawn.ore_source.open": "打开此材料策略。", + "tooltip.orespawn.ore_source.mode": "合并模式每个通道只使用一份放置预算。保持独立会保留每条已配置规则。", + "tooltip.orespawn.ore_source.channel": "各放置引擎会被独立协调。", + "tooltip.orespawn.ore_source.placement": "选择控制此通道频率、形状、宿主、筛选和高度的规则。", + "tooltip.orespawn.ore_source.weight": "为整条矿脉或已识别矿体选择此输出的正相对概率。", + "tooltip.orespawn.ore_source.external": "此模组在 OreSpawn 外部注册生成;OreSpawn 可以报告但无法抑制。", + "tooltip.orespawn.ore_source.controlled": "OreSpawn 控制此已配置的放置与输出来源。", + "tooltip.orespawn.ore_source.enrichment": "此条目属于富集矿,不是可互换的矿石来源。", + "tooltip.orespawn.ore_source.missing": "此来源不可用。其所属模组恢复后将自动恢复已保存的选择。", + "error.orespawn.ore_source.weight": "输出权重必须为正数。", + "button.orespawn.back": "返回", + "button.orespawn.ore_source.all_groups": "所有分组", + "button.orespawn.ore_source.add_group": "添加分组", + "button.orespawn.ore_source.accept": "接受", + "tooltip.orespawn.ore_source.accept": "保持“保留原始设置”不变,并将此分组标记为已审核。只有在主编辑器中点击“完成”后才会保存。", + "button.orespawn.ore_source.delete_group": "删除", + "button.orespawn.ore_source.dissolve_group": "解散分组", + "button.orespawn.ore_source.confirm_dissolve": "确认解散", + "button.orespawn.ore_source.move": "移动", + "button.orespawn.ore_source.advanced": "高级...", + "button.orespawn.ore_source.hide_advanced": "隐藏高级选项", + "option.orespawn.ore_source.group_name": "分组名称", + "option.orespawn.ore_source.alias": "方块标签条目", + "option.orespawn.ore_source.output_mode": "输出:%s", + "mode.orespawn.ore_source.keep_original": "保持原样", + "mode.orespawn.ore_source.balanced": "均衡", + "mode.orespawn.ore_source.single": "单一", + "mode.orespawn.ore_source.custom": "自定义", + "label.orespawn.ore_source.no_aliases": "无方块标签条目", + "status.orespawn.ore_source.external_short": "外部", + "status.orespawn.ore_source.missing_short": "缺失", + "status.orespawn.ore_source.output_only": "仅输出", + "tooltip.orespawn.ore_source.output_only": "此提供方输出可用,但其放置规则未启用。", + "tooltip.orespawn.ore_source.output_mode": "保持原样会保留每条规则;均衡会平均选择;单一只选择一个方块;自定义允许加权选择。", + "tooltip.orespawn.ore_source.alias": "添加准确的方块标签名称。再次按“移动”以确认从其他分组重新分配。", + "tooltip.orespawn.ore_source.reset_group": "恢复此预设分组的名称和方块标签条目。", + "tooltip.orespawn.ore_source.delete_group": "删除此空的自定义分组,不重写已生成的区块。", + "tooltip.orespawn.ore_source.dissolve_group": "将每个条目还原到推断或预设的分组。按两次以确认。", + "error.orespawn.ore_source.confirm_dissolve": "按“确认解散”可将每个方块标签条目还原到原分组。", + "error.orespawn.ore_source.alias": "请使用准确的方块标签名称,例如 forge:ores/sulfur。", + "error.orespawn.ore_source.alias_owned": "%s 已拥有此条目。按“移动”可重新分配。", + "error.orespawn.ore_source.manage_vanilla_first": "将原版矿石移入其他分组前,请先启用“管理原版矿石”。", + "error.orespawn.ore_source.defaults_write": "无法保存材料分组默认值。", + "screen.orespawn.ore_source.group_settings": "矿石来源分组设置", + "label.orespawn.ore_source.groups": "分组", + "label.orespawn.ore_source.aliases": "方块标签条目", + "label.orespawn.ore_source.placement_rules": "放置规则", + "label.orespawn.ore_source.placement_explanation": "输出选择方块;放置规则选择频率、形状、深度和宿主。", + "label.orespawn.ore_source.standard_veins": "标准矿脉", + "label.orespawn.ore_source.placement_choice_count": "%s(%s 个选项)", + "label.orespawn.ore_source.no_placement_rules": "没有受管理的放置规则", + "tooltip.orespawn.ore_source.group_settings": "编辑此分组的名称、别名和放置规则。", + "tooltip.orespawn.ore_source.remove_alias": "从此分组移除 %s。", + "tooltip.orespawn.ore_source.placement_rules": "输出选择矿石方块;放置规则选择频率、形状、深度和宿主限制。", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "放置来源仅适用于合并的均衡、单一或自定义模式。", + "tooltip.orespawn.ore_source.placement_missing": "保存的放置来源缺失;此通道保持禁用。", + "tooltip.orespawn.ore_source.placement_readonly": "只有一个有效的受管理规则,因此此来源为只读。", + "tooltip.orespawn.ore_source.placement_select": "点击以选择有效的受管理放置来源。", + "tooltip.orespawn.ore_source.external_excluded": "独立外部生成器会显示,但不能选作放置规则。", + "button.orespawn.ore_source.hide_single": "隐藏单项", + "tooltip.orespawn.ore_source.group_colours": "绿色条目只有一个别名和一个输出,无需处理。黄色分组已有保存的规则。红色分组需要处理。显示全部会包含绿色条目。", + "button.orespawn.ore_source.reset_all": "全部重置", + "button.orespawn.ore_source.confirm_reset_all": "确认重置", + "tooltip.orespawn.ore_source.reset_all": "恢复内置材料分组,并根据当前加载的矿石重新创建来源策略。更改会保持待定,直到在主编辑器中点击完成。", + "screen.orespawn.biomes": "生物群系", + "screen.orespawn.biome_palettes": "生物群系调色板", + "screen.orespawn.biome_palette_settings": "生物群系调色板设置", + "button.orespawn.biome.hide_routine": "隐藏常规项", + "button.orespawn.biome.replace_new_terrain": "在新地形中替换为...", + "button.orespawn.biome.replace_with": "在新地形中替换为 %s...", + "button.orespawn.biome.leave_original": "保留原始行为", + "button.orespawn.biome.palettes": "调色板:%s", + "button.orespawn.biome.reset_selected": "重置生物群系", + "button.orespawn.biome.confirm_reset": "确认重置", + "button.orespawn.biome.reset_palette": "重置调色板", + "button.orespawn.biome.confirm_reset_palette": "确认调色板重置", + "button.orespawn.biome.reset_dimension": "重置维度", + "button.orespawn.biome.confirm_reset_dimension": "确认维度重置", + "button.orespawn.biome.reset_all": "重置全部生物群系", + "button.orespawn.biome.confirm_reset_all": "确认全部重置", + "value.orespawn.biome.status.user_replaced": "已在新地形中替换", + "value.orespawn.biome.status.layered": "多个放置规则", + "value.orespawn.biome.status.disabled": "放置已禁用", + "value.orespawn.biome.status.missing": "缺少生物群系或提供者", + "value.orespawn.biome.status.provider_managed": "由提供者管理", + "value.orespawn.biome.status.unmanaged": "已注册,未管理", + "label.orespawn.biome.rule_count": "放置规则:%s", + "label.orespawn.biome.replaced_by": "新地形替换:%s", + "label.orespawn.biome.palette_owner_order": "所有者:%s;生效顺序:%s", + "label.orespawn.biome.surface_effective": "此规则提供最终生效的表面。", + "label.orespawn.biome.surface_overridden": "后续启用的规则会覆盖此表面。", + "label.orespawn.biome.placement_rules": "放置规则", + "label.orespawn.biome.palette_entries": "放置项:%s", + "tooltip.orespawn.biome.no_retrogen": "生物群系替换仅在生成新地形时应用。现有区块绝不会被重写。", + "tooltip.orespawn.biome.override_palette": "保留的最终替换层。请通过各生物群系的替换控件进行编辑。", + "tooltip.orespawn.biome.palette_entries_read_only": "调色板成员由提供者或配置定义。请在目录中选择生物群系以编辑现有规则。", + "option.orespawn.biome.coverage": "覆盖率", + "option.orespawn.biome.fallback_weight": "原始生物群系权重", + "option.orespawn.biome.include_namespaces": "包含的命名空间(逗号分隔)", + "option.orespawn.biome.exclude_namespaces": "排除的命名空间(逗号分隔)" } diff --git a/src/main/resources/assets/orespawn/textures/gui/cog.png b/src/main/resources/assets/orespawn/textures/gui/cog.png new file mode 100644 index 0000000000000000000000000000000000000000..e056ab0e803a1aa2de3fcc38e232ecbc6dc0daa6 GIT binary patch literal 321 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1quc!E2r_jv*HQZ!b7qD>N_CNk{_+IteYxkprPI-#7&inN*a9*mq zSEf$OiaQTCXqnBPAy5>wTTN=|l=YuwA|^SQ%s-s9lchy~ox!&^eoE@g_e=H|Pit8B z+P&v*@+pZN*FtxWpIQHvxSaJBo=iJpn9y_n`st^8Jc1>5zB_Bs^ZwmIOZ`{zuQgwp zE^}m{Naic|mq literal 0 HcmV?d00001 diff --git a/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java new file mode 100644 index 00000000..20309c95 --- /dev/null +++ b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java @@ -0,0 +1,198 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Properties; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.server.ServerWorld; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.dimension.DimensionType; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.server.FMLServerAboutToStartEvent; +import net.minecraftforge.fml.event.server.FMLServerStartedEvent; +import zone.moddev.mc.orespawn.worldgen.GeomeConfig; +import zone.moddev.mc.orespawn.worldgen.NativeOreProbeBridge; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; + +/** Exercises managed quartz in real Nether terrain, including tag and explicit block hosts. */ +@Mod("nativeoreprobe") +public final class NativeOreProbeTestMod { + private static final String QUARTZ = "minecraft:nether_quartz_ore"; + private static final String NETHER = "minecraft:the_nether"; + + public NativeOreProbeTestMod() { + MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, this::prepare); + MinecraftForge.EVENT_BUS.addListener(this::audit); + } + + private void prepare(FMLServerAboutToStartEvent event) { + Path profile = profilePath(event.getServer()); + String phase = System.getProperty("nativeoreprobe.phase", "fresh"); + if ("reload".equals(phase)) return; + try { + JsonObject root; + if ("edited".equals(phase)) { + root = new JsonParser().parse(new String(Files.readAllBytes(profile), + StandardCharsets.UTF_8)).getAsJsonObject(); + } else { + root = GeomeConfig.globalBaseProfile().rootCopy(); + Method defaults = GeomeConfig.class.getDeclaredMethod("defaultOreConfig"); + defaults.setAccessible(true); + JsonObject ores = (JsonObject) defaults.invoke(null); + JsonObject onlyQuartz = new JsonObject(); + onlyQuartz.add(QUARTZ, ores.get(QUARTZ)); + root.add("ores", onlyQuartz); + root.addProperty("manage_vanilla_ores", true); + root.addProperty("place_fluid_deposits", false); + root.add("terrain_dimensions", new JsonObject()); + root.add("ore_source_policies", new JsonObject()); + JsonObject retrogen = new JsonObject(); + retrogen.addProperty("enabled", false); + retrogen.addProperty("force", false); + retrogen.addProperty("revision", 0); + root.add("retrogen", retrogen); + } + JsonObject rule = root.getAsJsonObject("ores").getAsJsonObject(QUARTZ) + .getAsJsonObject("dimensions").getAsJsonObject(NETHER); + rule.addProperty("min_y", "edited".equals(phase) ? 100 : 24); + rule.addProperty("max_y", "edited".equals(phase) ? 104 : 28); + rule.addProperty("frequency", 20); + rule.addProperty("quantity", 14); + if ("explicit".equals(System.getProperty("nativeoreprobe.host"))) { + rule.remove("host_tags"); + JsonArray blocks = new JsonArray(); + blocks.add("minecraft:netherrack"); + rule.add("host_blocks", blocks); + } + Files.createDirectories(profile.getParent()); + Files.write(profile, new GsonBuilder().setPrettyPrinting().create().toJson(root) + .getBytes(StandardCharsets.UTF_8)); + } catch (IOException | ReflectiveOperationException failure) { + throw new IllegalStateException("Could not prepare disposable quartz profile", failure); + } + } + + private void audit(FMLServerStartedEvent event) { + MinecraftServer server = event.getServer(); + String phase = System.getProperty("nativeoreprobe.phase", "fresh"); + if (!NativeOreProbeBridge.controlsQuartz()) { + throw new IllegalStateException("Quartz is not controlled by OreSpawn before generation"); + } + if (WorldGeologyProfileManager.activeProfile().oreRetrogenEnabled()) { + throw new IllegalStateException("The quartz test must not enable retrogen"); + } + ServerWorld world = server.getWorld(DimensionType.THE_NETHER); + if (world == null) throw new IllegalStateException("Nether is unavailable"); + Path marker = worldRoot(server).resolve("nativeoreprobe.properties"); + Properties previous = new Properties(); + try { + if (!"fresh".equals(phase)) { + try (InputStream input = Files.newInputStream(marker)) { previous.load(input); } + } + Properties current = inspect(world, -8, 24, 28); + current.setProperty("profile_sha256", hex(digest().digest(Files.readAllBytes(profilePath(server))))); + if (!"fresh".equals(phase)) { + for (String name : current.stringPropertyNames()) { + if ("edited".equals(phase) && "profile_sha256".equals(name)) continue; + if (!current.getProperty(name).equals(previous.getProperty(name))) { + throw new IllegalStateException("Reload changed " + name + ": " + previous + " -> " + current); + } + } + } + if ("edited".equals(phase)) { + Properties newTerrain = inspect(world, -24, 100, 104); + current.setProperty("new_terrain_quartz", newTerrain.getProperty("quartz")); + current.setProperty("old_profile_sha256", previous.getProperty("profile_sha256")); + } + current.setProperty(phase + "_verified", "true"); + try (OutputStream output = Files.newOutputStream(marker)) { current.store(output, "Managed Nether quartz"); } + System.out.println("NATIVE_ORE_PROBE " + phase + " " + current); + } catch (IOException failure) { + throw new IllegalStateException("Could not save quartz audit", failure); + } + server.initiateShutdown(false); + } + + private static Properties inspect(ServerWorld world, int origin, int minY, int maxY) { + // Finish the surrounding row before hashing veins that cross chunk edges. + for (int z = origin - 1; z <= origin + 3; z++) { + for (int x = origin - 1; x <= origin + 3; x++) world.getChunk(x, z); + } + MessageDigest blocks = digest(); + MessageDigest biomes = digest(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + int quartz = 0; + for (int z = origin; z <= origin + 2; z++) { + for (int x = origin; x <= origin + 2; x++) { + Chunk chunk = world.getChunk(x, z); + for (int dz = 0; dz < 16; dz++) { + for (int dx = 0; dx < 16; dx++) { + cursor.setPos((x << 4) + dx, 64, (z << 4) + dz); + biomes.update(world.getBiome(cursor).getRegistryName().toString().getBytes(StandardCharsets.UTF_8)); + biomes.update((byte) 0); + for (int y = 0; y < 256; y++) { + BlockState state = chunk.getBlockState(cursor.setPos((x << 4) + dx, y, (z << 4) + dz)); + int id = Block.getStateId(state); + blocks.update((byte) (id >>> 24)); + blocks.update((byte) (id >>> 16)); + blocks.update((byte) (id >>> 8)); + blocks.update((byte) id); + if (state.getBlock() == Blocks.NETHER_QUARTZ_ORE) { + if (y < minY || y > maxY) throw new IllegalStateException("Quartz outside managed range at " + cursor); + quartz++; + } + } + } + } + } + } + if (quartz == 0) throw new IllegalStateException("Managed quartz did not generate"); + Properties result = new Properties(); + result.setProperty("quartz", Integer.toString(quartz)); + result.setProperty("blocks_sha256", hex(blocks.digest())); + result.setProperty("biomes_sha256", hex(biomes.digest())); + return result; + } + + private static Path worldRoot(MinecraftServer server) { + return server.getActiveAnvilConverter().getFile(server.getFolderName(), "level.dat") + .toPath().toAbsolutePath().normalize().getParent(); + } + + private static Path profilePath(MinecraftServer server) { + return worldRoot(server).resolve("serverconfig/orespawn-worldgen.json"); + } + + private static MessageDigest digest() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); } + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(Character.forDigit((value >>> 4) & 15, 16)); + result.append(Character.forDigit(value & 15, 16)); + } + return result.toString(); + } +} diff --git a/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/worldgen/NativeOreProbeBridge.java b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/worldgen/NativeOreProbeBridge.java new file mode 100644 index 00000000..3b55e873 --- /dev/null +++ b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/worldgen/NativeOreProbeBridge.java @@ -0,0 +1,14 @@ +package zone.moddev.mc.orespawn.worldgen; + +import net.minecraft.block.Blocks; +import net.minecraft.util.ResourceLocation; + +/** Keeps cache inspection in the test fixture, not in OreSpawn's public API. */ +public final class NativeOreProbeBridge { + private NativeOreProbeBridge() { } + + public static boolean controlsQuartz() { + return OreSpawnOreGeneration.takesOverVanillaOre( + new ResourceLocation("minecraft:the_nether"), Blocks.NETHER_QUARTZ_ORE); + } +} diff --git a/src/nativeOreIntegrationTest/resources/META-INF/mods.toml b/src/nativeOreIntegrationTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..2d218bb3 --- /dev/null +++ b/src/nativeOreIntegrationTest/resources/META-INF/mods.toml @@ -0,0 +1,23 @@ +modLoader="javafml" +loaderVersion="[28,)" +license="LGPL-2.1" + +[[mods]] +modId="nativeoreprobe" +version="1" +displayName="OreSpawn Native Ore Test" +description='''Test-only managed Nether quartz audit.''' + +[[dependencies.nativeoreprobe]] +modId="orespawn" +mandatory=true +versionRange="[4.1.0,5.0.0)" +ordering="AFTER" +side="BOTH" + +[[dependencies.nativeoreprobe]] +modId="minecraft" +mandatory=true +versionRange="[1.14.4]" +ordering="NONE" +side="BOTH" diff --git a/src/nativeOreIntegrationTest/resources/pack.mcmeta b/src/nativeOreIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..bb8df362 --- /dev/null +++ b/src/nativeOreIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "OreSpawn native ore qualification fixture", + "forge:resource_pack_format": 4, + "forge:data_pack_format": 4, + "pack_format": 4 + } +} diff --git a/src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/testmod/FutureAdvantageTestMod.java b/src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/testmod/FutureAdvantageTestMod.java new file mode 100644 index 00000000..1e90a0aa --- /dev/null +++ b/src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/testmod/FutureAdvantageTestMod.java @@ -0,0 +1,38 @@ +package zone.moddev.mc.orespawn.testmod; + +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.InterModEnqueueEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.WorldgenProvider; + +/** Test-only stand-in for a future Advantage ore provider. */ +@Mod(FutureAdvantageTestMod.MODID) +public final class FutureAdvantageTestMod { + static final String MODID = "electricadvantage"; + + public FutureAdvantageTestMod() { + FMLJavaModLoadingContext.get().getModEventBus().addListener(this::enqueue); + } + + private void enqueue(InterModEnqueueEvent event) { + WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); + provider.ore(id("electricadvantage:sulfur"), id("minecraft:yellow_wool"), ore -> ore + .material(id("orespawn:sulfur")) + .dimension(id("minecraft:overworld"), rule -> rule + .yRange(20, 42).attempts(20.0D).quantity(7) + .hostBlock(id("minecraft:stone")))); + provider.ore(id("electricadvantage:sulfur_output"), id("minecraft:green_wool"), ore -> ore + .material(id("orespawn:sulfur")) + .enabled(false) + .dimension(id("minecraft:overworld"), rule -> rule + .yRange(20, 42).attempts(20.0D).quantity(7) + .hostBlock(id("minecraft:stone")))); + if (!OreSpawnApi.enqueue(provider.build())) { + throw new IllegalStateException("Advantage fixture was not sent to OreSpawn"); + } + } + + private static ResourceLocation id(String value) { return new ResourceLocation(value); } +} diff --git a/src/oreSourceAdvantageTest/resources/META-INF/mods.toml b/src/oreSourceAdvantageTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..1c0b9f94 --- /dev/null +++ b/src/oreSourceAdvantageTest/resources/META-INF/mods.toml @@ -0,0 +1,16 @@ +modLoader="javafml" +loaderVersion="[28,)" +license="LGPL-2.1" + +[[mods]] +modId="electricadvantage" +version="1" +displayName="Future Electric Advantage Test" +description='''Test-only OreSpawn provider fixture.''' + +[[dependencies.electricadvantage]] +modId="orespawn" +mandatory=true +versionRange="[4.1.0,5.0.0)" +ordering="AFTER" +side="BOTH" diff --git a/src/oreSourceAdvantageTest/resources/data/forge/tags/blocks/ores/sulfur.json b/src/oreSourceAdvantageTest/resources/data/forge/tags/blocks/ores/sulfur.json new file mode 100644 index 00000000..e3a06399 --- /dev/null +++ b/src/oreSourceAdvantageTest/resources/data/forge/tags/blocks/ores/sulfur.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": ["minecraft:yellow_wool", "minecraft:green_wool", "minecraft:redstone_ore"] +} diff --git a/src/oreSourceAdvantageTest/resources/pack.mcmeta b/src/oreSourceAdvantageTest/resources/pack.mcmeta new file mode 100644 index 00000000..68ce4371 --- /dev/null +++ b/src/oreSourceAdvantageTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "Test-only Advantage OreSpawn fixture", + "forge:resource_pack_format": 4, + "forge:data_pack_format": 4, + "pack_format": 4 + } +} diff --git a/src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java b/src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java new file mode 100644 index 00000000..b7d08b61 --- /dev/null +++ b/src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java @@ -0,0 +1,130 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import com.google.gson.JsonObject; + +import net.minecraft.block.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.server.ServerWorld; +import net.minecraft.world.dimension.DimensionType; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.InterModEnqueueEvent; +import net.minecraftforge.fml.event.server.FMLServerStartedEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.WorldgenProvider; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; + +/** Test-only stand-in for a future Base Minerals provider. */ +@Mod(FutureMineralsTestMod.MODID) +public final class FutureMineralsTestMod { + static final String MODID = "baseminerals"; + private static final String PHASE = "oresourceprobe.phase"; + + public FutureMineralsTestMod() { + FMLJavaModLoadingContext.get().getModEventBus().addListener(this::enqueue); + MinecraftForge.EVENT_BUS.addListener(this::audit); + } + + private void enqueue(InterModEnqueueEvent event) { + WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); + provider.ore(id("baseminerals:sulfur"), id("minecraft:blue_wool"), ore -> ore + .material(id("orespawn:sulfur")) + .dimension(id("minecraft:overworld"), rule -> rule + .yRange(20, 42).attempts(20.0D).quantity(7) + .hostBlock(id("minecraft:stone")))); + if (!OreSpawnApi.enqueue(provider.build())) { + throw new IllegalStateException("Base Minerals fixture was not sent to OreSpawn"); + } + } + + private void audit(FMLServerStartedEvent event) { + String phase = System.getProperty(PHASE, ""); + if (!"fresh".equals(phase) && !"reload".equals(phase) + && !"missing".equals(phase) && !"restored".equals(phase)) return; + boolean both = ModList.get().isLoaded("electricadvantage"); + boolean sharedGroup = both || "missing".equals(phase); + MinecraftServer server = event.getServer(); + JsonObject profile = WorldGeologyProfileManager.activeProfile().rootCopy(); + String key = "orespawn:sulfur|minecraft:overworld"; + JsonObject policy = profile.getAsJsonObject("ore_source_policies").getAsJsonObject(key); + if (policy == null) throw new IllegalStateException("Sulfur policy is missing"); + String mode = policy.get("mode").getAsString(); + if (!mode.equals(sharedGroup ? "consolidated" : "keep_separate")) { + throw new IllegalStateException("Unexpected Sulfur policy with " + (both ? "both" : "one") + + " providers: " + mode); + } + if (sharedGroup) { + if (!"balanced".equals(policy.get("output_mode").getAsString()) + || policy.getAsJsonObject("outputs").size() != 3 + || policy.getAsJsonObject("placement_sources").size() != 1 + || !"baseminerals:sulfur".equals(policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString())) { + throw new IllegalStateException("Balanced Sulfur policy lacks the three outputs and one budget: " + + policy); + } + } + ServerWorld world = server.getWorld(DimensionType.OVERWORLD); + if (world == null) throw new IllegalStateException("Overworld is unavailable"); + int blue = 0, yellow = 0, green = 0; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int spawnChunkX = world.getSpawnPoint().getX() >> 4; + int spawnChunkZ = world.getSpawnPoint().getZ() >> 4; + for (int chunkX = spawnChunkX - 1; chunkX <= spawnChunkX + 1; chunkX++) { + for (int chunkZ = spawnChunkZ - 1; chunkZ <= spawnChunkZ + 1; chunkZ++) { + if ("fresh".equals(phase) && !world.getChunkProvider().chunkExists(chunkX, chunkZ)) { + throw new IllegalStateException("Spawn chunk was not generated before the audit"); + } + world.getChunk(chunkX, chunkZ); + for (int x = chunkX * 16; x < (chunkX + 1) * 16; x++) { + for (int z = chunkZ * 16; z < (chunkZ + 1) * 16; z++) { + for (int y = 20; y <= 42; y++) { + pos.setPos(x, y, z); + if (world.getBlockState(pos).getBlock() == Blocks.BLUE_WOOL) blue++; + if (world.getBlockState(pos).getBlock() == Blocks.YELLOW_WOOL) yellow++; + if (world.getBlockState(pos).getBlock() == Blocks.GREEN_WOOL) green++; + } + } + } + } + } + if (blue == 0 || (sharedGroup && (yellow == 0 || green == 0)) + || (!sharedGroup && (yellow != 0 || green != 0))) { + throw new IllegalStateException("Wrong ore outputs in generated chunks: blue=" + blue + + " yellow=" + yellow + " green=" + green); + } + Path marker = server.getActiveAnvilConverter().getFile(server.getFolderName(), "level.dat") + .toPath().toAbsolutePath().normalize().getParent().resolve("oresourceprobe.properties"); + Properties values = new Properties(); + values.setProperty("mode", mode); + values.setProperty("blue", Integer.toString(blue)); + values.setProperty("yellow", Integer.toString(yellow)); + values.setProperty("green", Integer.toString(green)); + if (!"fresh".equals(phase)) { + Properties previous = new Properties(); + try (InputStream input = Files.newInputStream(marker)) { previous.load(input); } + catch (IOException failure) { throw new IllegalStateException("Missing fresh ore audit", failure); } + for (String name : values.stringPropertyNames()) { + if (!values.getProperty(name).equals(previous.getProperty(name))) { + throw new IllegalStateException("Reload changed " + name + ": " + previous + " -> " + values); + } + } + values.setProperty(phase + "_verified", "true"); + } + try (OutputStream output = Files.newOutputStream(marker)) { values.store(output, "Ore source fixture"); } + catch (IOException failure) { throw new IllegalStateException("Could not save ore audit", failure); } + server.initiateShutdown(false); + } + + private static ResourceLocation id(String value) { return new ResourceLocation(value); } +} diff --git a/src/oreSourceMineralsTest/resources/META-INF/mods.toml b/src/oreSourceMineralsTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..b41dd7e2 --- /dev/null +++ b/src/oreSourceMineralsTest/resources/META-INF/mods.toml @@ -0,0 +1,16 @@ +modLoader="javafml" +loaderVersion="[28,)" +license="LGPL-2.1" + +[[mods]] +modId="baseminerals" +version="1" +displayName="Future Base Minerals Test" +description='''Test-only OreSpawn provider fixture.''' + +[[dependencies.baseminerals]] +modId="orespawn" +mandatory=true +versionRange="[4.1.0,5.0.0)" +ordering="AFTER" +side="BOTH" diff --git a/src/oreSourceMineralsTest/resources/data/forge/tags/blocks/ores/sulfur.json b/src/oreSourceMineralsTest/resources/data/forge/tags/blocks/ores/sulfur.json new file mode 100644 index 00000000..439ddefb --- /dev/null +++ b/src/oreSourceMineralsTest/resources/data/forge/tags/blocks/ores/sulfur.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": ["minecraft:blue_wool"] +} diff --git a/src/oreSourceMineralsTest/resources/pack.mcmeta b/src/oreSourceMineralsTest/resources/pack.mcmeta new file mode 100644 index 00000000..ff5634db --- /dev/null +++ b/src/oreSourceMineralsTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "Test-only Base Minerals OreSpawn fixture", + "forge:resource_pack_format": 4, + "forge:data_pack_format": 4, + "pack_format": 4 + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index 35a3ea09..d74367fa 100644 --- a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java @@ -31,9 +31,8 @@ class LocalizationParityTest { "es_mx.json", "fr_ca.json", "fr_fr.json", "ja_jp.json", "ko_kr.json", "pt_br.json", "ru_ru.json", "zh_cn.json"); /** - * Brand names, format-only values, canonical engine/pattern names, and words - * whose spelling is already valid in at least one shipped target language. - * Human-facing prose must never be added here merely to make this test pass. + * Names and format strings that can legitimately match English in another shipped language. + * Do not add untranslated sentences just to make the test pass. */ private static final Map> INTENTIONAL_ENGLISH_VALUES = ImmutableMap.>builder() @@ -47,6 +46,7 @@ class LocalizationParityTest { "zh_cn.json")) .put("option.orespawn.mod_filter", ImmutableSet.of( "de_au.json", "de_de.json", "fr_ca.json", "fr_fr.json")) + .put("screen.orespawn.biomes", ImmutableSet.of("fr_ca.json", "fr_fr.json")) .put("tab.orespawn.biomes", ImmutableSet.of("fr_ca.json", "fr_fr.json")) .put("tab.orespawn.geomes", ImmutableSet.of("de_au.json", "de_de.json")) .put("tab.orespawn.placement", ImmutableSet.of("fr_ca.json", "fr_fr.json")) @@ -85,7 +85,7 @@ class LocalizationParityTest { void everyLocaleMatchesEnglishKeysAndFormatting() throws Exception { JsonObject english = read(LANG_DIR.resolve("en_us.json")); Set englishKeys = JsonCopies.keys(english); - assertEquals(357, englishKeys.size(), + assertEquals(510, englishKeys.size(), "The target locale contract changed; review every shipped translation"); Set localeFiles = new HashSet<>(); Set observedIntentionalEnglishValues = new HashSet<>(); diff --git a/src/test/java/zone/moddev/mc/orespawn/PlatformClassShadowingTest.java b/src/test/java/zone/moddev/mc/orespawn/PlatformClassShadowingTest.java index 361a5515..f1d53567 100644 --- a/src/test/java/zone/moddev/mc/orespawn/PlatformClassShadowingTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/PlatformClassShadowingTest.java @@ -17,7 +17,7 @@ import org.junit.jupiter.api.Test; -/** Prevents target adapters from replacing classes supplied by Minecraft or Forge. */ +/** Prevents compatibility helpers from shadowing Minecraft or Forge classes. */ class PlatformClassShadowingTest { private static final Path MAIN_JAVA = Paths.get("src", "main", "java"); diff --git a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java index 77e7365c..7d288eaa 100644 --- a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -7,7 +7,6 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; import java.util.Properties; @@ -25,12 +24,14 @@ void usesOreSpawnSpecificMavenNamespace() throws Exception { @Test void verifiesGeneratedMavenCoordinatesBeforeCheckAndPublication() throws Exception { - Path buildFile = Paths.get("build.gradle"); - String build = new String(Files.readAllBytes(buildFile), StandardCharsets.UTF_8); - assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); - assertTrue(build.contains("generatePomFileForMavenJavaPublication")); - assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); - assertTrue(build.contains("expectedMavenCoordinate")); + String root = readBuildFile("build.gradle"); + String publishing = readBuildFile("gradle/release/publishing.gradle"); + String eclipse = readBuildFile("gradle/ide/eclipse.gradle"); + assertTrue(publishing.contains("tasks.register('verifyMavenCoordinates')")); + assertTrue(publishing.contains("generatePomFileForMavenJavaPublication")); + assertTrue(publishing.contains("dependsOn tasks.named('verifyMavenCoordinates')") + || eclipse.contains("dependsOn tasks.named('verifyMavenCoordinates')")); + assertTrue(root.contains("expectedMavenCoordinate")); } @Test @@ -63,6 +64,10 @@ private static String readWorkflow(String name) throws Exception { StandardCharsets.UTF_8); } + private static String readBuildFile(String name) throws Exception { + return new String(Files.readAllBytes(Paths.get(name)), StandardCharsets.UTF_8); + } + private static void assertPinnedToolchains(String workflow, int expectedJobs, int expectedPathUses) { String java25 = "java-version: '25.0.3+9.0.LTS'"; String java8 = "java-version: '8.0.502+7'"; diff --git a/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java new file mode 100644 index 00000000..e7ff2f18 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.orespawn.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import net.minecraft.util.ResourceLocation; + +class OreGenerationContextTest { + @Test + void extendsTheOriginalPlacementContractWithoutChangingItsAbi() { + assertTrue(OrePlacementContext.class.isAssignableFrom(OreGenerationContext.class)); + Set baseMethods = Arrays.stream(OrePlacementContext.class.getDeclaredMethods()) + .map(Method::getName).collect(Collectors.toSet()); + assertFalse(baseMethods.contains("worldSeed")); + assertFalse(baseMethods.contains("dimension")); + assertFalse(baseMethods.contains("chunkX")); + assertFalse(baseMethods.contains("chunkZ")); + assertFalse(baseMethods.contains("geologySampler")); + assertEquals(boolean.class, method(OrePlacementContext.class, "tryPlace", + int.class, int.class, int.class).getReturnType()); + } + + private static Method method(Class owner, String name, Class... parameters) { + try { return owner.getMethod(name, parameters); } + catch (ReflectiveOperationException missing) { throw new AssertionError(missing); } + } + + @Test + void exposesTheStableRegionDepositIdentityContract() throws Exception { + assertEquals(long.class, OreGenerationContext.class.getMethod("worldSeed").getReturnType()); + assertEquals(ResourceLocation.class, + OreGenerationContext.class.getMethod("dimension").getReturnType()); + assertEquals(int.class, OreGenerationContext.class.getMethod("chunkX").getReturnType()); + assertEquals(int.class, OreGenerationContext.class.getMethod("chunkZ").getReturnType()); + assertEquals(Optional.class, + OreGenerationContext.class.getMethod("geologySampler").getReturnType()); + assertTrue(OreGenerationContext.class.getMethod("tryPlace", + int.class, int.class, int.class, long.class).isDefault()); + } + + @Test + void oreSpawnPatternContextImplementsTheExtendedContract() throws Exception { + Class implementation = Class.forName( + "zone.moddev.mc.orespawn.worldgen.OreSpawnOreGeneration$PatternContext"); + assertTrue(OreGenerationContext.class.isAssignableFrom(implementation)); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java index 7993377d..179dac1a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -40,18 +40,20 @@ void terrainHostContractRetainsNaturalSourceOrder() { } @Test - void serializesTypedSchemaFourProvider() { + void serializesTypedSchemaFiveProvider() { ResourceLocation overworld = id("minecraft:overworld"); WorldgenProvider provider = WorldgenProvider.builder("examplemod", 4) .rock(id("examplemod:slate"), GeologyFamily.METAMORPHIC, rock -> rock .depth(20, 36).weight(1.25D).oreReplaceable(true)) .ore(id("examplemod:tin_ore"), ore -> ore + .material(id("orespawn:tin")) .output(id("examplemod:tin_ore"), 9.0D) .output(id("examplemod:rich_tin_ore"), 1.0D, -64, 24) .suppressVanilla(true).retrogen(false) .dimension(overworld, dimension -> dimension .yRange(-16, 96).attempts(6.5D).quantity(8) .pattern(OrePattern.CLUSTER) + .placementChannel(id("orespawn:standard")) .heightDistribution(OreHeightDistribution.BOTTOM_TRIANGLE) .discardChanceOnAirExposure(0.75D) .hostFamily(GeologyFamily.METAMORPHIC) @@ -61,7 +63,7 @@ void serializesTypedSchemaFourProvider() { .build(); JsonObject json = provider.toJson(); - assertEquals(4, json.get("schema_version").getAsInt()); + assertEquals(5, json.get("schema_version").getAsInt()); assertEquals("examplemod", json.get("provider_modid").getAsString()); assertTrue(json.getAsJsonObject("rocks").has("examplemod:rock/examplemod/slate")); assertEquals("examplemod:slate", json.getAsJsonObject("rocks") @@ -69,6 +71,9 @@ void serializesTypedSchemaFourProvider() { assertTrue(json.getAsJsonObject("ores").has("examplemod:ore/examplemod/tin_ore")); JsonObject ore = json.getAsJsonObject("ores").getAsJsonObject("examplemod:ore/examplemod/tin_ore"); assertEquals(2, ore.getAsJsonArray("outputs").size()); + assertEquals("orespawn:tin", ore.get("material").getAsString()); + assertEquals("orespawn:standard", ore.getAsJsonObject("dimensions") + .getAsJsonObject("minecraft:overworld").get("placement_channel").getAsString()); assertFalse(ore.get("retrogen").getAsBoolean()); assertEquals(0.75D, ore.getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") .getAsJsonArray("host_blocks").get(0).getAsJsonObject().get("weight").getAsDouble()); diff --git a/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java new file mode 100644 index 00000000..ac9762cd --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java @@ -0,0 +1,125 @@ +package zone.moddev.mc.orespawn.api.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.text.StringTextComponent; + +class WorldSettingsExtensionRegistryTest { + @Test + void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() { + WorldSettingsExtension first = register("order_first", "settings", parent -> new Screen(new StringTextComponent("test")) { }); + WorldSettingsExtension second = register("order_second", "settings", parent -> new Screen(new StringTextComponent("test")) { }); + List extensions = WorldSettingsExtensionRegistry.extensions(); + + assertTrue(extensions.indexOf(first) < extensions.indexOf(second)); + assertThrows(UnsupportedOperationException.class, () -> extensions.add(first)); + } + + @Test + void rejectsDuplicateOwnershipAndInvalidRegistrations() { + ResourceLocation id = id("duplicate_owner", "first"); + WorldSettingsExtensionRegistry.register(id, "button.realisticdeposits.settings", + parent -> new Screen(new StringTextComponent("test")) { }); + assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register( + id("duplicate_owner", "second"), + "button.realisticdeposits.other", parent -> new Screen(new StringTextComponent("test")) { })); + assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register( + id("blank_key", "settings"), " ", parent -> new Screen(new StringTextComponent("test")) { })); + assertThrows(IllegalArgumentException.class, () -> + WorldSettingsExtensionRegistry.registerConfigScreen("Bad Mod", parent -> parent)); + assertThrows(NullPointerException.class, () -> + WorldSettingsExtensionRegistry.registerConfigScreen("null_factory", null)); + } + + @Test + void preferredRegistrationOwnsOneConventionalScreenPerMod() { + WorldSettingsExtension extension = WorldSettingsExtensionRegistry.registerConfigScreen( + "preferred_form", parent -> parent); + + assertEquals("preferred_form", extension.id().getNamespace()); + assertEquals(new ResourceLocation("preferred_form", "configuration"), extension.id()); + assertEquals("button.orespawn.mod.configure", extension.buttonTranslationKey()); + assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register( + id("preferred_form", "legacy_form"), "button.example.settings", parent -> parent)); + } + + @Test + void passesTheCurrentOreSpawnScreenToTheFactory() { + AtomicReference receivedParent = new AtomicReference<>(); + Screen expectedParent = new Screen(new StringTextComponent("test")) { }; + Screen expectedChild = new Screen(new StringTextComponent("test")) { }; + WorldSettingsExtension extension = register("parent_handoff", "settings", parent -> { + receivedParent.set(parent); + return expectedChild; + }); + + assertSame(expectedChild, extension.createScreen(expectedParent)); + assertSame(expectedParent, receivedParent.get()); + assertEquals("button.realisticdeposits.settings", extension.buttonTranslationKey()); + } + + @Test + void screenResultsMayBeNullOrThrowForTheDirectoryToHandle() { + Screen parent = new Screen(new StringTextComponent("test")) { }; + WorldSettingsExtension missing = register("missing_screen", "settings", ignored -> null); + WorldSettingsExtension failing = register("throwing_screen", "settings", ignored -> { + throw new IllegalArgumentException("fixture"); + }); + + assertSame(null, missing.createScreen(parent)); + assertThrows(IllegalArgumentException.class, () -> failing.createScreen(parent)); + } + + @Test + void oldAndPreferredJvmDescriptorsRemainAvailable() throws Exception { + assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("register", + ResourceLocation.class, String.class, WorldSettingsScreenFactory.class)); + assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("registerConfigScreen", + String.class, WorldSettingsScreenFactory.class)); + } + + @Test + void mainScreenUsesOneBoundedDirectoryEntryPointInsteadOfExtensionRows() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "client", "OreSpawnWorldSettingsScreen.java")), + StandardCharsets.UTF_8); + assertTrue(!source.contains("WorldSettingsExtensionRegistry.extensions()")); + assertEquals(2, occurrences(source, "button.orespawn.mods"), + "Each mutually exclusive main-screen layout owns exactly one Mods button"); + assertTrue(source.contains("new TranslationTextComponent(\"button.orespawn.mods\")")); + assertTrue(source.contains("new OreSpawnModsScreen(this)")); + assertTrue(source.contains("syncSession();")); + } + + private static int occurrences(String source, String value) { + int count = 0; + for (int index = 0; (index = source.indexOf(value, index)) >= 0; index += value.length()) { + count++; + } + return count; + } + + private static WorldSettingsExtension register(String modId, String path, + WorldSettingsScreenFactory factory) { + return WorldSettingsExtensionRegistry.register(id(modId, path), + "button.realisticdeposits.settings", factory); + } + + private static ResourceLocation id(String modId, String path) { + return new ResourceLocation(modId, path); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java new file mode 100644 index 00000000..547b221d --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java @@ -0,0 +1,167 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.test.Forge28TestBootstrap; + +import net.minecraft.world.biome.Biomes; + +class BiomeDirectoryModelTest { + @BeforeAll + static void registerVanilla() { + Forge28TestBootstrap.registerVanilla(); + } + + @Test + void derivesVanillaRoutineDimensionsFromForgeBiomeTypes() { + assertEquals("minecraft:overworld", BiomeDirectoryModel.routineDimension(Biomes.BEACH)); + assertEquals("minecraft:the_nether", BiomeDirectoryModel.routineDimension(Biomes.NETHER)); + assertEquals("minecraft:the_end", BiomeDirectoryModel.routineDimension(Biomes.THE_END)); + } + + @Test + void classifiesManagedLayeredDisabledReplacedUnmanagedAndMissingBiomes() { + JsonObject profile = root(); + addPalette(profile, "provider:first", true, "example:managed", "example:layered", "example:disabled"); + profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:first") + .getAsJsonObject("biomes").getAsJsonObject("example:disabled") + .addProperty("enabled", false); + addPalette(profile, "provider:second", true, "example:layered", "missing:biome"); + BiomeReplacementRules.set(profile, "minecraft:overworld", "example:managed", "example:unmanaged"); + JsonObject defaults = new JsonObject(); + addPalette(defaultsRoot(defaults), "provider:first", true, "example:managed"); + + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, defaults, + new LinkedHashSet<>(Collections.singletonList("provider")), Arrays.asList( + loaded("example:managed", "Managed"), loaded("example:layered", "Layered"), + loaded("example:disabled", "Disabled"), loaded("example:unmanaged", "Unmanaged")), + Collections.singletonList("minecraft:overworld")); + assertEquals(BiomeDirectoryModel.Status.USER_REPLACED, find(snapshot, "example:managed").status); + assertEquals(BiomeDirectoryModel.Status.LAYERED, find(snapshot, "example:layered").status); + assertEquals(BiomeDirectoryModel.Status.DISABLED, find(snapshot, "example:disabled").status); + assertEquals(BiomeDirectoryModel.Status.UNMANAGED, find(snapshot, "example:unmanaged").status); + assertEquals(BiomeDirectoryModel.Status.MISSING, find(snapshot, "missing:biome").status); + assertEquals(4, snapshot.entries("minecraft:overworld", false).size()); + assertEquals(5, snapshot.entries("minecraft:overworld", true).size()); + } + + @Test + void retainsProfilePaletteOrderAndMarksOnlyLastEnabledSurfaceEffective() { + JsonObject profile = root(); + addPalette(profile, "z:first", true, "example:managed"); + addPalette(profile, "a:second", true, "example:managed"); + for (String id : Arrays.asList("z:first", "a:second")) { + profile.getAsJsonObject("biome_palettes").getAsJsonObject(id) + .getAsJsonObject("biomes").getAsJsonObject("example:managed") + .add("surface", new JsonObject()); + profile.getAsJsonObject("biome_palettes").getAsJsonObject(id) + .getAsJsonObject("biomes").getAsJsonObject("example:managed") + .getAsJsonObject("surface").addProperty("top_block", "minecraft:stone"); + } + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.emptySet(), + Collections.singletonList(loaded("example:managed", "Managed")), + Collections.singletonList("minecraft:overworld")); + assertEquals("z:first", snapshot.palettes.get(0).id); + assertEquals("a:second", snapshot.palettes.get(1).id); + assertTrue(!find(snapshot, "example:managed").placements.get(0).effectiveSurface); + assertTrue(find(snapshot, "example:managed").placements.get(1).effectiveSurface); + } + + @Test + void returnedCollectionsAndDefinitionsAreDetached() { + JsonObject profile = root(); + addPalette(profile, "provider:first", true, "example:managed"); + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.emptySet(), + Collections.singletonList(loaded("example:managed", "Managed")), + Collections.singletonList("minecraft:overworld")); + assertThrows(UnsupportedOperationException.class, () -> snapshot.entries.clear()); + snapshot.palettes.get(0).definition.addProperty("enabled", false); + assertTrue(profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:first") + .get("enabled").getAsBoolean()); + } + + @Test + void showAllFiltersRoutineBiomesByDimensionAndRetainsExplicitPlacements() { + JsonObject profile = root(); + addPalette(profile, "provider:nether", true, "example:declared_nether"); + profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:nether") + .addProperty("dimension", "minecraft:the_nether"); + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.singleton("provider"), Arrays.asList( + loaded("minecraft:beaches", "Beach", "minecraft:overworld"), + loaded("minecraft:hell", "Hell", "minecraft:the_nether"), + loaded("minecraft:sky", "The End", "minecraft:the_end"), + loaded("example:declared_nether", "Declared Nether", "minecraft:overworld")), + Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")); + + assertTrue(contains(snapshot, "minecraft:overworld", "minecraft:beaches")); + assertFalse(contains(snapshot, "minecraft:overworld", "minecraft:hell")); + assertFalse(contains(snapshot, "minecraft:overworld", "minecraft:sky")); + assertFalse(contains(snapshot, "minecraft:overworld", "example:declared_nether")); + assertTrue(contains(snapshot, "minecraft:the_nether", "minecraft:hell")); + assertTrue(contains(snapshot, "minecraft:the_nether", "example:declared_nether")); + assertFalse(contains(snapshot, "minecraft:the_nether", "minecraft:beaches")); + assertTrue(contains(snapshot, "minecraft:the_end", "minecraft:sky")); + assertFalse(contains(snapshot, "minecraft:the_end", "minecraft:beaches")); + } + + private static BiomeDirectoryModel.BiomeEntry find(BiomeDirectoryModel.Snapshot snapshot, String id) { + return snapshot.entries.stream().filter(entry -> id.equals(entry.id)).findFirst().orElseThrow(AssertionError::new); + } + + private static BiomeDirectoryModel.LoadedBiome loaded(String id, String name) { + return new BiomeDirectoryModel.LoadedBiome(id, name, "Example", "1"); + } + + private static BiomeDirectoryModel.LoadedBiome loaded(String id, String name, String dimension) { + return new BiomeDirectoryModel.LoadedBiome(id, name, "Example", "1", dimension); + } + + private static boolean contains(BiomeDirectoryModel.Snapshot snapshot, String dimension, String id) { + return snapshot.entries(dimension, true).stream().anyMatch(entry -> id.equals(entry.id)); + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("biome_palettes", new JsonObject()); + return root; + } + + private static JsonObject defaultsRoot(JsonObject palettes) { + JsonObject root = new JsonObject(); + root.add("biome_palettes", palettes); + return root; + } + + private static void addPalette(JsonObject root, String id, boolean enabled, String... biomes) { + JsonObject section = root.has("biome_palettes") ? root.getAsJsonObject("biome_palettes") : root; + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", enabled); + palette.addProperty("source_provider", "provider"); + JsonObject entries = new JsonObject(); + for (String biome : biomes) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + placement.add("similar_biomes", new JsonArray()); + entries.add(biome, placement); + } + palette.add("biomes", entries); + section.add(id, palette); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java b/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java new file mode 100644 index 00000000..daa382d1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java @@ -0,0 +1,59 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +class BiomeReplacementRulesTest { + @Test + void storesExactMappingsInTheReservedTerminalPalette() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "minecraft:desert", "example:orchard"); + Map mappings = BiomeReplacementRules.read(root, "minecraft:overworld"); + assertEquals("example:orchard", mappings.get("minecraft:desert")); + JsonObject palette = root.getAsJsonObject("biome_palettes") + .getAsJsonObject(BiomeReplacementRules.paletteId("minecraft:overworld")); + assertEquals("replace", palette.get("mode").getAsString()); + assertEquals("all", palette.get("scope").getAsString()); + assertEquals(1.0D, palette.get("coverage").getAsDouble()); + assertEquals(0.0D, palette.get("fallback_weight").getAsDouble()); + } + + @Test + void flattensChainsAndRepointsInboundMappings() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "example:b"); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:b", "example:c"); + Map mappings = BiomeReplacementRules.read(root, "minecraft:overworld"); + assertEquals("example:c", mappings.get("example:a")); + assertEquals("example:c", mappings.get("example:b")); + } + + @Test + void rejectsSelfMappingsAndCycles() { + JsonObject root = new JsonObject(); + assertThrows(IllegalArgumentException.class, () -> BiomeReplacementRules.set(root, + "minecraft:overworld", "example:a", "example:a")); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "example:b"); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:b", "example:c"); + assertThrows(IllegalArgumentException.class, () -> BiomeReplacementRules.set(root, + "minecraft:overworld", "example:c", "example:a")); + } + + @Test + void removingLastMappingRemovesOnlyTheReservedPalette() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "missing:target"); + BiomeReplacementRules.remove(root, "minecraft:overworld", "example:a"); + assertTrue(BiomeReplacementRules.read(root, "minecraft:overworld").isEmpty()); + assertFalse(root.getAsJsonObject("biome_palettes") + .has(BiomeReplacementRules.paletteId("minecraft:overworld"))); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java b/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java new file mode 100644 index 00000000..91470fa1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java @@ -0,0 +1,61 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class CompactScrollListTest { + @Test + void bothSupportedSizesKeepRowsAndSideScrollbars() { + assertEquals(13, CompactScrollList.visibleRows(209, 16)); + assertEquals(11, CompactScrollList.visibleRows(184, 16)); + assertEquals(5, CompactScrollList.maxFirst(18, 13)); + assertEquals(7, CompactScrollList.maxFirst(18, 11)); + assertEquals(137, CompactScrollList.thumbHeight(18, 13, 191)); + assertEquals(3, CompactScrollList.firstFromThumb(27, 54, 5)); + } + + @Test + void wheelArrowsTrackThumbAndRowTargetsUseOnlyTheirOwnList() { + ProbeList list = new ProbeList(); + assertTrue(list.mouseWheel(20, 30, -120)); + assertEquals(3, list.firstIndex()); + assertFalse(list.mouseWheel(2, 2, -120)); + + list.mouseClicked(105, 83, 0); // Lower scrollbar arrow. + assertEquals(4, list.firstIndex()); + list.mouseClicked(105, 65, 0); // Track below the thumb. + assertEquals(8, list.firstIndex()); + + list.setFirstIndex(0); + list.mouseClicked(20, 53, 0); + assertEquals(2, list.selected); + list.mouseClicked(95, 21, 0); + assertEquals(0, list.action); + list.setFirstIndex(20); + list.ensureVisible(5); + assertEquals(5, list.firstIndex()); + list.ensureVisible(12); + assertEquals(9, list.firstIndex()); + + list.setFirstIndex(0); + list.mouseClicked(105, 31, 0); // Grab the scrollbar thumb. + list.mouseDragged(105, 75, 0, 0, 44); + assertTrue(list.firstIndex() > 0); + list.mouseReleased(105, 75, 0); + } + + private static final class ProbeList extends CompactScrollList { + int selected = -1; + int action = -1; + + ProbeList() { super(null, 10, 20, 100, 64); } + @Override protected int size() { return 30; } + @Override protected String rowText(int index) { return Integer.toString(index); } + @Override protected void onRowPressed(int index) { selected = index; } + @Override protected int rowActionWidth(int index) { return 10; } + @Override protected void onRowActionPressed(int index) { action = index; } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index c9d18464..2c64f183 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -2,9 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; +import net.minecraft.block.Blocks; +import zone.moddev.mc.orespawn.util.FluidBlocks; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -123,6 +126,22 @@ void rangedSelectorOreIsValidWithoutAnExplicitDimension() { assertTrue(errors.isEmpty(), errors.toString()); } + @Test + void fluidPickerExcludesWaterloggedBlocksAndBubbleColumns() { + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); + assertTrue(FluidBlocks.isFluidBlock(Blocks.WATER)); + assertTrue(FluidBlocks.isFluidBlock(Blocks.LAVA)); + assertFalse(FluidBlocks.isFluidBlock(Blocks.BRAIN_CORAL_WALL_FAN)); + assertFalse(FluidBlocks.isFluidBlock(Blocks.BUBBLE_COLUMN)); + assertFalse(session.availableFluidBlockIds("").contains("minecraft:brain_coral_wall_fan")); + assertFalse(session.availableFluidBlockIds("").contains("minecraft:bubble_column")); + assertFalse(session.availableMaterialBlockIds("", true).contains("minecraft:brain_coral_wall_fan")); + assertNull(session.assignFluidDeposit("minecraft:brain_coral_wall_fan")); + session.setMaterialBlock("minecraft:overworld", "default_fluid", + "minecraft:brain_coral_wall_fan", true); + assertNull(session.dimensionMaterials("minecraft:overworld", false)); + } + @Test void standaloneFluidPickerCreatesAUsableCoveredOverworldRule() { GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); @@ -167,4 +186,84 @@ void namespacedGeomesCanBeAddedValidatedAndRoundTripped() { assertEquals(3.0D, reopened.rock("minecraft:stone").getAsJsonObject("geomes") .get(geomeId).getAsDouble()); } + + @Test + void invalidGeomeIdsAreRejectedWithoutRenamingThem() { + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); + session.configureDefaultVanillaStrata(); + session.addGeome(""); + session.addGeome("BAD:UPPER"); + session.addGeome("cakeworld:cocoa_basin"); + JsonObject beforeDuplicate = session.profile().rootCopy(); + session.addGeome("cakeworld:cocoa_basin"); + assertFalse(session.section("geomes").has("")); + assertFalse(session.section("geomes").has("bad:upper")); + assertTrue(session.section("geomes").has("cakeworld:cocoa_basin")); + assertEquals(beforeDuplicate, session.profile().rootCopy()); + } + + @Test + void balancedModeRestoresEveryEligibleOutputAfterCustomSelection() { + JsonObject root = WorldGeologyProfile.recommended(false).rootCopy(); + JsonObject policy = new JsonObject(); + policy.addProperty("material", "orespawn:sulfur"); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + JsonObject outputs = new JsonObject(); + outputs.addProperty("mineralogy:sulfur", 1.0D); + policy.add("outputs", outputs); + JsonObject placements = new JsonObject(); + placements.addProperty("orespawn:standard", "mineralogy:sulfur"); + policy.add("placement_sources", placements); + JsonArray candidates = new JsonArray(); + candidates.add(oreSourceCandidate("mineralogy:sulfur", "mineralogy:sulfur_ore", false)); + candidates.add(oreSourceCandidate("baseminerals:sulfur", "baseminerals:sulfur_ore", false)); + candidates.add(oreSourceCandidate("outside:sulfur", "outside:sulfur_ore", true)); + policy.add("candidates", candidates); + JsonObject policies = new JsonObject(); + String key = "orespawn:sulfur|minecraft:overworld"; + policies.add(key, policy); + root.add("ore_source_policies", policies); + WorldGeologyProfile original = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + + session.setOreSourceOutputMode(key, "balanced"); + GeologyEditorSession.OreSourceGroup balanced = session.oreSourceGroups().get(0); + assertEquals("consolidated", balanced.mode); + assertEquals(2, balanced.outputs.size()); + assertTrue(balanced.outputs.containsKey("mineralogy:sulfur")); + assertTrue(balanced.outputs.containsKey("baseminerals:sulfur")); + assertFalse(balanced.outputs.containsKey("outside:sulfur")); + + session.setOreSourceOutputMode(key, "custom"); + session.setOreSourceOutput(key, "baseminerals:sulfur", true, 2.5D); + session.setOreSourceOutput(key, "mineralogy:sulfur", false, 1.0D); + assertEquals(1, session.oreSourceGroups().get(0).outputs.size()); + session.setOreSourceOutputMode(key, "balanced"); + assertEquals(2, session.oreSourceGroups().get(0).outputs.size()); + assertEquals(1.0D, session.oreSourceGroups().get(0).outputs + .get("mineralogy:sulfur").doubleValue()); + assertEquals(before, original.rootCopy(), "the main editor still owns persistence"); + } + + private static JsonObject oreSourceCandidate(String source, String block, boolean external) { + JsonObject candidate = new JsonObject(); + candidate.addProperty("source_id", source); + candidate.addProperty("owner", source.substring(0, source.indexOf(':'))); + candidate.addProperty("registry_id", block); + candidate.addProperty("metadata", 0); + candidate.addProperty("material", "orespawn:sulfur"); + candidate.addProperty("domain", "minecraft:overworld"); + candidate.addProperty("placement_channel", "orespawn:standard"); + JsonArray tags = new JsonArray(); + tags.add("forge:ores/sulfur"); + candidate.add("block_tags", tags); + candidate.addProperty("loaded", true); + candidate.addProperty("placement_active", !external); + candidate.addProperty("external", external); + return candidate; + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java new file mode 100644 index 00000000..c4da21aa --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java @@ -0,0 +1,112 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.text.StringTextComponent; +import net.minecraft.util.ResourceLocation; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; +import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry; +import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.LoadedMod; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo; + +class OreSpawnModDirectoryModelTest { + @Test + void discoversLoadedNativeLegacyUiOnlyInactiveAndRejectedIntegrations() { + WorldSettingsExtension realisticScreen = WorldSettingsExtensionRegistry.register( + new ResourceLocation("directory_realistic", "settings"), + "button.realisticdeposits.settings", parent -> new Screen(new StringTextComponent("test")) { }); + WorldSettingsExtension uiOnlyScreen = WorldSettingsExtensionRegistry.registerConfigScreen( + "directory_ui_only", parent -> new Screen(new StringTextComponent("test")) { }); + + List loaded = Arrays.asList( + new LoadedMod("orespawn", "MMD OreSpawn", "4.1.0.112021"), + new LoadedMod("directory_realistic", "Realistic Deposits", "0.1.0.110021"), + new LoadedMod("directory_base", "Base Metals", "2.5.0-beta5"), + new LoadedMod("directory_mineralogy", "Mineralogy", "6.0.2.110021"), + new LoadedMod("directory_ui_only", "Client Tweaks", "1.2.3"), + new LoadedMod("directory_inactive", "Inactive Rocks", "2.0"), + new LoadedMod("directory_rejected", "Rejected Ores", "3.0")); + List providers = Arrays.asList( + provider("directory_mineralogy", true, true, 4, 3, + ProviderStatus.ACTIVE, false), + provider("directory_base", false, true, 4, 2, + ProviderStatus.ACTIVE, false, 3), + provider("directory_realistic", true, true, 4, 1, + ProviderStatus.ACTIVE, false, 3), + provider("directory_inactive", true, true, 4, 1, + ProviderStatus.INACTIVE, false), + provider("directory_rejected", true, true, -1, -1, + ProviderStatus.INACTIVE, true), + provider("directory_unloaded", true, true, 4, 9, + ProviderStatus.ACTIVE, false), + provider("orespawn", true, true, 4, 1, + ProviderStatus.ACTIVE, false)); + + List result = OreSpawnModDirectoryModel.assemble(loaded, providers, + Arrays.asList(realisticScreen, uiOnlyScreen)); + + assertEquals(Arrays.asList("directory_base", "directory_ui_only", "directory_inactive", + "directory_mineralogy", "directory_realistic", "directory_rejected"), + result.stream().map(entry -> entry.modId).collect(java.util.stream.Collectors.toList())); + assertEquals(6, result.size()); + assertEquals("config_only", find(result, "directory_ui_only").statusKey()); + assertEquals("inactive", find(result, "directory_inactive").statusKey()); + assertEquals("rejected", find(result, "directory_rejected").statusKey()); + assertFalse(find(result, "directory_base").nativeOs4()); + assertEquals(Collections.singletonList(3), find(result, "directory_base").legacyLineages()); + assertTrue(find(result, "directory_realistic").nativeOs4()); + assertTrue(find(result, "directory_realistic").configurable()); + assertThrows(UnsupportedOperationException.class, () -> result.clear()); + } + + @Test + void deduplicatesByModIdAndSortsEqualNamesByModId() { + List loaded = Arrays.asList( + new LoadedMod("directory_sort_b", "Same Name", "1"), + new LoadedMod("directory_sort_a", "Same Name", "2")); + List providers = Arrays.asList( + provider("directory_sort_b", false, true, 4, 1, ProviderStatus.ACTIVE, false, 1, 2, 3), + provider("directory_sort_a", true, true, 4, 2, ProviderStatus.PENDING, false)); + + List result = OreSpawnModDirectoryModel.assemble(loaded, providers, + Collections.emptyList()); + + assertEquals("directory_sort_a", result.get(0).modId); + assertEquals("directory_sort_b", result.get(1).modId); + assertEquals(Arrays.asList(1, 2, 3), result.get(1).legacyLineages()); + assertThrows(UnsupportedOperationException.class, + () -> result.get(1).legacyLineages().add(2)); + } + + @Test + void paginationRemainsBoundedAtMinimumResolution() { + assertEquals(3, OreSpawnModsScreen.pageSize(240)); + assertEquals(1, OreSpawnModsScreen.pageCount(0, 3)); + assertEquals(1, OreSpawnModsScreen.pageCount(3, 3)); + assertEquals(2, OreSpawnModsScreen.pageCount(4, 3)); + } + + private static Entry find(List entries, String modId) { + return entries.stream().filter(entry -> modId.equals(entry.modId)).findFirst().orElseThrow( + () -> new AssertionError("Missing " + modId)); + } + + private static ProviderIntegrationInfo provider(String modId, boolean nativeOs4, + boolean hasProvider, int schema, int revision, ProviderStatus status, + boolean rejected, Integer... lineages) { + return new ProviderIntegrationInfo(modId, nativeOs4, hasProvider, schema, revision, + status, rejected, Arrays.asList(lineages)); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java index 648cb743..7bfe104b 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -26,7 +26,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce .sorted() .collect(Collectors.toList()); } - assertEquals(24, screens.size(), "Review this render-order gate when screens are added or removed"); + assertEquals(28, screens.size(), "Review this render-order gate when screens are added or removed"); for (Path screen : screens) { String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8); int render = source.indexOf( @@ -60,6 +60,27 @@ void compactOrePlacementRowsStayAboveFooterAtMinimumTestHeight() { assertCompactOrePlacementClearsFooter(240); } + @Test + void oreSourcesKeepTwoCompactListsAtBothSupportedSizes() { + assertEquals(406, OreSourceListScreen.contentWidth(426)); + assertEquals(174, OreSourceListScreen.leftPaneWidth(426)); + assertEquals(209, OreSourceListScreen.listHeight(265)); + assertEquals(300, OreSourceListScreen.contentWidth(320)); + assertEquals(129, OreSourceListScreen.leftPaneWidth(320)); + assertEquals(184, OreSourceListScreen.listHeight(240)); + assertEquals(80, OreSourceGroupSettingsScreen.aliasListHeight(265)); + assertEquals(64, OreSourceGroupSettingsScreen.aliasListHeight(240)); + assertEquals(58, OreSourceListScreen.compactFilterWidth(110, 48)); + } + + @Test + void biomeDirectoryUsesPagesOnlyAtTheMinimumSize() { + assertTrue(426 >= BiomeWorldMaterialsScreen.TWO_PANE_MINIMUM); + assertTrue(320 < BiomeWorldMaterialsScreen.TWO_PANE_MINIMUM); + assertTrue(BiomeWorldMaterialsScreen.listHeight(265) > 0); + assertTrue(BiomeWorldMaterialsScreen.listHeight(240) > 0); + } + private static void assertRowsClearFooter(int height) { int lastRowBottom = OreSpawnScreenLayout.mainTop(height) + (OreSpawnScreenLayout.mainRowSpacing(height) * 7) + 20; diff --git a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java index f34e49d3..2ac2dddb 100644 --- a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java @@ -35,6 +35,10 @@ void rejectsImpossibleFluidOutputs() { "examplemod", provider("minecraft:air", true, 3))); assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( "examplemod", provider("minecraft:stone", true, 3))); + assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( + "examplemod", provider("minecraft:brain_coral_wall_fan", true, 3))); + assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( + "examplemod", provider("minecraft:bubble_column", true, 3))); assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( "examplemod", provider("missingmod:not_here", true, 3))); } diff --git a/src/test/java/zone/moddev/mc/orespawn/test/Forge28TestBootstrap.java b/src/test/java/zone/moddev/mc/orespawn/test/Forge28TestBootstrap.java new file mode 100644 index 00000000..3a074b74 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/test/Forge28TestBootstrap.java @@ -0,0 +1,34 @@ +package zone.moddev.mc.orespawn.test; + +import java.lang.reflect.Field; + +import net.minecraft.util.registry.Bootstrap; + +/** Supplies the Forge 28 version fields normally set by its launcher. */ +public final class Forge28TestBootstrap { + private static boolean initialized; + + private Forge28TestBootstrap() { } + + public static synchronized void registerVanilla() { + if (initialized) return; + try { + Class loader = Class.forName("net.minecraftforge.fml.loading.FMLLoader"); + set(loader, "mcVersion", "1.14.4"); + set(loader, "mcpVersion", "20190829.143755"); + set(loader, "forgeVersion", "28.2.26"); + set(loader, "forgeGroup", "net.minecraftforge"); + Bootstrap.register(); + initialized = true; + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException("Unable to initialize the Forge 28 test runtime", ex); + } + } + + private static void set(Class owner, String name, String value) + throws ReflectiveOperationException { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + field.set(null, value); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java new file mode 100644 index 00000000..289cbd08 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java @@ -0,0 +1,153 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map.Entry; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.block.BlockState; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.biome.Biome; +import net.minecraft.world.biome.provider.BiomeProvider; +import net.minecraft.world.gen.feature.structure.Structure; +import net.minecraftforge.registries.ForgeRegistries; +import zone.moddev.mc.orespawn.test.Forge28TestBootstrap; +import zone.moddev.mc.orespawn.worldgen.BakedBiomeWorldgen.Palette; + +class BiomePaletteOrderingTest { + @BeforeAll static void bootstrapMinecraft() { Forge28TestBootstrap.registerVanilla(); } + + @Test void ordinaryPalettesKeepTheirOrderAndTheUiOverrideRunsLast() { + JsonObject palettes = new JsonObject(); + palettes.add("provider:first", new JsonObject()); + palettes.add("orespawn:ui/biome_overrides/minecraft_overworld", new JsonObject()); + palettes.add("provider:second", new JsonObject()); + List ids = BiomeWorldgenManager.orderedPaletteEntries(palettes).stream() + .map(Entry::getKey).collect(Collectors.toList()); + assertEquals(java.util.Arrays.asList("provider:first", "provider:second", + "orespawn:ui/biome_overrides/minecraft_overworld"), ids); + } + + @Test void exactReplacementRunsAfterTheProviderEvenWhenStoredFirst() { + JsonObject root = root(); + JsonObject palettes = root.getAsJsonObject("biome_palettes"); + palettes.add("orespawn:ui/biome_overrides/minecraft_overworld", + palette("minecraft:forest", "minecraft:desert")); + palettes.add("provider:ordinary", palette("minecraft:desert", "minecraft:plains")); + List baked = BiomeWorldgenManager.bakePalettes(root, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + BiomeOverlaySource overlay = new BiomeOverlaySource( + new ConstantBiomeProvider(biome("minecraft:plains")), baked, 78431L); + assertEquals(2, baked.size()); + assertSame(biome("minecraft:forest"), overlay.getBiome(new BlockPos(0, 64, 0))); + assertSame(biome("minecraft:forest"), overlay.getBiome(new BlockPos(-257, 64, 389))); + } + + @Test void aMissingTargetStaysDormant() { + JsonObject root = root(); + root.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", + palette("missing:orchard", "minecraft:plains")); + assertTrue(BiomeWorldgenManager.bakePalettes(root, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()).isEmpty()); + assertTrue(root.toString().contains("missing:orchard")); + } + + @Test void aDisabledOverrideDoesNotChangeExistingBiomeChoices() { + JsonObject root = root(); + root.getAsJsonObject("biome_palettes").add("provider:first", + palette("minecraft:desert", "minecraft:plains")); + JsonObject disabled = new com.google.gson.JsonParser().parse(root.toString()).getAsJsonObject(); + JsonObject override = palette("minecraft:forest", "minecraft:desert"); + override.addProperty("enabled", false); + disabled.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", override); + Biome source = biome("minecraft:plains"); + BiomeOverlaySource baseline = overlay(root, source); + BiomeOverlaySource candidate = overlay(disabled, source); + for (int z = -96; z <= 96; z += 3) { + for (int x = -96; x <= 96; x += 3) { + BlockPos pos = new BlockPos(x, 63, z); + assertSame(baseline.getBiome(pos), candidate.getBiome(pos)); + } + } + } + + private static BiomeOverlaySource overlay(JsonObject root, Biome source) { + return new BiomeOverlaySource(new ConstantBiomeProvider(source), + BiomeWorldgenManager.bakePalettes(root, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()), 918273L); + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("biome_palettes", new JsonObject()); + return root; + } + + private static JsonObject palette(String target, String source) { + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", true); + palette.addProperty("mode", "replace"); + palette.addProperty("scope", "all"); + palette.addProperty("region_size", "tiny"); + palette.addProperty("coverage", 1.0D); + palette.addProperty("fallback_weight", 0.0D); + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray similar = new JsonArray(); + similar.add(new JsonPrimitive(source)); + placement.add("similar_biomes", similar); + JsonObject biomes = new JsonObject(); + biomes.add(target, placement); + palette.add("biomes", biomes); + return palette; + } + + private static Biome biome(String id) { + Biome biome = ForgeRegistries.BIOMES.getValue(new ResourceLocation(id)); + if (biome == null) throw new AssertionError("Missing test biome " + id); + return biome; + } + + private static final class ConstantBiomeProvider extends BiomeProvider { + private final Biome biome; + ConstantBiomeProvider(Biome biome) { this.biome = biome; } + @Override public Biome getBiome(int x, int z) { return biome; } + private Biome[] filledBiomes(int width, int length) { + Biome[] result = new Biome[width * length]; + java.util.Arrays.fill(result, biome); + return result; + } + @Override public Biome[] getBiomes(int x, int z, int width, int length, boolean cacheFlag) { + return filledBiomes(width, length); + } + @Override public Set getBiomesInSquare(int x, int z, int radius) { + return Collections.singleton(biome); + } + @Override public BlockPos findBiomePosition(int x, int z, int range, + List biomes, Random random) { return null; } + @Override public boolean hasStructure(Structure structure) { return false; } + @Override public Set getSurfaceBlocks() { + return Collections.singleton(biome.getSurfaceBuilderConfig().getTop()); + } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java index fac85dcb..21cd3510 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java @@ -43,8 +43,7 @@ void savedWorldBoundaryUsesItsConfiguredBiomeInsteadOfEqualFallbackWeights() { @Test void closeGeomeContestDoesNotMoveEveryStableLayerAtOneColumnBoundary() { - // These are the leading scores measured in New World 5 at z=-261. The - // fallback configuration changed winner between x=225 and x=226. + // Scores from New World 5 at z=-261, where the fallback changed winner between x=225 and x=226. double[] leftScores = { 2.618658D, 2.618126D }; double[] rightScores = { 2.619946D, 2.620774D }; int changedLayers = 0; diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java new file mode 100644 index 00000000..4f9e6db7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class MmdOreConflictCatalogTest { + @Test + void futureProvidersOnlyBalanceWhenBothLoadedAndSharingAnExactTag() { + Map> loaded = new LinkedHashMap<>(); + loaded.put("baseminerals", tags("forge:ores/sulfur")); + assertFalse(MmdOreConflictCatalog.automaticallyBalance(loaded)); + loaded.put("electricadvantage", tags("forge:ores/sulfur")); + assertTrue(MmdOreConflictCatalog.automaticallyBalance(loaded)); + loaded.put("electricadvantage", tags("forge:ores/sulphur")); + assertFalse(MmdOreConflictCatalog.automaticallyBalance(loaded)); + } + + @Test + void unrelatedExternalAndEnrichmentProvidersDoNotTriggerBalancing() { + Map> loaded = new LinkedHashMap<>(); + loaded.put("basemetals", tags("forge:ores/copper")); + loaded.put("externalgenerator", tags("forge:ores/copper")); + loaded.put("densemetals", tags("forge:ores/copper")); + assertFalse(MmdOreConflictCatalog.automaticallyBalance(loaded)); + loaded.put("modernmetals", tags("forge:ores/copper")); + assertTrue(MmdOreConflictCatalog.automaticallyBalance(loaded)); + } + + @Test + void establishedPrioritiesAndDimensionProvidersRemainDistinct() { + assertEquals(Arrays.asList("mineralogy", "baseminerals", "electricadvantage"), + MmdOreConflictCatalog.priority("sulfur")); + assertEquals(Arrays.asList("baseminerals", "electricadvantage"), + MmdOreConflictCatalog.priority("lithium")); + assertEquals(Collections.emptyList(), MmdOreConflictCatalog.priority("copper")); + assertTrue(MmdOreConflictCatalog.netherOnly("nethermetals")); + assertTrue(MmdOreConflictCatalog.endOnly("endmetals")); + assertFalse(MmdOreConflictCatalog.ordinary("densemetals")); + } + + private static Set tags(String tag) { + return new LinkedHashSet<>(Collections.singletonList(tag)); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/NativeOreHostTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/NativeOreHostTest.java new file mode 100644 index 00000000..a34f5146 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/NativeOreHostTest.java @@ -0,0 +1,263 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; + +import net.minecraft.block.Block; +import net.minecraft.block.Blocks; +import net.minecraft.tags.BlockTags; +import net.minecraft.tags.Tag; +import net.minecraft.tags.TagCollection; +import net.minecraft.util.ResourceLocation; +import zone.moddev.mc.orespawn.test.Forge28TestBootstrap; + +class NativeOreHostTest { + private static final ResourceLocation NETHERRACK = new ResourceLocation("forge:netherrack"); + private TagCollection previousTags; + + @BeforeAll + static void bootstrap() { + Forge28TestBootstrap.registerVanilla(); + zone.moddev.mc.orespawn.init.OreSpawnPatterns.registry().register( + zone.moddev.mc.orespawn.api.OrePatternType.create( + zone.moddev.mc.orespawn.api.StandardPatternSettings.CODEC, + settings -> context -> false) + .setRegistryName("example", "host_test")); + } + + @BeforeEach + void isolateTags() { + previousTags = BlockTags.getCollection(); + BlockTags.setCollection(new TagCollection<>(id -> Optional.empty(), "tags/blocks", false, "block")); + } + + @AfterEach + void restoreTags() { + BlockTags.setCollection(previousTags); + } + + @Test + void forge28ShipsTheDefaultQuartzHost() throws Exception { + try (InputStream input = getClass().getResourceAsStream("/data/forge/tags/blocks/netherrack.json")) { + assertNotNull(input, "Forge 28 must supply the tag used by the saved quartz defaults"); + JsonObject json = new JsonParser().parse(new InputStreamReader(input, StandardCharsets.UTF_8)) + .getAsJsonObject(); + assertEquals("minecraft:netherrack", json.getAsJsonArray("values").get(0).getAsString()); + Map> tags = new HashMap<>(); + tags.put(NETHERRACK, Tag.Builder.create().fromJson( + id -> "minecraft:netherrack".equals(id.toString()) + ? Optional.of(Blocks.NETHERRACK) : Optional.empty(), json)); + BlockTags.getCollection().registerAll(tags); + assertEquals(Collections.singleton(Blocks.NETHERRACK), resolve(NETHERRACK)); + } + } + + @Test + void unknownHostDoesNotGuessAReplacement() throws Exception { + assertTrue(resolve(new ResourceLocation("example:missing_host")).isEmpty()); + assertTrue(BlockTags.getCollection().getTagMap().isEmpty(), "Resolution must not register a tag"); + } + + @Test + void installedNetherTagRemainsAuthoritative() throws Exception { + install(Tag.Builder.create().add(Blocks.END_STONE)); + assertEquals(Collections.singleton(Blocks.END_STONE), resolve(NETHERRACK)); + } + + @Test + void deliberatelyEmptyNetherTagRemainsEmpty() throws Exception { + install(Tag.Builder.create()); + assertTrue(resolve(NETHERRACK).isEmpty()); + } + + @Test + void quartzDefaultsUseTheShippedHostTag() throws Exception { + Method defaults = GeomeConfig.class.getDeclaredMethod("defaultOreConfig"); + defaults.setAccessible(true); + JsonObject ores = (JsonObject) defaults.invoke(null); + JsonObject rule = ores.getAsJsonObject("minecraft:nether_quartz_ore") + .getAsJsonObject("dimensions").getAsJsonObject("minecraft:the_nether"); + assertEquals("forge:netherrack", rule.getAsJsonArray("host_tags").get(0).getAsString()); + } + + @Test + void newNetherOreRulesUseTheShippedHostTag() throws Exception { + assertNetherEditorHost("OreEntryScreen"); + } + + @Test + void newNetherFluidRulesUseTheShippedHostTag() throws Exception { + assertNetherEditorHost("FluidDepositEntryScreen"); + } + + @Test + void tagRetryPreservesWorkingFamilyHosts() throws Exception { + JsonObject rule = rule(); + JsonArray families = new JsonArray(); + families.add("sedimentary"); + rule.add("host_families", families); + assertOreHostsRemainStable(rule); + } + + @Test + void tagRetryPreservesWorkingExplicitHosts() throws Exception { + JsonObject rule = rule(); + JsonArray hosts = new JsonArray(); + hosts.add("minecraft:netherrack"); + rule.add("host_blocks", hosts); + assertOreHostsRemainStable(rule); + } + + @Test + void tagOnlyOreRulesRetryLoadedTagsAndRespectAnEmptyOverride() throws Exception { + Map early = bakeOres(rule(), Collections.emptyMap()); + assertTrue(early.isEmpty()); + install(Tag.Builder.create().add(Blocks.NETHERRACK)); + Map ready = bakeOres(rule(), early); + assertEquals(1, ready.size()); + assertTrue(hosts(ready.values().iterator().next()).containsKey(Blocks.NETHERRACK)); + install(Tag.Builder.create()); + assertTrue(bakeOres(rule(), ready).isEmpty()); + } + + @Test + void fluidTagRetryPreservesWorkingExplicitHosts() throws Exception { + JsonObject rule = rule(); + JsonArray hosts = new JsonArray(); + hosts.add("minecraft:netherrack"); + rule.add("host_blocks", hosts); + Map early = bakeFluids(rule, Collections.emptyMap()); + assertEquals(1, early.size()); + install(Tag.Builder.create().add(Blocks.END_STONE)); + Map ready = bakeFluids(rule, early); + assertEquals(early, ready, "A working deposit must retain its original host selection"); + } + + @Test + void tagOnlyFluidRulesRetryLoadedTagsAndRespectAnEmptyOverride() throws Exception { + Map early = bakeFluids(rule(), Collections.emptyMap()); + assertTrue(early.isEmpty()); + install(Tag.Builder.create().add(Blocks.NETHERRACK)); + Map ready = bakeFluids(rule(), early); + assertEquals(1, ready.size()); + install(Tag.Builder.create()); + assertTrue(bakeFluids(rule(), ready).isEmpty()); + } + + private static void assertOreHostsRemainStable(JsonObject rule) throws Exception { + Map early = bakeOres(rule, Collections.emptyMap()); + assertEquals(1, early.size()); + install(Tag.Builder.create().add(Blocks.END_STONE)); + Map ready = bakeOres(rule, early); + assertEquals(hosts(early.values().iterator().next()), hosts(ready.values().iterator().next())); + assertFalse(hosts(ready.values().iterator().next()).containsKey(Blocks.END_STONE)); + } + + private static JsonObject rule() { + JsonObject rule = new JsonObject(); + rule.addProperty("min_y", 0); + rule.addProperty("max_y", 127); + rule.addProperty("frequency", 1); + rule.addProperty("quantity", 14); + rule.addProperty("pattern", "example:host_test"); + JsonArray tags = new JsonArray(); + tags.add("forge:netherrack"); + rule.add("host_tags", tags); + return rule; + } + + private static JsonObject profile(String section, String key, JsonObject rule) { + JsonObject root = new JsonObject(); + JsonObject entries = new JsonObject(); + JsonObject entry = new JsonObject(); + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:the_nether", rule); + entry.add("dimensions", dimensions); + entry.addProperty("block", section.equals("ores") ? "minecraft:iron_ore" : "minecraft:water"); + entries.add(key, entry); + root.add(section, entries); + return root; + } + + private static Map bakeOres(JsonObject rule, Map previous) throws Exception { + Method bake = OreSpawnOreGeneration.class.getDeclaredMethod("bakeOres", JsonObject.class, + BakedGeomeConfig.class, Map.class, Map.class); + bake.setAccessible(true); + Map resolved = new HashMap<>(); + bake.invoke(null, profile("ores", "example:ore", rule), config(), previous, resolved); + return resolved; + } + + private static Map bakeFluids(JsonObject rule, Map previous) throws Exception { + Method bake = FluidDepositFeature.class.getDeclaredMethod("bakeDeposits", JsonObject.class, + Map.class, Map.class); + bake.setAccessible(true); + Map resolved = new HashMap<>(); + bake.invoke(null, profile("fluid_deposits", "example:fluid", rule), previous, resolved); + return resolved; + } + + @SuppressWarnings("unchecked") + private static Map hosts(Object ore) throws Exception { + java.lang.reflect.Field field = ore.getClass().getDeclaredField("hostBlocks"); + field.setAccessible(true); + return (Map) field.get(ore); + } + + private static BakedGeomeConfig config() { + BakedGeomeConfig.GeomeDefinition[] geomes = { + new BakedGeomeConfig.GeomeDefinition("example:geome", 1, + new double[] { 1, 1, 1, 1 }) + }; + BakedGeomeConfig.RockEntry[] rocks = { + new BakedGeomeConfig.RockEntry(Blocks.STONE.getDefaultState(), RockFamily.SEDIMENTARY, + 64, 64, 0, 255, 1, true, new double[] { 1 }) + }; + FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, + 256, 100, 8, 48, 64, 12, 2, 0.85); + return new BakedGeomeConfig(geomes, 384, 1.15, 0.9, 0.45, + Collections.emptyMap(), Collections.emptyMap(), rocks, formations); + } + + private static void install(Tag.Builder builder) { + Map> tags = new HashMap<>(); + tags.put(NETHERRACK, builder); + BlockTags.getCollection().registerAll(tags); + } + + private static void assertNetherEditorHost(String name) throws Exception { + Class screen = Class.forName("zone.moddev.mc.orespawn.client." + name); + Method defaults = screen.getDeclaredMethod("defaultDimension", String.class); + defaults.setAccessible(true); + JsonObject rule = (JsonObject) defaults.invoke(null, "minecraft:the_nether"); + assertEquals("forge:netherrack", rule.getAsJsonArray("host_tags").get(0).getAsString()); + } + + @SuppressWarnings("unchecked") + private static Set resolve(ResourceLocation id) throws Exception { + Method resolver = OreSpawnOreGeneration.class.getDeclaredMethod("resolveTag", ResourceLocation.class); + resolver.setAccessible(true); + return (Set) resolver.invoke(null, id); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java new file mode 100644 index 00000000..cea8c249 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import net.minecraft.util.ResourceLocation; + +class OreMaterialGroupsTest { + @Test + void curatedAliasesUseExactBlockTags() { + JsonObject root = new JsonObject(); + OreMaterialGroups.initialize(root); + assertEquals(new ResourceLocation("orespawn:sulfur"), OreMaterialGroups.infer(root, + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")).material); + assertFalse(OreMaterialGroups.infer(root, + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")).reviewRequired); + assertEquals(new ResourceLocation("orespawn:aluminum"), OreMaterialGroups.infer(root, + Arrays.asList("forge:ores/aluminum", "forge:ores/aluminium")).material); + } + + @Test + void importedOreDictionaryAliasesStayDormantWithoutExactTags() { + JsonObject root = new JsonObject(); + JsonObject groups = new JsonObject(); + JsonObject oldGroup = new JsonObject(); + oldGroup.addProperty("display_name", "Rare stone"); + JsonArray aliases = new JsonArray(); + aliases.add("oreRareStone"); + oldGroup.add(OreMaterialGroups.LEGACY_ALIASES, aliases); + groups.add("example:rare_stone", oldGroup); + root.add(OreMaterialGroups.SECTION, groups); + OreMaterialGroups.initialize(root); + OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, + new ResourceLocation("example:rare_stone")); + assertEquals(Collections.singletonList("oreRareStone"), definition.dormantOreDictionaryEntries); + assertTrue(definition.blockTagEntries.isEmpty()); + assertEquals(null, OreMaterialGroups.infer(root, Collections.singletonList("oreRareStone")).material); + assertEquals(null, OreMaterialGroups.infer(root, + Collections.singletonList("example:rare_stone_ore")).material); + } + + @Test + void exactSharedTagIsStableAndGroupOwnershipIsExplicit() { + JsonObject root = new JsonObject(); + OreMaterialGroups.initialize(root); + ResourceLocation inferred = OreMaterialGroups.infer(root, + Collections.singletonList("forge:ores/copper")).material; + assertEquals(new ResourceLocation("orespawn:copper"), inferred); + assertTrue(OreMaterialGroups.ensureDefinition(root, inferred, + Collections.singletonList("forge:ores/copper"))); + assertEquals(Collections.singletonList("forge:ores/copper"), + OreMaterialGroups.definition(root, inferred).blockTagEntries); + assertFalse(OreMaterialGroups.ensureDefinition(root, inferred, + Collections.singletonList("forge:ores/copper"))); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java new file mode 100644 index 00000000..44d62954 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java @@ -0,0 +1,103 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +import net.minecraft.util.ResourceLocation; + +class OreSourcePoliciesTest { + private static final ResourceLocation SULFUR = id("orespawn:sulfur"); + private static final ResourceLocation OVERWORLD = id("minecraft:overworld"); + + @Test + void newWorldBalancesOnlyTwoLoadedOrdinaryOwnersWithAnExactSharedTag() { + OreSourcePolicies.Group one = group(); + one.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + assertSeparate(OreSourcePolicies.createPolicy(one, false)); + + OreSourcePolicies.Group differentTag = group(); + differentTag.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + differentTag.add(candidate("electricadvantage:sulfur", "electricadvantage", + "electricadvantage:sulfur_ore", "forge:ores/sulphur", true, true, false, false)); + assertSeparate(OreSourcePolicies.createPolicy(differentTag, false)); + + OreSourcePolicies.Group shared = group(); + shared.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + shared.add(candidate("electricadvantage:sulfur", "electricadvantage", + "electricadvantage:sulfur_ore", "forge:ores/sulfur", true, true, false, false)); + JsonObject balanced = OreSourcePolicies.createPolicy(shared, false); + assertEquals("consolidated", balanced.get("mode").getAsString()); + assertEquals("balanced", balanced.get("output_mode").getAsString()); + assertEquals(2, balanced.getAsJsonObject("outputs").size()); + assertEquals(1, balanced.getAsJsonObject("placement_sources").size()); + assertEquals("mineralogy:sulfur", balanced.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + } + + @Test + void oldWorldKeepsOriginalAndExternalOrEnrichmentDoesNotTriggerBalance() { + OreSourcePolicies.Group group = group(); + group.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + group.add(candidate("electricadvantage:sulfur", "electricadvantage", + "electricadvantage:sulfur_ore", "forge:ores/sulfur", true, true, false, false)); + assertSeparate(OreSourcePolicies.createPolicy(group, true)); + + OreSourcePolicies.Group onlyExternal = group(); + onlyExternal.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + onlyExternal.add(candidate("outside:sulfur", "outside", "outside:sulfur_ore", + "forge:ores/sulfur", true, false, true, false)); + assertSeparate(OreSourcePolicies.createPolicy(onlyExternal, false)); + + OreSourcePolicies.Group enrichment = group(); + enrichment.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + enrichment.add(candidate("densemetals:sulfur", "densemetals", "densemetals:sulfur_ore", + "forge:ores/sulfur", true, true, false, true)); + assertSeparate(OreSourcePolicies.createPolicy(enrichment, false)); + } + + @Test + void unloadedFutureProviderCreatesNeitherAnOutputNorAPlacementBudget() { + OreSourcePolicies.Group group = group(); + group.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + group.add(candidate("baseminerals:sulfur", "baseminerals", "baseminerals:sulfur_ore", + "forge:ores/sulfur", false, false, false, false)); + JsonObject policy = OreSourcePolicies.createPolicy(group, false); + assertSeparate(policy); + assertFalse(policy.getAsJsonObject("outputs").has("baseminerals:sulfur")); + assertEquals(1, policy.getAsJsonObject("placement_sources").size()); + assertTrue(policy.getAsJsonObject("placement_sources").has("orespawn:standard")); + } + + private static void assertSeparate(JsonObject policy) { + assertEquals("keep_separate", policy.get("mode").getAsString()); + } + + private static OreSourcePolicies.Group group() { + return new OreSourcePolicies.Group(SULFUR, OVERWORLD); + } + + private static OreSourcePolicies.Candidate candidate(String source, String owner, String block, + String tag, boolean loaded, boolean active, boolean external, boolean enrichment) { + return new OreSourcePolicies.Candidate(source, owner, owner, "1", id(block), 0, + SULFUR, OVERWORLD, OreSourcePolicies.STANDARD, Collections.singletonList(tag), + loaded, active, external, enrichment, false, false); + } + + private static ResourceLocation id(String value) { + return new ResourceLocation(value); + } +} diff --git a/src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java b/src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java new file mode 100644 index 00000000..d98bf961 --- /dev/null +++ b/src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java @@ -0,0 +1,113 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Properties; + +import net.minecraft.block.Block; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.server.ServerWorld; +import net.minecraft.world.chunk.Chunk; +import net.minecraft.world.dimension.DimensionType; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.server.FMLServerStartedEvent; +import net.minecraftforge.registries.ForgeRegistries; + +/** Test-only checksum of completed chunks; it works with both 4.0.16 and 4.1. */ +@Mod("worldhashprobe") +public final class WorldHashProbeTestMod { + public WorldHashProbeTestMod() { + MinecraftForge.EVENT_BUS.addListener(EventPriority.LOWEST, this::audit); + } + + private void audit(FMLServerStartedEvent event) { + ServerWorld world = event.getServer().getWorld(DimensionType.OVERWORLD); + if (world == null) throw new IllegalStateException("Overworld is unavailable"); + // Adjacent veins can reach the outer row. Finish the surrounding chunks before hashing the 81-chunk area. + for (int x = 251; x <= 261; x++) { + world.getChunk(x, 251); + world.getChunk(x, 261); + } + for (int z = 252; z <= 260; z++) { + world.getChunk(251, z); + world.getChunk(261, z); + } + MessageDigest blocks = digest(); + MessageDigest biomes = digest(); + MessageDigest[] levels = new MessageDigest[256]; + for (int y = 0; y < levels.length; y++) levels[y] = digest(); + Properties marker = new Properties(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + int chunks = 0; + for (int chunkZ = 252; chunkZ <= 260; chunkZ++) { + for (int chunkX = 252; chunkX <= 260; chunkX++) { + if (!world.getChunkProvider().chunkExists(chunkX, chunkZ)) { + throw new IllegalStateException("Benchmark did not finish chunk " + chunkX + "," + chunkZ); + } + Chunk chunk = world.getChunk(chunkX, chunkZ); + MessageDigest chunkBlocks = digest(); + chunks++; + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int absoluteX = (chunkX << 4) + x; + int absoluteZ = (chunkZ << 4) + z; + ResourceLocation biome = ForgeRegistries.BIOMES.getKey(world.getBiome( + cursor.setPos(absoluteX, 64, absoluteZ))); + if (biome == null) throw new IllegalStateException("Unregistered biome in benchmark"); + biomes.update(biome.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + biomes.update((byte) 0); + for (int y = 0; y < 256; y++) { + cursor.setPos(absoluteX, y, absoluteZ); + int state = Block.getStateId(chunk.getBlockState(cursor)); + updateInt(blocks, state); + updateInt(chunkBlocks, state); + updateInt(levels[y], state); + } + } + } + marker.setProperty("chunk." + chunkX + "." + chunkZ, hex(chunkBlocks.digest())); + } + } + for (int y = 0; y < levels.length; y++) + marker.setProperty("level." + y, hex(levels[y].digest())); + marker.setProperty("chunks", Integer.toString(chunks)); + marker.setProperty("blocks_sha256", hex(blocks.digest())); + marker.setProperty("biomes_sha256", hex(biomes.digest())); + Path worldRoot = event.getServer().getActiveAnvilConverter() + .getFile(event.getServer().getFolderName(), "level.dat") + .toPath().toAbsolutePath().normalize().getParent(); + try (OutputStream output = Files.newOutputStream(worldRoot.resolve("worldhashprobe.properties"))) { + marker.store(output, "Completed benchmark chunks"); + } catch (IOException failure) { + throw new IllegalStateException("Could not save benchmark hashes", failure); + } + } + + private static MessageDigest digest() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); } + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(Character.forDigit((value >>> 4) & 15, 16)); + result.append(Character.forDigit(value & 15, 16)); + } + return result.toString(); + } +} diff --git a/src/worldHashTest/resources/META-INF/mods.toml b/src/worldHashTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..cb3c21a6 --- /dev/null +++ b/src/worldHashTest/resources/META-INF/mods.toml @@ -0,0 +1,9 @@ +modLoader="javafml" +loaderVersion="[28,)" +license="MIT" + +[[mods]] +modId="worldhashprobe" +version="1" +displayName="OreSpawn World Hash Test" +description="Test-only checksum of completed benchmark chunks." diff --git a/src/worldHashTest/resources/pack.mcmeta b/src/worldHashTest/resources/pack.mcmeta new file mode 100644 index 00000000..b976210d --- /dev/null +++ b/src/worldHashTest/resources/pack.mcmeta @@ -0,0 +1 @@ +{"pack":{"pack_format":4,"description":"OreSpawn world hash test fixture"}}