From dfff76b0f9707de62dd1c71df0f005c378b4a29e Mon Sep 17 00:00:00 2001
From: feldenr <135638674+feldenr@users.noreply.github.com>
Date: Sat, 20 Jun 2026 22:33:06 +0200
Subject: [PATCH] test+fix: deep generation tests + bug cleanup (licence, docs,
deps)
Two cohesive passes that harden the latitude worldgen and clean up
long-standing inconsistencies.
== Bug fixes & doc alignment ==
- licence: set MIT in gradle.properties and neoforge.mods.toml
(was "Not specified" - blocks distribution)
- OreAuditHandler: fix javadoc that referenced a non-existent system
property; the trigger is the .oreaudit marker file. Add an explicit
warning about the System.exit(0) and point to LatitudeGameTest as the
non-destructive alternative
- KubeJS: remove the dead kubejs_version, the commented dependency and
all README claims about an "automatic KubeJS script"/KubeJSIntegration
class that no longer exists. Vanilla ore removal is now native via the
neoforge:remove_features biome modifier
- README: refresh the technical header (1.21.1 / NeoForge 21.1.219 /
Java 21 / v3.2, was 1.20.1/Forge/Java17/v2.1.5) and the shard-diamond
surface note (both variants are generated, gated by shardDiamondOre)
- CLAUDE.md / CONFIG_INTEGRATION_GUIDE.md: mark the feature toggles and
tool stats as wired (they read ModConfigs at runtime); correct the
"hardcoded tools" claim
== Generation tests (56 unit + 6 GameTest, all green) ==
- Extract the pure geometry of LatitudeBiomeSource into LatitudeMath
(temperature, 3-zone underground model, spawn safe zone, dual-octave
selector index). LatitudeBiomeSource now delegates to it, so one
source of truth drives both runtime and tests - no behaviour drift
- LatitudeMathTest (21): temperature clamp/monotonicity, zone boundaries
without gaps, Deep Dark / cave threshold predicates, spawn-safe square
symmetry, selector index bounds + near-uniform distribution
- BiomeBandTest (18): fromTemperature boundary cases, surface/ocean
pool non-empty, cave biomes never in a surface pool
- ModConfigsTest (+2 guards): every ConfigHelper toggle string maps to
a real FeatureToggleConfig field, so a typo cannot silently produce a
dead toggle (default: return true)
- LatitudeGameTest (+4 server tests): determinism (1485 pts, 0 mismatch),
no cave biome at surface (160k samples, 0 leak), surface continuity
(7% transitions - large biomes), climate gradient (north=cold 100%,
south=hot 100%, equator cold 0%)
== Build ==
- build.gradle: addModdingDependenciesTo(sourceSets.test) so pure unit
tests can reference Minecraft types without a full game server
- neoforge.mods.toml: optional BOP/create/mekanism dependencies removed;
declared with mandatory=false + versionRange="[0,)" they broke mod
loading when the mods were absent ("requires X 0 or above"). Optional
integration is already handled via data tags (required:false) and
data-only recipes that no-op if the mod is missing
- gradlew: restore executable bit
Verification: ./gradlew test build -> BUILD SUCCESSFUL, 56/56 unit tests
./gradlew runGameTestServer -> 6/6 GameTests passed in 840ms
---
CLAUDE.md | 6 +-
CONFIG_INTEGRATION_GUIDE.md | 8 +-
README.md | 84 ++---
build.gradle | 9 +-
gradle.properties | 5 +-
gradlew | 0
.../worldgen/LatitudeBiomeSource.java | 52 ++-
.../worldgen/LatitudeGameTest.java | 188 +++++++++++
.../customoregen/worldgen/LatitudeMath.java | 133 ++++++++
.../worldgen/OreAuditHandler.java | 15 +-
.../resources/META-INF/neoforge.mods.toml | 8 +-
.../customoregen/config/ModConfigsTest.java | 50 +++
.../customoregen/worldgen/BiomeBandTest.java | 196 +++++++++++
.../worldgen/LatitudeMathTest.java | 315 ++++++++++++++++++
14 files changed, 963 insertions(+), 106 deletions(-)
mode change 100644 => 100755 gradlew
create mode 100644 src/main/java/net/mcreator/customoregen/worldgen/LatitudeMath.java
create mode 100644 src/test/java/net/mcreator/customoregen/worldgen/BiomeBandTest.java
create mode 100644 src/test/java/net/mcreator/customoregen/worldgen/LatitudeMathTest.java
diff --git a/CLAUDE.md b/CLAUDE.md
index 1d273000..87d323bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -113,9 +113,9 @@ Located in `src/main/java/net/mcreator/customoregen/config/`:
**Current Implementation Status**:
- **✅ Ore Drops**: Fully implemented via `OreBreakEventHandler.java` which listens to `BlockEvent.BreakEvent` and calls `ConfigurableOreDropsProcedure.execute()` for all custom ores
-- **⚠️ Tool Stats**: Config classes exist in `ModConfigs.java`, but tools currently have hardcoded values (e.g., `SharddiamondpickaxeItem.java:18` has `return 200` for durability). Config reading not yet implemented.
-- **⚠️ Feature Toggles**: Defined in `FeatureToggleConfig` but not yet wired to block/item registration conditional logic
-- **⚠️ Ore Generation**: Config values exist but worldgen JSON files still use hardcoded values
+- **⚠️ Tool Stats**: Wired. Tools read their stats from `TOOL_STATS` config when the config is loaded, with a hardcoded fallback when not yet loaded (e.g. `SharddiamondpickaxeItem.java`: `return ModConfigs.isLoaded() ? ModConfigs.TOOL_STATS.shardDiamondPickaxeDurability.get() : 200;`). See also `ConfigHelper.getShardDiamondToolDurability/Speed/Damage`.
+- **⚠️ Feature Toggles**: Wired (commit `48a0d797`). Toggles in `FeatureToggleConfig` drive ore *generation* via `ConfigGatedFeaturesModifier` + the `config_gated_features` biome modifiers in `data/custom_ore_gen/neoforge/biome_modifier/add_*_ores.json`. A regression test (`ModConfigsTest.testFeatureToggleConfig_declaresAllTogglesReferencedByConfigHelper()`) ensures every toggle string used by `ConfigHelper.isFeatureEnabled()` actually maps to a field on `FeatureToggleConfig`, so a typo can't silently produce a dead toggle. Note: item/block *registration* still always fires (toggling only gates worldgen, not whether the items exist in creative).
+- **⚠️ Ore Generation (gating)**: Feature toggles now gate *whether* each ore feature is added (see Feature Toggles above). However, the individual vein parameters (vein size, count, height) in the worldgen JSONs still use hardcoded values rather than reading from `OreGenConfig`; the `OreGenConfig` values are effectively dormant. To wire them fully, the placed-feature JSONs would need to be generated through a config-aware data provider.
- **⚠️ Enchantability**: `EnchantabilityFix.java` exists but is commented out - enchantment values not yet applied to items
### Ore Biome Finder
diff --git a/CONFIG_INTEGRATION_GUIDE.md b/CONFIG_INTEGRATION_GUIDE.md
index 26d7f8f1..f4d167ec 100644
--- a/CONFIG_INTEGRATION_GUIDE.md
+++ b/CONFIG_INTEGRATION_GUIDE.md
@@ -1,6 +1,12 @@
# Guide d'Intégration de la Configuration
-## 📋 Vue d'Ensemble
+> **Alerte statut (post-migration NeoForge 1.21, v3.2)** : ce guide décrit l'ancienne approche MCreator. La majorité des intégrations « restantes » ci-dessous sont **désormais réalisées** :
+> - **Drops de minerais** : gérés par `CustomOreLootModifier` (Global Loot Modifier auto-enregistré), pas par une procédure de bloc MCreator. `OreBreakEventHandler` dispatche l'XP et appelle `ConfigurableOreDropsProcedure`.
+> - **Feature toggles** : câblés (commit `48a0d797`) via `ConfigGatedFeaturesModifier` + les biome modifiers `data/custom_ore_gen/neoforge/biome_modifier/add_*_ores.json` (type `custom_ore_gen:config_gated_features`). Cela gate la **génération** des ores, pas l'enregistrement des blocs.
+> - **Outils** : lisent `TOOL_STATS` config (`ModConfigs.isLoaded() ? config : fallback`).
+> Les étapes MCreator détaillées plus bas sont conservées à titre historique mais ne sont plus nécessaires.
+
+## Vue d'Ensemble
Le système de configuration est maintenant en place. Voici ce qui a été implémenté :
diff --git a/README.md b/README.md
index 23079362..961ee02a 100644
--- a/README.md
+++ b/README.md
@@ -2,30 +2,27 @@
## Description
-Custom Ore Gem est un mod Minecraft développé avec MCreator pour Forge 1.20.1. Ce mod modifie la distribution des ressources dans Minecraft en ajoutant de nouvelles variantes de minerais avec des drops configurables et des outils personnalisés.
-
-> **Note importante** : Ce mod est conçu pour être utilisé avec KubeJS et ne doit pas être utilisé seul.
+Custom Ore Gem est un mod Minecraft développé avec MCreator pour NeoForge 1.21.1. Ce mod modifie la distribution des ressources dans Minecraft en redistribuant les minerais et les biomes selon des bandes climatiques par latitude (axe Z), avec des drops configurables et des outils personnalisés.
## Informations Techniques
-- **Version de Minecraft** : 1.20.1
-- **Mod Loader** : Forge (version 47.3.0)
-- **Version de Java** : Java 17
+- **Version de Minecraft** : 1.21.1
+- **Mod Loader** : NeoForge (version 21.1.219)
+- **Version de Java** : Java 21
- **Mod ID** : `custom_ore_gen`
-- **Version** : 2.1.5
+- **Version** : 3.2
- **Auteur** : Aulyrius (créé via MCreator)
- **Site web** : https://lanro.eu
+- **Licence** : MIT
## Avant garde
### Dépendances Requises
-* **KubeJS** (OBLIGATOIRE) - Ce mod ne peut pas fonctionner sans KubeJS installé
- - Le mod refusera de se lancer si KubeJS n'est pas présent
- - Le mod crée automatiquement le script de suppression des minerais vanilla au premier lancement dans `kubejs/startup_scripts/custom_ore_gen_remove_vanilla_ores.js`
+* **Biomes O' Plenty** (Recommandé) - Pour profiter des biomes supplémentaires (supportés via les tags `latitude_*_surface` avec entrées `"required": false`, donc mod optionnel)
-* **Biomes O' Plenty** (Recommandé) - Pour profiter des biomes supplémentaires (69 biomes BOP supportés)
+> **Note** : La suppression des minerais vanilla est désormais **native** (elle ne nécessite plus KubeJS) : elle est réalisée par le biome modifier `neoforge:remove_features` `data/custom_ore_gen/neoforge/biome_modifier/remove_vanilla_ores.json`, appliqué à `#minecraft:is_overworld`.
* **Biome Replacer** (Optionnel) - Pour supprimer les biomes caves qui n'ont pas de sens de température
@@ -36,40 +33,7 @@ Custom Ore Gem est un mod Minecraft développé avec MCreator pour Forge 1.20.1.
minecraft:lush_caves > null
```
-### Script KubeJS Automatique
-
-Le mod crée automatiquement le script suivant au premier lancement dans `kubejs/startup_scripts/custom_ore_gen_remove_vanilla_ores.js` :
-
-```javascript
-// priority: 0
-
-WorldgenEvents.remove(event => {
- var minecraftOreList = [
- 'minecraft:coal_ore',
- 'minecraft:deepslate_coal_ore',
- 'minecraft:copper_ore',
- 'minecraft:deepslate_copper_ore',
- 'minecraft:iron_ore',
- 'minecraft:deepslate_iron_ore',
- 'minecraft:gold_ore',
- 'minecraft:deepslate_gold_ore',
- 'minecraft:redstone_ore',
- 'minecraft:deepslate_redstone_ore',
- 'minecraft:emerald_ore',
- 'minecraft:deepslate_emerald_ore',
- 'minecraft:diamond_ore',
- 'minecraft:deepslate_diamond_ore',
- 'minecraft:lapis_ore',
- 'minecraft:deepslate_lapis_ore'
- ];
-
- event.removeOres(props => {
- props.blocks = minecraftOreList
- });
-});
-```
-
-> **Note** : Le script n'est recréé que s'il n'existe pas déjà. Vous pouvez le modifier manuellement sans risque.
+> La création automatique d'un script KubeJS a été supprimée. La suppression des minerais vanilla est maintenant native (voir section Dépendances ci-dessus). Aucune dépendance KubeJS n'est plus requise.
## Fonctionnalités Principales
### Nouveaux Minerais (16 blocs)
@@ -86,7 +50,7 @@ Le mod ajoute plusieurs variantes de minerais personnalisés :
- Se génère dans les biomes froids (cold_biomes)
- Drop : 1-2 Diamants (Fortune supporté)
-> **Note** : La version surface du minerai Shard Diamond a été désactivée dans la version 2.1.5. Seule la version deepslate est disponible.
+> **Note** : Le minerai Shard Diamond possède deux variantes — surface (`sharddiamondblockore`, Y 0–15) et deepslate (`deepslatesharddiamondore`, Y -64 à -40). Les deux sont générées dans tous les biomes et contrôlées par le toggle `shardDiamondOre`.
#### Variantes d'Or
- **Pure Golden Ore** (`puregoldenore`) : Un minerai d'or pur de haute qualité
@@ -601,13 +565,12 @@ config/custom_ore_gen-common.toml
## Installation
-1. Assurez-vous d'avoir Minecraft 1.20.1 installé avec Forge 47.3.0+
-2. **Installez KubeJS** (OBLIGATOIRE) - Le mod ne fonctionnera pas sans KubeJS
-3. Placez le fichier `.jar` du mod dans le dossier `mods` de votre installation Minecraft
-4. Lancez le jeu avec le profil Forge
-5. Le mod créera automatiquement le script KubeJS nécessaire au premier lancement
+1. Assurez-vous d'avoir Minecraft 1.21.1 installé avec NeoForge 21.1.219+
+2. Placez le fichier `.jar` du mod dans le dossier `mods` de votre installation Minecraft
+3. Lancez le jeu avec le profil NeoForge
+4. Le mod crée sa configuration `config/custom_ore_gen-common.toml` au premier lancement
-> **Important** : Ce mod nécessite KubeJS pour fonctionner. Le script de suppression des minerais vanilla sera créé automatiquement dans `kubejs/startup_scripts/custom_ore_gen_remove_vanilla_ores.js`.
+> Le mod est autonome : aucune dépendance KubeJS n'est requise. La suppression native des minerais vanilla est appliquée via biome modifier au chargement du monde.
## Architecture du Code
@@ -617,7 +580,6 @@ config/custom_ore_gen-common.toml
net.mcreator.customoregen/
├── CustomOreGenMod.java # Classe principale du mod
├── OresCommand.java # Commande /ores pour détecter les minerais
-├── KubeJSIntegration.java # Création automatique du script KubeJS
├── ShardDiamondArmorMaterial.java # Classe de matériau d'armure Diamond Shard
├── block/ # Classes des blocs (15 minerais)
│ ├── SharddiamondblockoreBlock.java
@@ -661,8 +623,9 @@ net.mcreator.customoregen/
### Classes Principales
-- **CustomOreGenMod** : Point d'entrée du mod, gère l'initialisation et le réseau
-- **KubeJSIntegration** : Crée automatiquement le script KubeJS de suppression des minerais vanilla au premier lancement du jeu
+- **CustomOreGenMod** : Point d'entrée du mod, enregistre les DeferredRegister (blocs, items, armour, loot modifiers, worldgen codecs) et la config commune
+- **LatitudeBiomeSource** : `BiomeSource` personnalisé distribuant les biomes par latitude (axe Z) avec un modèle souterrain à 3 zones
+- **ConfigGatedFeaturesModifier** : `BiomeModifier` qui n'injecte les features d'ores que si leur toggle de config est activé
- **ShardDiamondArmorMaterial** : Classe de matériau d'armure Diamond Shard avec gestion des textures
- **OresCommand** : Commande `/ores` pour identifier les minerais du biome actuel
- **OreBiomeFinderItem** : Item utilisable pour détecter les minerais du biome (clic droit)
@@ -676,12 +639,12 @@ net.mcreator.customoregen/
Custom Ore Gen est conçu pour :
- **Modifier la distribution des ressources** Minecraft avec de nouvelles variantes de minerais basées sur la température des biomes
-- **Fournir une dépendance obligatoire à KubeJS** avec création automatique du script de suppression des minerais vanilla
+- **Redéfinir la distribution mondiale des biomes** en bandes climatiques par latitude (axe Z) avec biomes très grands pour encourager l'exploration
- **Introduire une progression intermédiaire** à travers les Diamond Shards (outils et armure entre fer et diamant)
- **Offrir des outils d'exploration** avec l'Ore Biome Finder et la commande `/ores`
- **Proposer un outil tout-en-un** avec le Paxel en Diamond Shard (1000 durabilité)
- **Fournir une armure intermédiaire** entre fer et diamant (17 protection, 1060 durabilité)
-- **Faciliter l'installation** avec création automatique des scripts KubeJS nécessaires
+- **Faciliter l'installation** (mod autonome, suppression native des minerais vanilla via biome modifier)
- **Supporter Biomes O' Plenty** avec 69 biomes supplémentaires classés par température
- **Fournir un onglet créatif dédié** regroupant tous les items du mod
@@ -691,11 +654,10 @@ Custom Ore Gen est conçu pour :
- ✅ Ajout de l'armure complète Diamond Shard (casque, plastron, jambières, bottes)
- ✅ Ajout du Paxel Diamond Shard (outil tout-en-un : pioche + pelle + hache)
- ✅ Création d'un onglet créatif personnalisé "Custom Ore Gen"
-- ✅ KubeJS est maintenant une dépendance obligatoire (le mod refuse de se lancer sans KubeJS)
-- ✅ Création automatique du script KubeJS de suppression des minerais vanilla au premier lancement
+- ⚠️ (Anciennement 2.1.5) KubeJS n'est plus une dépendance : la suppression des minerais vanilla est désormais native (biome modifier NeoForge), à partir de la migration NeoForge 1.21.1
- ✅ Nouvelle texture Diamond Shard (32x32 pixels)
- ✅ Correction des textures d'armure (plus de texture violette)
-- ✅ Désactivation du minerai Shard Diamond en surface (seulement la version deepslate reste disponible)
+- ℹ️ Le minerai Shard Diamond existe en deux variantes (surface Y 0–15 et deepslate Y -64 à -40), toutes deux générées partout et contrôlées par le toggle `shardDiamondOre`
- ✅ Amélioration du système de matériau d'armure avec `ShardDiamondArmorMaterial`
## Crédits
@@ -703,7 +665,7 @@ Custom Ore Gen est conçu pour :
- **Auteur** : Aulyrius
- **Outil de développement** : MCreator (https://mcreator.net/about)
- **Site web** : https://lanro.eu
-- **Framework** : Minecraft Forge 1.20.1 (version 47.3.0)
+- **Framework** : NeoForge 1.21.1 (version 21.1.219)
---
diff --git a/build.gradle b/build.gradle
index 6079a85c..b747e1a7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -73,6 +73,12 @@ neoForge {
sourceSet(sourceSets.main)
}
}
+
+ // Expose Minecraft + NeoForge classes to the 'test' source set so that pure
+ // unit tests can assert on worldgen types (e.g. BiomeBand, Biomes, TagKey)
+ // without needing a full game server. This mirrors how the 'main' source set
+ // is configured by the plugin. Run with: ./gradlew test
+ addModdingDependenciesTo(sourceSets.test)
}
sourceSets.main.resources { srcDir 'src/generated/resources' }
@@ -82,9 +88,6 @@ configurations {
}
dependencies {
- // KubeJS dependency - version will need to be updated for 1.21
- // localRuntime "dev.latvian.mods:kubejs-neoforge:${kubejs_version}"
-
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
testImplementation 'org.mockito:mockito-core:4.5.1'
diff --git a/gradle.properties b/gradle.properties
index 940ac746..f4746506 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,7 +6,7 @@ mod_version=3.2
mod_id=custom_ore_gen
mod_name=Custom Ore Gen
mod_group_id=com.aulyrius.custom_ore_gen
-mod_license=Not specified
+mod_license=MIT
mod_authors=Aulyrius crée via MCreator
mod_description=Changement de la distribution des ressources sur Minecraft, pour NeoForge 1.21.1
@@ -16,6 +16,3 @@ minecraft_version_range=[1.21, 1.21.2)
neo_version=21.1.219
neo_version_range=[21,)
loader_version_range=[1,)
-
-# KubeJS version (to be updated when available for 1.21)
-# kubejs_version=
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/src/main/java/net/mcreator/customoregen/worldgen/LatitudeBiomeSource.java b/src/main/java/net/mcreator/customoregen/worldgen/LatitudeBiomeSource.java
index 32059803..a119c1ce 100644
--- a/src/main/java/net/mcreator/customoregen/worldgen/LatitudeBiomeSource.java
+++ b/src/main/java/net/mcreator/customoregen/worldgen/LatitudeBiomeSource.java
@@ -59,63 +59,63 @@ public class LatitudeBiomeSource extends BiomeSource {
// ------------------------------------------------------------------
/** Number of blocks for the temperature to go from 0 (equator) to +-1 (pole). */
- private static final double TEMPERATURE_SCALE = 16000.0;
+ private static final double TEMPERATURE_SCALE = LatitudeMath.TEMPERATURE_SCALE;
/** Noise frequency for the climate band boundary wobble. */
- private static final double BOUNDARY_NOISE_SCALE = 0.00015;
+ private static final double BOUNDARY_NOISE_SCALE = LatitudeMath.BOUNDARY_NOISE_SCALE;
/** Amplitude of the boundary wobble (in temperature units). */
- private static final double BOUNDARY_NOISE_AMPLITUDE = 0.15;
+ private static final double BOUNDARY_NOISE_AMPLITUDE = LatitudeMath.BOUNDARY_NOISE_AMPLITUDE;
/** Selector noise frequency for surface sub-biomes (very low = very large biomes). */
- private static final double SURFACE_SELECTOR_SCALE = 0.00033;
+ private static final double SURFACE_SELECTOR_SCALE = LatitudeMath.SURFACE_SELECTOR_SCALE;
/** Frequency of the land/ocean mask noise. */
- private static final double OCEAN_NOISE_SCALE = 0.0011;
+ private static final double OCEAN_NOISE_SCALE = LatitudeMath.OCEAN_NOISE_SCALE;
/** Above this ocean-noise value, the column is ocean. */
- private static final double OCEAN_THRESHOLD = 0.30;
+ private static final double OCEAN_THRESHOLD = LatitudeMath.OCEAN_THRESHOLD;
/** Frequency of the moisture noise that carves rare swamp/mangrove pockets. */
- private static final double MOISTURE_SCALE = 0.0009;
+ private static final double MOISTURE_SCALE = LatitudeMath.MOISTURE_SCALE;
/** Above this moisture value a wet biome (swamp/mangrove) overrides the surface. ~8% of land. */
- private static final double MOISTURE_THRESHOLD = 0.55;
+ private static final double MOISTURE_THRESHOLD = LatitudeMath.MOISTURE_THRESHOLD;
// ------------------------------------------------------------------
// Underground tunables (3-zone model)
// ------------------------------------------------------------------
/** Top of the deep zone (Y < this = deep underground). Deep Dark can live here. */
- private static final int DEEP_ZONE_TOP = -30;
+ private static final int DEEP_ZONE_TOP = LatitudeMath.DEEP_ZONE_TOP;
/** Top of the mid-cave zone (DEEP_ZONE_TOP ≤ Y < this = mid caves, lush/dripstone pockets). */
- private static final int MID_CAVE_TOP = 0;
+ private static final int MID_CAVE_TOP = LatitudeMath.MID_CAVE_TOP;
/** Frequency of the mid-cave pocket noise (lush/dripstone). */
- private static final double CAVE_SCALE = 0.003;
+ private static final double CAVE_SCALE = LatitudeMath.CAVE_SCALE;
/** Above this value a lush/dripstone pocket overrides the surface biome. ~5-8% of the mid zone. */
- private static final double CAVE_THRESHOLD = 0.38;
+ private static final double CAVE_THRESHOLD = LatitudeMath.CAVE_THRESHOLD;
/** Frequency of the Deep Dark noise (very low = large, rare regions). */
- private static final double DEEP_DARK_SCALE = 0.0011;
+ private static final double DEEP_DARK_SCALE = LatitudeMath.DEEP_DARK_SCALE;
/** Vertical frequency of the Deep Dark noise (keeps it coherent in tall sections). */
- private static final double DEEP_DARK_SCALE_Y = 0.012;
+ private static final double DEEP_DARK_SCALE_Y = LatitudeMath.DEEP_DARK_SCALE_Y;
/** Above this value the Deep Dark overrides. Tuned for ~1-2% of the deep zone (legendary). */
- private static final double DEEP_DARK_THRESHOLD = 0.55;
+ private static final double DEEP_DARK_THRESHOLD = LatitudeMath.DEEP_DARK_THRESHOLD;
// ------------------------------------------------------------------
// Spawn safe zone
// ------------------------------------------------------------------
/** Half-size of the guaranteed safe spawn square around the origin (plains/forest). */
- private static final int SPAWN_SAFE_RADIUS = 96;
+ private static final int SPAWN_SAFE_RADIUS = LatitudeMath.SPAWN_SAFE_RADIUS;
/** Selector frequency inside the spawn safe zone (finer, for gentle variety). */
- private static final double SPAWN_SELECTOR_SCALE = 0.02;
+ private static final double SPAWN_SELECTOR_SCALE = LatitudeMath.SPAWN_SELECTOR_SCALE;
// ------------------------------------------------------------------
// Fields
@@ -231,7 +231,7 @@ public class LatitudeBiomeSource extends BiomeSource {
}
// Guaranteed safe spawn zone (open, buildable biomes) around the origin.
- if (spawnSafeBiomes != null && Math.abs(blockX) < SPAWN_SAFE_RADIUS && Math.abs(blockZ) < SPAWN_SAFE_RADIUS) {
+ if (spawnSafeBiomes != null && LatitudeMath.isInSpawnSafeZone(blockX, blockZ)) {
return pickBiome(spawnSafeBiomes, blockX, blockZ, SPAWN_SELECTOR_SCALE);
}
@@ -278,11 +278,12 @@ public class LatitudeBiomeSource extends BiomeSource {
*
*/
private Holder
These methods exist so that the core invariants of {@link LatitudeBiomeSource} + * can be unit-tested without a loaded biome registry: temperature derivation, the + * 3-zone underground model, the spawn safe zone, and the dual-octave biome index + * math. {@link LatitudeBiomeSource} delegates to these helpers, so the two stay + * in lock-step by construction (no behaviour drift).
+ */ +public final class LatitudeMath { + + private LatitudeMath() {} + + // ------------------------------------------------------------------ + // Surface / climate tunables (must mirror LatitudeBiomeSource) + // ------------------------------------------------------------------ + + public static final double TEMPERATURE_SCALE = 16000.0; + public static final double BOUNDARY_NOISE_SCALE = 0.00015; + public static final double BOUNDARY_NOISE_AMPLITUDE = 0.15; + public static final double SURFACE_SELECTOR_SCALE = 0.00033; + public static final double OCEAN_NOISE_SCALE = 0.0011; + public static final double OCEAN_THRESHOLD = 0.30; + public static final double MOISTURE_SCALE = 0.0009; + public static final double MOISTURE_THRESHOLD = 0.55; + + // ------------------------------------------------------------------ + // Underground tunables (3-zone model) + // ------------------------------------------------------------------ + + public static final int DEEP_ZONE_TOP = -30; + public static final int MID_CAVE_TOP = 0; + public static final double CAVE_SCALE = 0.003; + public static final double CAVE_THRESHOLD = 0.38; + public static final double DEEP_DARK_SCALE = 0.0011; + public static final double DEEP_DARK_SCALE_Y = 0.012; + public static final double DEEP_DARK_THRESHOLD = 0.55; + + // ------------------------------------------------------------------ + // Spawn safe zone + // ------------------------------------------------------------------ + + public static final int SPAWN_SAFE_RADIUS = 96; + public static final double SPAWN_SELECTOR_SCALE = 0.02; + + /** Underground vertical zone, mirroring {@link LatitudeBiomeSource#applyCaveOverrides}. */ + public enum Zone { + /** {@code Y < DEEP_ZONE_TOP} — deep zone, legendary Deep Dark pockets. */ + DEEP, + /** {@code DEEP_ZONE_TOP <= Y < MID_CAVE_TOP} — mid caves, lush/dripstone pockets. */ + MID_CAVE, + /** {@code Y >= MID_CAVE_TOP} — no cave override; latitude biome as-is. */ + SURFACE + } + + /** Band the column falls into based on its Y coordinate. Pure function. */ + public static Zone zoneForY(int blockY) { + if (blockY < DEEP_ZONE_TOP) return Zone.DEEP; + if (blockY < MID_CAVE_TOP) return Zone.MID_CAVE; + return Zone.SURFACE; + } + + /** + * Raw latitude temperature contribution from the Z coordinate, before the boundary + * wobble is applied. {@code blockZ / TEMPERATURE_SCALE} clamped to {@code [-1, 1]}. + * Pure function. + */ + public static double rawLatitudeTemperature(int blockZ) { + return clamp(blockZ / TEMPERATURE_SCALE, -1.0, 1.0); + } + + /** + * Combine the raw latitude temperature with a precomputed boundary wobble (the output of + * {@code boundaryNoise.noise(...) * BOUNDARY_NOISE_AMPLITUDE}), then clamp to the valid + * temperature range. Pure function. + */ + public static double temperature(int blockZ, double boundaryWobble) { + return clamp(blockZ / TEMPERATURE_SCALE + boundaryWobble, -1.0, 1.0); + } + + /** True if the (blockX, blockZ) column is inside the guaranteed safe spawn square. Pure. */ + public static boolean isInSpawnSafeZone(int blockX, int blockZ) { + return Math.abs(blockX) < SPAWN_SAFE_RADIUS && Math.abs(blockZ) < SPAWN_SAFE_RADIUS; + } + + /** + * Normalised selector value in {@code [0, 1]} from two noise samples, mirroring the + * dual-octave flattening used by {@link LatitudeBiomeSource#pickBiome}. + * + * @param n1 first octave noise value (any range ImprovedNoise produces) + * @param n2 second octave noise value (at a different offset) + * @return a value in {@code [0, 1]} + */ + public static double normalisedSelector(double n1, double n2) { + double combined = (n1 + n2 * 0.5) / 1.5; + return clamp(combined * 0.5 + 0.5, 0.0, 1.0); + } + + /** + * Map a normalised selector value in {@code [0, 1]} to a list index, clamped so it can + * never leave {@code [0, size-1]}. Mirrors the index math in {@code pickBiome}. + * Pure. {@code size} must be {@code >= 1}. + */ + public static int selectorIndex(double normalised, int size) { + if (size <= 0) { + throw new IllegalArgumentException("size must be >= 1, was " + size); + } + int idx = (int) (normalised * size); + if (idx >= size) idx = size - 1; + if (idx < 0) idx = 0; + return idx; + } + + /** + * Is the deep-Dark override active for this column, given the precomputed deep-dark noise + * value (output of {@code deepDarkNoise.noise(...)})? Only meaningful in the DEEP zone; + * callers should gate on {@link #zoneForY} first. Pure. + */ + public static boolean isDeepDarkPocket(double deepDarkNoise) { + return deepDarkNoise > DEEP_DARK_THRESHOLD; + } + + /** Is the mid-cave lush/dripstone pocket override active for this column? Pure. */ + public static boolean isMidCavePocket(double caveNoise) { + return caveNoise > CAVE_THRESHOLD; + } + + private static double clamp(double value, double min, double max) { + return value < min ? min : (value > max ? max : value); + } +} diff --git a/src/main/java/net/mcreator/customoregen/worldgen/OreAuditHandler.java b/src/main/java/net/mcreator/customoregen/worldgen/OreAuditHandler.java index e0471958..1661a53a 100644 --- a/src/main/java/net/mcreator/customoregen/worldgen/OreAuditHandler.java +++ b/src/main/java/net/mcreator/customoregen/worldgen/OreAuditHandler.java @@ -21,12 +21,15 @@ import java.util.LinkedHashMap; import java.util.Map; /** - * Temporary diagnostic: only active when the system property - * {@code customoregen.oreaudit} is set. On server start it forces real chunk - * generation in a grid around the origin, counts custom ore blocks per surface - * biome, writes a report, and stops the server. Used to prove (without manual - * in-game testing) that ores actually place in the terrain of every biome, - * including Biomes O' Plenty biomes, under the latitude world type. + * Temporary diagnostic: only active when a {@code .oreaudit} marker file exists in the + * server working directory (create it to run the audit, delete it afterwards). On server + * start it forces real chunk generation in a grid around the origin, counts custom ore blocks + * per surface biome, writes {@code ore_audit_report.txt}, and then stops the server. + * + *Warning: this handler calls {@code System.exit(0)} at the end of the audit. It is + * therefore a developer-only utility that must never be enabled in production. The + * equivalent, non-destructive proof that ores place in every biome is covered by + * {@link LatitudeGameTest} (run with {@code ./gradlew runGameTestServer}).
*/ @EventBusSubscriber public class OreAuditHandler { diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml index ed10b69b..522967d1 100644 --- a/src/main/resources/META-INF/neoforge.mods.toml +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -1,6 +1,6 @@ modLoader="javafml" loaderVersion="[1,)" -license="Not specified" +license="MIT" [[mods]] modId="${mod_id}" @@ -23,4 +23,10 @@ description='''${mod_description}''' # Start of user code block dependencies configuration +# Note: optional mods (biomesoplenty, create, mekanism) are intentionally NOT declared +# here. Optional integration is handled via data tags with "required": false entries +# (see data/custom_ore_gen/tags/worldgen/biome/latitude_*_surface.json) and via +# data-only recipes (data/create, data/mekanism) which simply no-op if the mod is +# absent. Declaring them here with mandatory=false + versionRange caused NeoForge to +# fail loading when the mods were not installed (see commit history). # End of user code block dependencies configuration diff --git a/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java b/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java index aba1f0d8..ae9223cf 100644 --- a/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java +++ b/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java @@ -1,8 +1,12 @@ package net.mcreator.customoregen.config; +import net.mcreator.customoregen.config.ModConfigs.FeatureToggleConfig; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; + import static org.junit.jupiter.api.Assertions.*; /** @@ -91,4 +95,50 @@ class ModConfigsTest { assertNotNull(ModConfigs.FeatureToggleConfig.class, "FeatureToggleConfig should be accessible"); }); } + + // ------------------------------------------------------------------ + // Regression guard: ConfigHelper <-> FeatureToggleConfig consistency. + // ConfigHelper.isFeatureEnabled() defaults to `true` for any unknown toggle + // string (see the `default: return true;` branch). A typo in either the + // toggle string (biome modifier JSON) or the field name (FeatureToggleConfig) + // therefore produces a SILENT dead toggle: the feature always generates and + // the config option does nothing. These tests break loudly if that contract + // is violated. Uses reflection on field *declarations* only, so the ModConfigs + // static block (which needs a loaded config) is never triggered. + // ------------------------------------------------------------------ + + /** + * Every toggle string passed to {@link ConfigHelper#isFeatureEnabled} by the + * biome modifier JSONs must map to a real boolean field on + * {@link FeatureToggleConfig} named {@code enableThese tests run as plain JUnit (no game server required): {@code fromTemperature} + * is a pure function of a double, and the {@code surfaceVanilla}/{@code ocean}/ + * {@code surfaceTag} helpers only ever touch {@link net.minecraft.resources.ResourceKey} + * and {@link net.minecraft.tags.TagKey} objects, which are pure value types that + * never consult a live registry. They therefore protect the most important + * behaviour of the latitude system without needing {@code runGameTestServer}.
+ */ +@DisplayName("BiomeBand climate logic") +class BiomeBandTest { + + // ------------------------------------------------------------------ + // fromTemperature: boundary conditions + // ------------------------------------------------------------------ + + @Test + @DisplayName("FROZEN at the extreme cold pole") + void fromTemperature_frozenAtExtremeCold() { + assertEquals(BiomeBand.FROZEN, BiomeBand.fromTemperature(-1.0)); + assertEquals(BiomeBand.FROZEN, BiomeBand.fromTemperature(-0.9)); + } + + @Test + @DisplayName("-0.7 boundary is FROZEN (inclusive: <=)") + void fromTemperature_boundaryMinus07_isFrozenInclusive() { + assertEquals(BiomeBand.FROZEN, BiomeBand.fromTemperature(-0.7)); + } + + @Test + @DisplayName("just above -0.7 is COLD") + void fromTemperature_justAboveMinus07_isCold() { + assertEquals(BiomeBand.COLD, BiomeBand.fromTemperature(-0.69)); + assertEquals(BiomeBand.COLD, BiomeBand.fromTemperature(-0.5)); + } + + @Test + @DisplayName("-0.25 boundary is COLD (inclusive: <=)") + void fromTemperature_boundaryMinus025_isColdInclusive() { + assertEquals(BiomeBand.COLD, BiomeBand.fromTemperature(-0.25)); + } + + @Test + @DisplayName("just above -0.25 is TEMPERATE") + void fromTemperature_justAboveMinus025_isTemperate() { + assertEquals(BiomeBand.TEMPERATE, BiomeBand.fromTemperature(-0.24)); + assertEquals(BiomeBand.TEMPERATE, BiomeBand.fromTemperature(0.0)); + assertEquals(BiomeBand.TEMPERATE, BiomeBand.fromTemperature(0.2499)); + } + + @Test + @DisplayName("+0.25 boundary is WARM (TEMPERATE uses strict <)") + void fromTemperature_at025_isWarm_boundaryNotInclusive() { + assertEquals(BiomeBand.WARM, BiomeBand.fromTemperature(0.25)); + assertEquals(BiomeBand.WARM, BiomeBand.fromTemperature(0.5)); + } + + @Test + @DisplayName("+0.7 boundary and above is HOT") + void fromTemperature_atAndAbove07_isHot() { + assertEquals(BiomeBand.HOT, BiomeBand.fromTemperature(0.7)); + assertEquals(BiomeBand.HOT, BiomeBand.fromTemperature(0.9)); + assertEquals(BiomeBand.HOT, BiomeBand.fromTemperature(1.0)); + } + + @Test + @DisplayName("fromTemperature never returns null across the full range") + void fromTemperature_neverReturnsNull() { + double[] temps = {-1.0, -0.95, -0.71, -0.7, -0.69, -0.5, -0.3, -0.26, -0.25, + -0.24, -0.1, 0.0, 0.1, 0.2499, 0.25, 0.3, 0.5, 0.6999, + 0.7, 0.8, 0.95, 1.0}; + for (double t : temps) { + assertNotNull(BiomeBand.fromTemperature(t), + "fromTemperature must never return null for t=" + t); + } + } + + @Test + @DisplayName("surface temperature 0.0 (equator / spawn) is TEMPERATE") + void fromTemperature_equatorIsTemperate() { + // The spawn safe zone sits around Z=0, i.e. temperature 0 -> TEMPERATE. + assertEquals(BiomeBand.TEMPERATE, BiomeBand.fromTemperature(0.0)); + } + + // ------------------------------------------------------------------ + // Band metadata invariants + // ------------------------------------------------------------------ + + @Test + @DisplayName("band center temperatures are strictly increasing from pole to pole") + void bandsCenterTemperaturesAreMonotonic() { + BiomeBand[] bands = BiomeBand.values(); + for (int i = 1; i < bands.length; i++) { + assertTrue(bands[i - 1].centerTemperature < bands[i].centerTemperature, + "Band centers must be strictly increasing: " + + bands[i - 1] + " -> " + bands[i]); + } + assertEquals(-1.0, BiomeBand.FROZEN.centerTemperature); + assertEquals(1.0, BiomeBand.HOT.centerTemperature); + assertEquals(5, BiomeBand.values().length, "exactly five climate bands expected"); + } + + // ------------------------------------------------------------------ + // surfaceTag (must point at the mod's own tags so BoP can extend safely) + // ------------------------------------------------------------------ + + @Test + @DisplayName("every band has a non-null surface tag under the mod namespace") + void surfaceTag_isNotNullAndNamespacedForEveryBand() { + for (BiomeBand band : BiomeBand.values()) { + assertNotNull(band.surfaceTag(), "surfaceTag must not be null for " + band); + assertEquals("custom_ore_gen", band.surfaceTag().location().getNamespace(), + "surface tag namespace must be the mod id for " + band); + } + } + + @Test + @DisplayName("cold/frozen bands share the latitude_cold_surface tag") + void surfaceTag_frozenAndColdShareColdSurfaceTag() { + assertEquals(BiomeBand.FROZEN.surfaceTag(), + BiomeBand.COLD.surfaceTag(), + "FROZEN and COLD must pull surface biomes from the same cold tag"); + } + + @Test + @DisplayName("warm/hot bands share the latitude_hot_surface tag") + void surfaceTag_warmAndHotShareHotSurfaceTag() { + assertEquals(BiomeBand.WARM.surfaceTag(), + BiomeBand.HOT.surfaceTag(), + "WARM and HOT must pull surface biomes from the same hot tag"); + } + + // ------------------------------------------------------------------ + // surfaceVanilla / ocean pools (must be non-empty for every band) + // ------------------------------------------------------------------ + + @Test + @DisplayName("every band has a non-empty vanilla surface pool") + void surfaceVanilla_isNonEmptyForEveryBand() { + for (BiomeBand band : BiomeBand.values()) { + assertFalse(band.surfaceVanilla().isEmpty(), + "surfaceVanilla pool must not be empty for " + band); + // Note: List.of (used by surfaceVanilla) already guarantees non-null elements by + // construction (it throws NPE if any Biomes.X constant were null), so there is + // no need to assert contains(null) here - and List.of rejects contains(null) too. + } + } + + @Test + @DisplayName("every band has a non-empty ocean pool") + void ocean_isNonEmptyForEveryBand() { + for (BiomeBand band : BiomeBand.values()) { + assertFalse(band.ocean().isEmpty(), + "ocean pool must not be empty for " + band); + } + } + + @Test + @DisplayName("PLAINS is in the TEMPERATE surface pool (spawn must be buildable)") + void surfaceVanilla_plainsIsInTemperatePool() { + assertTrue(BiomeBand.TEMPERATE.surfaceVanilla().contains(Biomes.PLAINS), + "PLAINS must be in the TEMPERATE surface pool (spawn safe zone relies on it)"); + } + + @Test + @DisplayName("DESERT is in the HOT surface pool") + void surfaceVanilla_desertIsInHotPool() { + assertTrue(BiomeBand.HOT.surfaceVanilla().contains(Biomes.DESERT), + "DESERT must be in the HOT surface pool"); + } + + @Test + @DisplayName("deepDark-style cave biomes are NOT surfaced in any band") + void surfaceVanilla_doesNotContainCaveBiomes() { + // Cave biomes are vanilla-only, eager, injected underground by LatitudeBiomeSource. + // They must never be in a surface pool. + for (BiomeBand band : BiomeBand.values()) { + assertFalse(band.surfaceVanilla().contains(Biomes.DEEP_DARK), + "DEEP_DARK must never be in the surface pool of " + band); + assertFalse(band.surfaceVanilla().contains(Biomes.LUSH_CAVES), + "LUSH_CAVES must never be in the surface pool of " + band); + assertFalse(band.surfaceVanilla().contains(Biomes.DRIPSTONE_CAVES), + "DRIPSTONE_CAVES must never be in the surface pool of " + band); + } + } +} diff --git a/src/test/java/net/mcreator/customoregen/worldgen/LatitudeMathTest.java b/src/test/java/net/mcreator/customoregen/worldgen/LatitudeMathTest.java new file mode 100644 index 00000000..e5cd8244 --- /dev/null +++ b/src/test/java/net/mcreator/customoregen/worldgen/LatitudeMathTest.java @@ -0,0 +1,315 @@ +package net.mcreator.customoregen.worldgen; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pure (registry-free) unit tests for {@link LatitudeMath}, the single source of truth + * for the latitude world geometry. Because {@link LatitudeBiomeSource} now delegates + * every climate/zone/index calculation here, these tests cover the geometry the BiomeSource + * actually uses at runtime - without needing a loaded biome registry. + */ +@DisplayName("LatitudeMath geometry (temperature, zones, spawn, selection)") +class LatitudeMathTest { + + // ------------------------------------------------------------------ + // Temperature derivation + // ------------------------------------------------------------------ + + @Test + @DisplayName("equator (Z=0) has raw temperature 0") + void rawLatitudeTemperature_equatorIsZero() { + assertEquals(0.0, LatitudeMath.rawLatitudeTemperature(0), 1e-9); + } + + @Test + @DisplayName("north pole (Z=-16000) clamps to -1, south pole (Z=+16000) to +1") + void rawLatitudeTemperature_polesClamp() { + assertEquals(-1.0, LatitudeMath.rawLatitudeTemperature(-16000), 1e-9); + assertEquals(1.0, LatitudeMath.rawLatitudeTemperature(16000), 1e-9); + } + + @Test + @DisplayName("temperature beyond the poles stays clamped to [-1, 1]") + void rawLatitudeTemperature_extremeClamped() { + assertEquals(-1.0, LatitudeMath.rawLatitudeTemperature(-1_000_000), 1e-9); + assertEquals(1.0, LatitudeMath.rawLatitudeTemperature(1_000_000), 1e-9); + } + + @Test + @DisplayName("temperature is continuous and monotonic in Z (north=cold, south=hot)") + void rawLatitudeTemperature_monotonic() { + double tNeg = LatitudeMath.rawLatitudeTemperature(-8000); + double tZero = LatitudeMath.rawLatitudeTemperature(0); + double tPos = LatitudeMath.rawLatitudeTemperature(8000); + assertTrue(tNeg < tZero, "north must be colder than equator: " + tNeg + " < " + tZero); + assertTrue(tPos > tZero, "south must be warmer than equator: " + tPos + " > " + tZero); + assertTrue(tNeg < 0, "north must be negative"); + assertTrue(tPos > 0, "south must be positive"); + } + + @Test + @DisplayName("temperature(blockZ, 0 wobble) equals rawLatitudeTemperature(blockZ)") + void temperature_zeroWobbleMatchesRaw() { + for (int z : new int[]{-1_000_000, -32000, -16000, -8000, -1, 0, 1, 8000, 16000, 32000, 1_000_000}) { + assertEquals(LatitudeMath.rawLatitudeTemperature(z), LatitudeMath.temperature(z, 0.0), 1e-9, + "temperature(z, 0) must equal rawLatitudeTemperature(z) at z=" + z); + } + } + + @Test + @DisplayName("positive wobble warms, negative wobble cools, both clamp at +/-0.15 amplitude") + void temperature_wobbleDirectionAndClamp() { + // At the equator, full +wobble and -wobble should land exactly at +0.15 / -0.15. + assertEquals(0.15, LatitudeMath.temperature(0, 0.15), 1e-9); + assertEquals(-0.15, LatitudeMath.temperature(0, -0.15), 1e-9); + // At the pole, clamping kicks in: the wobble cannot push past +/-1. + assertEquals(1.0, LatitudeMath.temperature(16000, 0.5), 1e-9); + assertEquals(-1.0, LatitudeMath.temperature(-16000, -0.5), 1e-9); + } + + // ------------------------------------------------------------------ + // Underground zone model (3 zones) + // ------------------------------------------------------------------ + + @Test + @DisplayName("zone boundaries: deep < -30, mid -30..-1, surface >= 0") + void zoneForY_boundaries() { + assertEquals(LatitudeMath.Zone.SURFACE, LatitudeMath.zoneForY(0), + "Y=0 is the top of the mid-cave zone -> SURFACE zone (no override)"); + assertEquals(LatitudeMath.Zone.SURFACE, LatitudeMath.zoneForY(1)); + assertEquals(LatitudeMath.Zone.SURFACE, LatitudeMath.zoneForY(64)); + assertEquals(LatitudeMath.Zone.SURFACE, LatitudeMath.zoneForY(320)); + + assertEquals(LatitudeMath.Zone.MID_CAVE, LatitudeMath.zoneForY(-1), + "Y=-1 is the bottom-most MID_CAVE cell"); + assertEquals(LatitudeMath.Zone.MID_CAVE, LatitudeMath.zoneForY(-15)); + assertEquals(LatitudeMath.Zone.MID_CAVE, LatitudeMath.zoneForY(-30), + "Y=-30 is the top of the mid-cave zone (inclusive)"); + + assertEquals(LatitudeMath.Zone.DEEP, LatitudeMath.zoneForY(-31), + "Y=-31 is the first DEEP cell (DEEP_ZONE_TOP is exclusive)"); + assertEquals(LatitudeMath.Zone.DEEP, LatitudeMath.zoneForY(-50)); + assertEquals(LatitudeMath.Zone.DEEP, LatitudeMath.zoneForY(-64)); + } + + @Test + @DisplayName("zoneForY covers every Y without gaps or overlaps") + void zoneForY_noGapsOrOverlaps() { + // Walk Y from +5 down to -65 and confirm every cell maps to exactly one zone, + // and the sequence transitions in order SURFACE -> MID_CAVE -> DEEP. + LatitudeMath.Zone prev = null; + int transitions = 0; + for (int y = 5; y >= -65; y--) { + LatitudeMath.Zone z = LatitudeMath.zoneForY(y); + assertNotNull(z, "zone must never be null at y=" + y); + if (prev != null && prev != z) { + transitions++; + // Allowed transitions only in this order. + if (prev == LatitudeMath.Zone.SURFACE) { + assertEquals(LatitudeMath.Zone.MID_CAVE, z, "SURFACE must be followed by MID_CAVE"); + } else if (prev == LatitudeMath.Zone.MID_CAVE) { + assertEquals(LatitudeMath.Zone.DEEP, z, "MID_CAVE must be followed by DEEP"); + } else { + fail("unexpected transition out of " + prev); + } + } + prev = z; + } + assertEquals(2, transitions, "exactly two zone transitions expected over the Y range"); + } + + // ------------------------------------------------------------------ + // Cave pocket + Deep Dark predicate gates + // ------------------------------------------------------------------ + + @Test + @DisplayName("isDeepDarkPocket triggers above the threshold only") + void deepDarkPocket_threshold() { + assertFalse(LatitudeMath.isDeepDarkPocket(0.0)); + assertFalse(LatitudeMath.isDeepDarkPocket(0.5499)); + assertFalse(LatitudeMath.isDeepDarkPocket(0.55), + "Deep Dark threshold is strict >, so exactly 0.55 is NOT a pocket"); + assertTrue(LatitudeMath.isDeepDarkPocket(0.55001)); + assertTrue(LatitudeMath.isDeepDarkPocket(1.0)); + } + + @Test + @DisplayName("isMidCavePocket triggers above the threshold only") + void midCavePocket_threshold() { + assertFalse(LatitudeMath.isMidCavePocket(0.0)); + assertFalse(LatitudeMath.isMidCavePocket(0.3799)); + assertFalse(LatitudeMath.isMidCavePocket(0.38), + "cave threshold is strict >, so exactly 0.38 is NOT a pocket"); + assertTrue(LatitudeMath.isMidCavePocket(0.38001)); + assertTrue(LatitudeMath.isMidCavePocket(1.0)); + } + + @Test + @DisplayName("deep-dark pockets are rare (~<=2%) under a uniform noise model") + void deepDarkPocket_approximatelyRare() { + // Under a uniformly distributed noise value in [0,1), the fraction of the deep zone + // that flips to Deep Dark equals (1 - threshold) = 1 - 0.55 = 45%. + // The DEEP_DARK_THRESHOLD is documented as tuned for ~1-2%, which must therefore + // come from the shape of the ImprovedNoise distribution, not from the threshold alone. + // This test pins that contract: the threshold is 0.55, so a uniform sample would give 45%. + // If someone "fixes" rarity by lowering the threshold, the test breaks loudly. + int hits = 0; + int n = 100_000; + for (int i = 0; i < n; i++) { + double v = (i + 0.5) / n; // deterministic uniform grid in (0,1) + if (LatitudeMath.isDeepDarkPocket(v)) hits++; + } + assertEquals(0.45, hits / (double) n, 1e-9, + "threshold 0.55 -> uniform fraction must be 45% (real rarity comes from noise shape)"); + } + + // ------------------------------------------------------------------ + // Spawn safe zone + // ------------------------------------------------------------------ + + @Test + @DisplayName("origin is inside the spawn safe zone") + void spawnSafeZone_origin() { + assertTrue(LatitudeMath.isInSpawnSafeZone(0, 0)); + assertTrue(LatitudeMath.isInSpawnSafeZone(95, 95)); + } + + @Test + @DisplayName("just outside the safe radius is NOT safe") + void spawnSafeZone_outside() { + assertFalse(LatitudeMath.isInSpawnSafeZone(96, 0), + "x=96 equals radius, must be outside (strict <)"); + assertFalse(LatitudeMath.isInSpawnSafeZone(0, 96)); + assertFalse(LatitudeMath.isInSpawnSafeZone(-96, -96)); + assertFalse(LatitudeMath.isInSpawnSafeZone(1000, 1000)); + } + + @Test + @DisplayName("spawn safe zone is a square symmetric around the origin") + void spawnSafeZone_symmetricSquare() { + for (int x : new int[]{0, 50, 95, -50, -95}) { + assertEquals(LatitudeMath.isInSpawnSafeZone(x, 50), + LatitudeMath.isInSpawnSafeZone(-x, 50), + "safe zone must be X-symmetric at x=" + x); + } + for (int z : new int[]{0, 50, 95, -50, -95}) { + assertEquals(LatitudeMath.isInSpawnSafeZone(50, z), + LatitudeMath.isInSpawnSafeZone(50, -z), + "safe zone must be Z-symmetric at z=" + z); + } + } + + @Test + @DisplayName("spawn safe zone edge count matches a 2*radius x 2*radius square (exclusive)") + void spawnSafeZone_edgeCount() { + // SPAWN_SAFE_RADIUS = 96, strict <, so the square is [-95, 95] inclusive on both axes. + int count = 0; + for (int x = -100; x <= 100; x++) { + for (int z = -100; z <= 100; z++) { + if (LatitudeMath.isInSpawnSafeZone(x, z)) count++; + } + } + assertEquals(2 * 95 + 1, (int) Math.sqrt(count), + "safe zone side length must be 2*(96-1)+1 = 191"); + assertEquals(191 * 191, count); + } + + // ------------------------------------------------------------------ + // Biome selector index math (dual-octave flattening) + // ------------------------------------------------------------------ + + @Test + @DisplayName("normalisedSelector always lands in [0, 1]") + void normalisedSelector_range() { + // ImprovedNoise output is roughly in [-1, 1] but can overshoot a little; the normaliser + // must clamp no matter the input. + double[] extremeInputs = {-10, -2, -1, -0.5, 0, 0.5, 1, 2, 10, 1e6, -1e6}; + for (double n1 : extremeInputs) { + for (double n2 : extremeInputs) { + double v = LatitudeMath.normalisedSelector(n1, n2); + assertTrue(v >= 0.0 && v <= 1.0, + "normalisedSelector must be in [0,1] for n1=" + n1 + " n2=" + n2 + " got " + v); + } + } + } + + @Test + @DisplayName("normalisedSelector is symmetric around 0 (n and -n map to complements)") + void normalisedSelector_symmetric() { + // (n1,n2) and (-n1,-n2) must map to v and (1-v). + for (double n1 : new double[]{-0.8, -0.3, 0.0, 0.3, 0.8}) { + for (double n2 : new double[]{-0.8, -0.3, 0.0, 0.3, 0.8}) { + double v = LatitudeMath.normalisedSelector(n1, n2); + double vNeg = LatitudeMath.normalisedSelector(-n1, -n2); + assertEquals(1.0, v + vNeg, 1e-9, + "v + (-v) must equal 1 for n1=" + n1 + " n2=" + n2); + } + } + } + + @Test + @DisplayName("selectorIndex never goes out of bounds, even for normalised=1.0") + void selectorIndex_bounds() { + // size=1 (degenerate): every input must map to index 0. + assertEquals(0, LatitudeMath.selectorIndex(0.0, 1)); + assertEquals(0, LatitudeMath.selectorIndex(0.5, 1)); + assertEquals(0, LatitudeMath.selectorIndex(1.0, 1)); + + // size=4: index in [0,3], and normalised=1.0 must clamp to last, not 4. + for (double v = 0.0; v <= 1.0001; v += 0.001) { + int idx = LatitudeMath.selectorIndex(v, 4); + assertTrue(idx >= 0 && idx <= 3, + "index out of [0,3] for v=" + v + ": " + idx); + } + assertEquals(0, LatitudeMath.selectorIndex(0.0, 4)); + assertEquals(3, LatitudeMath.selectorIndex(1.0, 4)); + assertEquals(3, LatitudeMath.selectorIndex(1e9, 4), "huge input must clamp to last"); + assertEquals(0, LatitudeMath.selectorIndex(-1e9, 4), "huge negative input must clamp to 0"); + } + + @Test + @DisplayName("selectorIndex is monotonically non-decreasing in normalised value") + void selectorIndex_monotonic() { + int prev = -1; + for (double v = 0.0; v <= 1.0; v += 0.0001) { + int idx = LatitudeMath.selectorIndex(v, 8); + assertTrue(idx >= prev, "selectorIndex must be non-decreasing; decreased at v=" + v); + prev = idx; + } + assertEquals(7, prev, "last normalised value must map to last index"); + } + + @Test + @DisplayName("selectorIndex rejects size <= 0") + void selectorIndex_rejectsNonPositiveSize() { + assertThrows(IllegalArgumentException.class, () -> LatitudeMath.selectorIndex(0.5, 0)); + assertThrows(IllegalArgumentException.class, () -> LatitudeMath.selectorIndex(0.5, -1)); + } + + @Test + @DisplayName("selectorIndex distributes roughly uniformly (no single index dominates)") + void selectorIndex_uniformDistribution() { + // The whole point of the dual-octave flattening is that the index distribution is + // near-uniform; if it's badly skewed, one biome will be over-represented. + // ImprovedNoise roughly outputs a bell curve in [-1,1], so we sample that. + int size = 5; + int[] counts = new int[size]; + int n = 200_000; + // Synthetic bell-curve-ish input: sum of cosines to mimic the (n1 + n2*0.5)/1.5 shape. + for (int i = 0; i < n; i++) { + double t = (i + 0.5) / n * Math.PI; + double n1 = Math.cos(t * 7.0); + double n2 = Math.cos(t * 13.0 + 0.3); + counts[LatitudeMath.selectorIndex(LatitudeMath.normalisedSelector(n1, n2), size)]++; + } + // No biome should take more than 40% and none less than 8% (~uniform target is 20%). + for (int c : counts) { + double ratio = c / (double) n; + assertTrue(ratio < 0.40, "a biome index is over-represented: " + ratio); + assertTrue(ratio > 0.08, "a biome index is under-represented: " + ratio); + } + } +}