Compare commits

...
Author SHA1 Message Date
koka 6e94c1c539 Add contained surface hydrology to Sanctuary Island
Build Sanctuary / build (push) Canceled after 0s
2026-09-08 14:24:56 +02:00
27 changed files with 1587 additions and 24 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 0.1.0-alpha.4 — 2026-09-08
- Première hydrologie de surface : étangs, petits lacs et cours deau calmes
adaptés au relief, avec vérification du fond et des berges.
- Fonds de sable, gravier et argile, berges sableuses et canne à sucre
placée après la végétation lorsque ses conditions vanilla sont réunies.
- Hauteur, ciel et densité du terrain alpha.3 conservés ; nouveaux paramètres
dédiés pour préserver la génération des anciennes sauvegardes.
- Plan déterministe indépendant de lordre de génération des chunks, sans
digue ajoutée, remplissage de cavité souterraine ni mer globale.
Créer un **nouveau monde Sanctuary** pour tester lhydrologie. Le pack continue
à se mettre à jour dans la même instance Prism. Les grandes rivières en pente,
cascades, océans et eaux des futurs continents restent à développer.
Validation : 10/10 tests serveur réussis sur chacune des graines 0, 42 et
8675309, avec eau contenue après simulation réelle, continuité du cours deau
et canne à sucre valide. Tests de forme et de déterminisme également réussis.
Les cartes de blocs sont inspectées ; laspect dans le client reste à tester.
## 0.1.0-alpha.3 — 2026-09-08
- Nouveaux mondes Sanctuary sur 384 blocs de hauteur, avec l'île relevée de
+5 -3
View File
@@ -29,11 +29,13 @@ décrits dans la vision ne sont pas encore implémentés.
- Nouveaux mondes sur 384 blocs (`Y=0..383`), île relevée de 64 blocs,
dessous sculpté et nuages au-dessus du terrain. Les anciennes sauvegardes
conservent leurs paramètres de génération.
- Première hydrologie de surface : étangs, petits lacs et cours deau calmes,
avec des berges de sable et de la canne à sucre lorsque le terrain sy prête.
L'île a une emprise nominale de 512 blocs de diamètre, avec une bordure sculptée
par des bruits liés à la seed. Aucune masse centrale n'est ajoutée pour forcer
le terrain : le spawn cherche un emplacement naturel. Le catalogue
TerraMix, les continents déverrouillables, les lacs et rivières, les ruines et
TerraMix, les continents déverrouillables, les grandes rivières et cascades, les ruines et
les autres fonctionnalités restent à construire par tickets.
Le premier champ de recherche climatique est défini : nord froid, sud chaud,
@@ -58,7 +60,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes.
| Fabric API | 0.160.0+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | 0.1.0-alpha.3 |
| Sanctuary / pack | 0.1.0-alpha.4 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -85,7 +87,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.3.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.4.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
+4
View File
@@ -122,6 +122,10 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
- Les profils du dessous et les bords du continent sont inspectés visuellement depuis les airs.
- La génération est mesurée sur une emprise et une machine indiquées ; les valeurs observées sont consignées, sans annoncer un objectif de performance non mesuré.
**Incrément île initiale — alpha.4 :** première hydrologie de surface, documentée
dans [Génération](worldgen.md). Ce travail prépare les eaux des continents sans
clore WG-06 : océans, grandes rivières et cascades restent à développer.
### WG-07 — Introduire un premier biome distinct et une structure
**Dépendance :** WG-06.
+5 -3
View File
@@ -19,7 +19,7 @@ Après les vérifications, le commit et le push de la branche du ticket, le scri
de publication réalise les étapes de release et de canal ci-dessous :
```sh
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.3.mrpack
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.4.mrpack
```
Les options sont facultatives. Ce script **publie** sur le Git configuré dans
@@ -38,7 +38,7 @@ La procédure complète, également utilisable manuellement :
3. Préparer les métadonnées de la livraison, avec l'URL exacte du futur JAR :
```sh
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.3/sanctuary-0.1.0-alpha.3.jar
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.4/sanctuary-0.1.0-alpha.4.jar
```
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
@@ -115,7 +115,9 @@ n'autorisent pas à supprimer ou régénérer un monde.
La hauteur de 384 blocs, le dessous sculpté et le ciel de l'alpha.3 se testent
dans un **nouveau monde** avec le preset **Sanctuary**. Les anciens mondes gardent
leurs identifiants de génération et leurs hauteurs. Les détails des versions de génération sont dans
leurs identifiants de génération et leurs hauteurs. Lhydrologie de lalpha.4
nécessite elle aussi un nouveau monde Sanctuary ; la mise à jour de linstance
najoute pas deau aux sauvegardes existantes. Les détails des versions de génération sont dans
[Génération](worldgen.md). Les changements de version Minecraft ou de format
de sauvegarde demandent leur propre ticket et contrat de migration.
+97 -1
View File
@@ -27,6 +27,12 @@ sculpture du bord, limite extérieure, hauteur et stabilité de la fonction
géométrique. Elle complète les tests en jeu ; elle ne charge pas à elle
seule les ressources de génération de Minecraft.
La tâche `:sanctuary:hydrologySmoke`, également incluse dans `check`, vérifie
le planificateur sur des reliefs contrôlés : reproductibilité, variation de la
graine, limites de profondeur et dexcavation, support naturel, parois, ruisseau
sur plateau compatible et partition indépendante de lordre des chunks. Les
scénarios vides, percés ou trop minces ne reçoivent aucun bassin de secours.
## Tests dans le moteur Minecraft
`runGameTest` démarre le serveur de test headless officiel de Fabric dans
@@ -52,7 +58,7 @@ Le framework déplace aussi le spawn vers une grille de tests située loin du ce
Le test capture donc le spawn juste avant ce déplacement, après l'initialisation
normale du monde, et examine les coordonnées absolues de l'île.
Les sept tests Sanctuary vérifient :
Les neuf tests Sanctuary vérifient :
1. Le vrai générateur Sanctuary est chargé et le spawn collectif repose sur une
surface déjà présente dans la densité naturelle, avec un sol plein de 3×3
@@ -72,6 +78,12 @@ Les sept tests Sanctuary vérifient :
7. Le relevé après génération compte exactement tous les états des blocs des
chunks demandés, avec des totaux cohérents et une complétude explicite.
Voir [Expansion et ressources](expansion.md) pour le mode complet facultatif.
8. Le plan hydrologique se retrouve dans les vrais chunks après décoration :
eau retenue, fonds naturels sur trois blocs, parois continues, ouverture au
ciel, raccords entre chunks et canne à sucre pouvant survivre. Après des
mises à jour réelles de fluides, chaque bloc deau doit rester dans ce plan.
9. Les paramètres et le biome des anciennes sauvegardes nactivent ni le
traitement hydrologique ni la nouvelle décoration des berges.
La recherche de spawn parcourt l'île finie, par anneaux de quatre blocs jusqu'à
288 blocs du centre. Elle examine d'abord la hauteur brute pour éviter de
@@ -109,6 +121,40 @@ Référence du workflow : [tests automatiques Fabric](https://docs.fabricmc.net/
Les signatures propres à `26.3-pre-2` ont été vérifiées dans les dépendances locales,
car la documentation publiée vise actuellement `26.2`.
## Diagnostic hydrologique
Les essais hydrologiques demandent les chunks contenant tous les sites du plan
et un halo dun chunk. Ils inspectent des chunks `FULL`, avec minerais, arbres
et plantes déjà générés, et comparent tous les blocs deau observés au volume
annoncé. Leur carte montre les vrais blocs de surface ; le feuillage et les
troncs sont masqués pour lire les berges. Il ne sagit pas dune capture du jeu.
Les chunks de l’île sont maintenus actifs pendant le test. Chaque position
deau doit être dans un chunk où les blocs sont effectivement simulés. Un petit
témoin deau temporaire, enfermé en hauteur dans un de ces mêmes chunks, doit
réellement couler ; il est ensuite entièrement retiré. Les sources des sites
reçoivent elles aussi des ticks, puis la rétention est revérifiée au tick 200.
Attendre près de la grille éloignée du framework ne remplacerait pas ce contrôle.
Les fichiers `surface-hydrology-seed-<graine>-generated.json/.png` et
`surface-hydrology-seed-<graine>-after-200-ticks.json/.png` sont écrits dans
`mods/sanctuary/build/run/gameTest/diagnostics/`. Ils décrivent les sites et leur
nature, leurs coordonnées, le volume deau réel, la canne à sucre, les raccords
entre chunks, les berges et les bornes inspectées. Le nombre de blocs deau
nest pas une mesure de débit. Un résultat sans site `STREAM` ne valide pas
une rivière, même sil contient des lacs.
Pour itérer sur leau sans recalculer les trois grandes cartes de densité :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryDensityMaps=false -PsanctuaryResourceSurvey=off --console=plain
```
Ces options désactivent seulement les exports de densité et le relevé de
ressources. Elles gardent les assertions de densité et tous les tests de
comportement, y compris les cartes et contrôles hydrologiques. Le parcours
standard conserve les exports complets de densité pour la graine 0.
## Vérification client facultative
```sh
@@ -127,6 +173,56 @@ Pour cet essai visuel en monde de développement créatif, comparer la même vue
Y=63 ni assombrissement d'altitude au passage de Y=32. Les brouillards de l'eau,
de la météo et des effets d'aveuglement ou d'obscurité doivent rester présents.
## Validation alpha.4 — 8 septembre 2026
Les tests serveur utilisent le preset de production et des mondes neufs. Sur
la graine `0`, le scénario exige un véritable site `STREAM` ; les autres graines
vérifient la continuité de chaque cours deau quelles produisent, sans exiger
quune graine arbitraire soit artificiellement complétée.
| Graine | Spawn naturel | Eau après 200 ticks | Canne à sucre | Chunks FULL inspectés | Raccords deau aux frontières |
| --- | --- | ---: | ---: | ---: | ---: |
| `0` | `(0, 251, -4)` | 1 938 | 15 | 94 | 253 |
| `42` | `(-4, 249, -4)` | 1 501 | 24 | 94 | 213 |
| `8675309` | `(0, 247, 0)` | 1 551 | 12 | 90 | 190 |
Pour la graine `0`, le cours deau est à **Y=250**, de **X=48, Z=48** à
**X=20, Z=20**. Son chemin suit 18 points et relie deux petites poches deau.
Deux petits lacs et quatre étangs sont également présents, en comptant la poche
de départ du cours deau. La graine `42` produit elle aussi un cours deau, à
Y=221, de `(72, 24)` à `(60, 40)` en X/Z. Ce sont des observations de ces
graines, pas un minimum promis pour toutes les îles.
**10/10 tests requis réussissent sur chacune des trois graines**, avec un cours
deau observé sur chacune. Les contrôles comparent chaque bloc deau avant et après une simulation réelle
confirmée par un témoin d’écoulement et lactivité de tous les chunks concernés.
Les quantités deau restent identiques. Les cartes des vrais blocs ont été
inspectées : les lacs sont arrondis et les chenaux restent continus. Le tracé
des cours deau comporte encore des segments géométriques ; lappréciation du
relief, des berges et de leur aspect dans le client reste à faire par le joueur.
Les cartes masquent la canopée et ne remplacent pas une vue en jeu.
Le plan initial prend **12 060 ms** pour la graine `0` et **8 743 ms** pour `42`
et **9 755 ms** pour `8675309`, sur le poste Apple M1 à 8 Go utilisé pendant ces
essais. Il est calculé une seule
fois par état aléatoire de monde, sans charger les chunks voisins. La préparation
des 94 chunks FULL de chaque diagnostic est distincte du calcul du plan et
ne représente pas le travail requis à chaque lancement de partie.
Les trois exécutions serveur réussies ont été suivies de
`./gradlew check build assemblePack -x :sanctuary:runGameTest` : le serveur déjà
validé séparément nest pas relancé à lidentique. Les tests de forme et
dhydrologie pure, la compilation et lassemblage du pack réussissent en 2 s.
Le JAR ne contient aucune classe de test, et le `.mrpack` contient exactement
ce même JAR, avec les versions Minecraft et Fabric attendues.
Journaux : `build/alpha4-hydrology-v2-seed0.log` et
`build/alpha4-validation-seed42.log`, `build/alpha4-validation-seed8675309.log`
et `build/alpha4-build.log`. Les exports correspondants sont dans
le dossier `diagnostics` décrit plus haut. Les cartes de densité, inchangées
par lhydrologie, ont été exportées lors des premières exécutions de ce ticket ;
les assertions de densité restent actives pendant chaque validation.
## Validation alpha.3 — 8 septembre 2026
Sur Java 25 / Minecraft 26.3-pre-2 / Fabric Loader 0.19.5 :
+51 -6
View File
@@ -34,7 +34,8 @@ de test et les sauvegardes personnelles doivent rester distincts.
- Le terrain naturel reste libre de laisser un trou à l'origine, de former des
surplombs ou de se séparer en fragments. **Aucun noyau ellipsoïdal, cylindre ou
plateforme de départ n'est ajouté.** Au-dessus de `Y=144`, le relief de la
génération précédente est conservé, simplement relevé de 64 blocs.
génération précédente est conservé dans la densité, simplement relevé de 64
blocs. Lhydrologie v4 creuse ensuite localement les lits de surface.
- Le pourtour est sculpté par retrait de matière à partir du rayon **128 blocs**.
Le contour de référence de 256 blocs est déformé par les bruits, entre 204 et
288 blocs ; il indique la fin de l'érosion, pas un mur de roche.
@@ -46,7 +47,7 @@ de test et les sauvegardes personnelles doivent rester distincts.
Il n'y a ni plancher de bedrock,
ni mer globale, ni reprise du terrain à distance. Le Nether et l'End restent
ceux de Minecraft.
- `sanctuary:starter_forest` reprend végétation, minerais et animaux de la forêt
- `sanctuary:starter_forest_hydrology` reprend végétation, minerais et animaux de la forêt
vanilla 26.3. Ses blocs sont exclusivement vanilla. C'est un biome distinct
pour ne pas hériter des tags de structures vanilla et de leurs apparitions
possibles dans le vide.
@@ -54,9 +55,9 @@ de test et les sauvegardes personnelles doivent rester distincts.
forêt initiale. Les minerais remplacent la roche existante et les arbres ont
besoin de sol. Aucun mod de biomes externe n'est nécessaire.
Ce premier prototype ne fournit pas encore d'eau : hydrologie, répartition
complète des ressources de survie et variété de biomes constituent le prochain
ticket de terrain. Les minerais vanilla conservent leurs plages d'altitude ;
Lhydrologie v4 fournit les premières eaux de surface décrites ci-dessous. La
répartition complète des ressources de survie et la variété de biomes restent
à développer. Les minerais vanilla conservent leurs plages daltitude ;
leur abondance n'est donc pas encore équilibrée pour une île flottante.
## Sculpture du pourtour — conservée depuis la génération v2
@@ -134,6 +135,49 @@ ou régénéré. Il n'existe pas de migration automatique des anciens mondes ver
dimension de 384 blocs. Changer manuellement leurs références de générateur ou
de dimension sort de ce contrat de compatibilité.
## Hydrologie de surface — génération v4
La version **0.1.0-alpha.4** conserve la densité, la hauteur et le ciel de
lalpha.3. Les nouveaux mondes sélectionnent les paramètres
`sanctuary:sanctuary_hydrology` et le biome `sanctuary:starter_forest_hydrology`.
Le type de dimension reste `sanctuary:sanctuary_384`. Les anciens paramètres et
le biome `sanctuary:starter_forest` restent disponibles et inchangés : ils
nactivent pas cette hydrologie dans les sauvegardes antérieures.
Un plan calculé depuis le relief et la graine recherche des emplacements de
surface pour des étangs, des petits lacs et un premier cours deau calme.
Le lit peut être légèrement creusé dans la roche existante. Avant daccepter
un emplacement, le plan vérifie le fond et les parois en trois dimensions ainsi
que louverture vers le ciel. Un trou profond dans la carte de hauteur ne suffit
pas à constituer un lac. Aucune digue ni masse de roche nest ajoutée pour
boucher une fuite, et aucun bassin souterrain nest rempli.
Les fonds alternent sable, gravier et argile ; les berges basses peuvent recevoir
du sable. Dans ce nouveau biome, les disques de sédiments vanilla et les veines
de gravier sont remplacés par ces dépôts de rive et de fond : une feature
ultérieure ne doit pas transformer leur support en gravier instable. Les
sources de magma sous-marines sont également absentes. Après les arbres et la
végétation, de la canne à sucre peut pousser sur
les rives qui respectent ses règles de placement vanilla. Le nombre de sites,
leur eau et leurs ressources dépendent du terrain : le générateur ne complète
pas une graine pour atteindre un quota économique.
Le plan est indépendant des chunks chargés. Chaque chunk applique uniquement
ses propres colonnes après le terrain et avant la décoration, ce qui permet de
raccorder un même plan deau aux frontières des chunks. Les anciens chunks ne
sont jamais retouchés, et aucun travail hydrologique ne se déclenche au simple
chargement dune sauvegarde.
Cette première version vise de petites eaux retenues et des cours deau calmes
sur les plateaux. Le cours deau est un chenal à niveau constant reliant des
plans deau ; il ne simule pas encore une pente vers laval ou un débit. Les
tracés peuvent passer par les diagonales, mais leur eau reste connectée de
bloc en bloc. Lexcavation est limitée à six blocs, leau à quatre blocs de
profondeur, avec un fond plein sur trois blocs d’épaisseur, dont deux blocs naturels sous
le sédiment. Les grandes rivières en pente, cascades, océans, aquifères,
climats multiples et continents restent des incréments distincts.
**Créer un nouveau monde Sanctuary pour tester cette hydrologie.**
## Apparition commune
Une injection limitée à la création initiale cherche du sol naturel depuis
@@ -142,7 +186,8 @@ une surface stable de 3 × 3 blocs, avec deux blocs libres en hauteur. En
l'absence d'une telle surface, elle retient une colonne de sol praticable ; si
aucun emplacement naturel sûr n'existe, la création signale la seed concernée
plutôt que d'ajouter une plateforme. Elle ne s'applique qu'à l'Overworld
utilisant les paramètres `sanctuary:sanctuary`.
utilisant les paramètres `sanctuary:sanctuary`, `sanctuary:sanctuary_384` ou
`sanctuary:sanctuary_hydrology`.
Elle enregistre le spawn partagé dans la sauvegarde et ne le réinitialise pas
au redémarrage : les modifications administratives de `/setworldspawn` restent
conservées. Les lits et la dispersion habituelle du spawn suivent encore les
+2 -2
View File
@@ -8,6 +8,6 @@ loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.3
mod_version=0.1.0-alpha.3
pack_version=0.1.0-alpha.3
mod_version=0.1.0-alpha.4
pack_version=0.1.0-alpha.4
maven_group=fr.koka.sanctuary
+11
View File
@@ -34,6 +34,7 @@ tasks.named('runGameTest') {
dependsOn('cleanGameTestWorld')
systemProperty('sanctuary.test.seed', providers.gradleProperty('sanctuaryTestSeed').getOrElse('0'))
systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample'))
systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true'))
}
dependencies {
@@ -76,5 +77,15 @@ tasks.register('worldgenSmoke', JavaExec) {
tasks.named('check') { dependsOn('worldgenSmoke') }
tasks.register('hydrologySmoke', JavaExec) {
group = 'verification'
description = 'Check deterministic surface drainage, natural support and bounded excavation.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.SurfaceHydrologySmoke'
}
tasks.named('check') { dependsOn('hydrologySmoke') }
// src/test contains a JavaExec assertion harness; engine tests use Fabric's gametest source set.
tasks.named('test') { failOnNoDiscoveredTests = false }
@@ -44,7 +44,7 @@ final class DensityDiagnostics {
var noises = registries.lookupOrThrow(Registries.NOISE);
// The default run exports all three seeds once. Additional complete-world runs only
// repeat the sparse assertions, avoiding redundant image generation.
boolean export = level.getSeed() == 0L;
boolean export = level.getSeed() == 0L && Boolean.parseBoolean(System.getProperty("sanctuary.test.densityMaps", "true"));
boolean foundUnforcedCentralVoid = false;
for (long seed : new long[]{0L, 42L, 8675309L}) {
RandomState state = RandomState.create(noises, seed, settings);
@@ -0,0 +1,448 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
import fr.koka.sanctuary.worldgen.SurfaceHydrology;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.FluidTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.material.Fluids;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/** Checks the actual decorated terrain and fluid simulation in a disposable world, far from test fixtures. */
public final class HydrologyDiagnostics {
private static final List<Direction> HORIZONTAL = List.of(Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST);
private HydrologyDiagnostics() {}
public static void start(GameTestHelper helper) throws IOException {
Context context = new Context(helper);
context.prepare();
context.verifyNaturalShellAndFinishedWater();
context.verifyAllObservedWaterIsPlanned();
context.export("generated");
helper.assertTrue(context.caneBlocks > 0,
"Regression seed must provide at least one naturally valid shore sugar cane plant; inspect the exported site map");
// Other synchronous generation diagnostics finish before this tick. The temporary fluid
// witness therefore cannot be counted by the initial resource survey.
helper.runAtTickTime(20, context::startFluidWitness);
helper.runAtTickTime(50, context::verifyFluidWitnessAndRemoveIt);
helper.runAtTickTime(200, () -> {
try {
context.verifyNaturalShellAndFinishedWater();
context.verifyAllObservedWaterIsPlanned();
context.export("after-200-ticks");
SanctuaryMod.LOGGER.info("Hydrology seed {}: {} water blocks retained after real fluid ticks, {} chunk seams checked",
context.level.getSeed(), context.expectedWater.size(), context.seams);
helper.succeed();
} catch (IOException exception) {
throw new IllegalStateException("Could not write hydrology diagnostics", exception);
} finally {
context.cleanup();
}
});
}
public static void verifyLegacyIsolation(GameTestHelper helper) {
ServerLevel level = helper.getLevel();
var active = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(HydrologyRuntime.enabled(active), "The current preset must enable surface hydrology");
helper.assertTrue(active.generatorSettings().is(SanctuarySpawn.HYDROLOGY_SETTINGS),
"The active generator must use the new hydrology settings, not alter a legacy settings key");
var registry = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
for (var key : List.of(SanctuarySpawn.SETTINGS, SanctuarySpawn.RAISED_SETTINGS)) {
var legacy = new NoiseBasedChunkGenerator(active.getBiomeSource(), registry.getOrThrow(key));
helper.assertFalse(HydrologyRuntime.enabled(legacy), "Saved legacy generator must remain dry: " + key.identifier());
helper.assertFalse(new ShoreSugarCaneFeature().place(level, legacy, RandomSource.create(0), BlockPos.ZERO),
"Even an explicitly invoked shoreline feature must ignore legacy generators");
}
var biomes = level.registryAccess().lookupOrThrow(Registries.BIOME);
var oldBiome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest"))).value();
var wetBiome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest_hydrology"))).value();
var shore = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE).getOrThrow(
ResourceKey.create(Registries.PLACED_FEATURE, SanctuaryMod.id("shore_sugar_cane"))).value();
helper.assertFalse(oldBiome.getGenerationSettings().hasFeature(shore),
"The old biome referenced by saved worlds must not acquire the new shoreline decoration");
helper.assertTrue(wetBiome.getGenerationSettings().hasFeature(shore),
"The new hydrology biome must include its shoreline decoration");
helper.assertTrue(active.getBiomeSource().possibleBiomes().stream().allMatch(holder -> holder.is(
ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest_hydrology")))),
"The current preset must select the separate hydrology biome");
}
private static final class Context {
final GameTestHelper helper;
final ServerLevel level;
final SurfaceHydrology.Plan plan;
final RandomState random;
final DensityFunction natural;
final Map<Long, SurfaceHydrology.Cell> cells = new HashMap<>();
final Set<BlockPos> expectedWater = new HashSet<>();
final Map<Long, LevelChunk> chunks = new TreeMap<>();
final Set<Long> newlyForced = new HashSet<>();
BlockPos witness;
boolean witnessPlaced;
boolean allWaterChunksTicking;
int seams;
int caneBlocks;
Context(GameTestHelper helper) {
this.helper = helper;
level = helper.getLevel();
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(HydrologyRuntime.enabled(generator), "Hydrology test requires the production wet preset");
plan = HydrologyRuntime.plan(level);
random = level.getChunkSource().randomState();
natural = generator.generatorSettings().value().noiseRouter().finalDensity();
}
void prepare() {
helper.assertTrue(!plan.features().isEmpty(), "Regression seed must exercise real surface water");
for (var feature : plan.features()) {
SanctuaryMod.LOGGER.info("Hydrology planned site: seed {}, {}, Y{}, start {}, {} water columns, {} path points",
level.getSeed(), feature.kind(), feature.waterY(), feature.path().getFirst(), feature.waterCells(), feature.path().size());
}
if (level.getSeed() == 0L) {
helper.assertTrue(plan.features().stream().anyMatch(feature -> feature.kind() == SurfaceHydrology.Kind.STREAM),
"The reference seed 0 must demonstrate a real stream, not only isolated ponds; see the logged planned sites");
}
Set<Long> requested = new HashSet<>();
for (var cell : plan.cells()) {
helper.assertTrue(cells.put(ChunkPos.pack(cell.x(), cell.z()), cell) == null,
"A plan must own each horizontal column exactly once");
if (cell.hasWater()) {
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
expectedWater.add(new BlockPos(cell.x(), y, cell.z()));
}
}
int cx = cell.x() >> 4;
int cz = cell.z() >> 4;
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) requested.add(ChunkPos.pack(cx + dx, cz + dz));
}
}
helper.assertTrue(!expectedWater.isEmpty(), "At least one generated water volume must be tested");
SanctuaryMod.LOGGER.info("Hydrology seed {}: preparing {} site and neighbour chunks for actual water inspection",
level.getSeed(), requested.size());
for (long packed : requested.stream().sorted().toList()) {
ChunkPos pos = ChunkPos.unpack(packed);
if (level.setChunkForced(pos.x(), pos.z(), true)) newlyForced.add(packed);
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Hydrology inspection requires a decorated FULL chunk");
chunks.put(packed, (LevelChunk) chunk);
}
// Use one contained witness in the SAME simulation chunk as actual water. It proves
// that the island's fluids tick, rather than merely waiting beside the remote test grid.
BlockPos first = expectedWater.stream().min(Comparator.comparingInt((BlockPos pos) -> pos.getX())
.thenComparingInt(BlockPos::getZ).thenComparingInt(BlockPos::getY)).orElseThrow();
witness = new BlockPos((first.getX() >> 4) * 16 + 8, level.getMaxY() - 20,
(first.getZ() >> 4) * 16 + 8);
for (BlockPos position : expectedWater) {
for (Direction direction : List.of(Direction.EAST, Direction.SOUTH)) {
BlockPos next = position.relative(direction);
if (expectedWater.contains(next)
&& (position.getX() >> 4 != next.getX() >> 4 || position.getZ() >> 4 != next.getZ() >> 4)) seams++;
}
}
helper.assertTrue(seams > 0, "Regression seed must exercise a continuous water volume across a chunk seam");
SanctuaryMod.LOGGER.info("Hydrology seed {}: {} features, {} water blocks, {} FULL chunks including halo, {} seam adjacencies",
level.getSeed(), plan.features().size(), expectedWater.size(), chunks.size(), seams);
}
void verifyNaturalShellAndFinishedWater() {
for (var cell : plan.cells()) {
if (!cell.hasWater()) continue;
helper.assertTrue(cell.waterY() > cell.bedY() && cell.waterY() - cell.bedY() <= SurfaceHydrology.MAX_DEPTH,
"A surface basin must keep the declared shallow water depth");
helper.assertTrue(cell.carveTop() - cell.bedY() <= SurfaceHydrology.MAX_CARVE,
"Hydrology must not excavate a deep artificial chamber beneath the surface");
for (int depth = 0; depth < 3; depth++) {
int y = cell.bedY() - depth;
helper.assertTrue(sample(cell.x(), y, cell.z()) > 0,
"Water must retain three blocks of natural floor, without adding a liner at " + cell.x() + "," + y + "," + cell.z());
BlockPos floor = new BlockPos(cell.x(), y, cell.z());
helper.assertTrue(level.getBlockState(floor).isCollisionShapeFullBlock(level, floor),
"Generated water floor must remain solid after decoration and fluid ticks: " + floor);
}
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
BlockPos water = new BlockPos(cell.x(), y, cell.z());
helper.assertTrue(level.getFluidState(water).is(FluidTags.WATER) && level.getFluidState(water).isSource(),
"The planned water volume must exist as retained source water: " + water);
for (Direction direction : HORIZONTAL) {
BlockPos side = water.relative(direction);
if (expectedWater.contains(side)) continue;
helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) > 0,
"Every external underwater side must be supported by natural terrain: " + side);
helper.assertTrue(level.getBlockState(side).isCollisionShapeFullBlock(level, side),
"A decorated shore must not leave a hole in the three-dimensional water shell: " + side);
}
}
// The original density must have no roof above the explicitly carved surface.
// Leaves and branches from neighbouring trees may shade the water afterwards.
for (int y = cell.carveTop() + 1; y <= level.getMaxY(); y++) {
helper.assertTrue(sample(cell.x(), y, cell.z()) <= 0,
"A surface basin must not be a flooded underground chamber: " + cell.x() + "," + y + "," + cell.z());
}
}
for (var feature : plan.features()) {
if (feature.kind() != SurfaceHydrology.Kind.STREAM) continue;
helper.assertTrue(feature.path().size() > 1, "A stream needs a route connecting distinct surface locations");
double travelled = 0;
for (int i = 1; i < feature.path().size(); i++) {
var from = feature.path().get(i - 1);
var to = feature.path().get(i);
int length = Math.max(Math.abs(to.x() - from.x()), Math.abs(to.z() - from.z()));
helper.assertTrue(length > 0, "A stream route must advance between consecutive points");
helper.assertTrue(from.x() == to.x() || from.z() == to.z()
|| Math.abs(to.x() - from.x()) == Math.abs(to.z() - from.z()),
"Stream control points must be joined by cardinal or diagonal steps");
travelled += Math.hypot(to.x() - from.x(), to.z() - from.z());
BlockPos previous = null;
for (int step = 0; step <= length; step++) {
BlockPos p = new BlockPos(from.x() + (to.x() - from.x()) * step / length,
feature.waterY(), from.z() + (to.z() - from.z()) * step / length);
helper.assertTrue(expectedWater.contains(p) && level.getFluidState(p).is(FluidTags.WATER),
"The real stream must form an uninterrupted water route across chunk boundaries: " + p);
var column = plan.cellAt(p.getX(), p.getZ());
helper.assertTrue(column != null && column.waterY() == feature.waterY(),
"This stream contract has one constant water level along its whole route");
if (previous != null && p.getX() != previous.getX() && p.getZ() != previous.getZ()) {
BlockPos bridgeX = new BlockPos(p.getX(), p.getY(), previous.getZ());
BlockPos bridgeZ = new BlockPos(previous.getX(), p.getY(), p.getZ());
helper.assertTrue(expectedWater.contains(bridgeX) && level.getFluidState(bridgeX).is(FluidTags.WATER)
|| expectedWater.contains(bridgeZ) && level.getFluidState(bridgeZ).is(FluidTags.WATER),
"Diagonal route steps need actual cardinal water connectivity, not only touching corners");
}
previous = p;
}
}
var first = feature.path().getFirst();
var last = feature.path().getLast();
helper.assertTrue(travelled >= 24 && Math.hypot(last.x() - first.x(), last.z() - first.z()) >= 16,
"A demonstrated stream must have at least 24 blocks of route between locations at least 16 blocks apart");
}
}
void verifyAllObservedWaterIsPlanned() {
int[] observed = {0};
int[] cane = {0};
for (LevelChunk chunk : chunks.values()) {
chunk.findBlocks(state -> state.getFluidState().is(FluidTags.WATER) || state.is(Blocks.SUGAR_CANE), (pos, state) -> {
if (state.getFluidState().is(FluidTags.WATER)) {
helper.assertTrue(expectedWater.contains(pos),
"Water escaped its declared surface volume, possibly through a chunk seam or toward the void: " + pos);
observed[0]++;
}
if (state.is(Blocks.SUGAR_CANE)) {
helper.assertTrue(state.canSurvive(level, pos), "Generated shore sugar cane must survive at " + pos);
cane[0]++;
}
});
}
helper.assertTrue(observed[0] == expectedWater.size(), "Observed water must account for every planned water block");
caneBlocks = cane[0];
}
void startFluidWitness() {
helper.assertTrue(level.shouldTickBlocksAt(witness), "The witness and its water-site chunk must actually tick");
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
for (int dy = -4; dy <= 0; dy++) {
BlockPos p = witness.offset(dx, dy, dz);
helper.assertTrue(level.getBlockState(p).isAir(), "The temporary simulation witness must use empty test space");
}
}
}
witnessPlaced = true;
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
for (int dy = -4; dy <= 0; dy++) {
if (dx != 0 || dz != 0 || dy == -4) level.setBlock(witness.offset(dx, dy, dz), Blocks.STONE.defaultBlockState(), 3);
}
}
}
level.setBlock(witness, Blocks.WATER.defaultBlockState(), 3);
helper.assertTrue(level.getFluidState(witness.below()).isEmpty(), "Witness starts with an empty cell below its source");
level.scheduleTick(witness, Fluids.WATER, 1);
for (BlockPos water : expectedWater) {
helper.assertTrue(level.shouldTickBlocksAt(water), "Every actual water-site chunk must be simulated: " + water);
level.scheduleTick(water, Fluids.WATER, 1);
}
allWaterChunksTicking = true;
}
void verifyFluidWitnessAndRemoveIt() {
try {
helper.assertTrue(level.getFluidState(witness.below()).is(FluidTags.WATER),
"Real fluid simulation must move the witness water down inside the forced island chunk");
SanctuaryMod.LOGGER.info("Hydrology seed {}: fluid tick witness passed at {}", level.getSeed(), witness);
} finally {
removeWitness();
}
}
void removeWitness() {
if (!witnessPlaced) return;
// Empty the fluid column before opening its walls, keeping the test fixture contained.
for (int dy = 0; dy >= -3; dy--) level.setBlock(witness.offset(0, dy, 0), Blocks.AIR.defaultBlockState(), 3);
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
for (int dy = -4; dy <= 0; dy++) level.setBlock(witness.offset(dx, dy, dz), Blocks.AIR.defaultBlockState(), 3);
}
}
witnessPlaced = false;
}
void cleanup() {
removeWitness();
for (long packed : newlyForced) {
ChunkPos chunk = ChunkPos.unpack(packed);
level.setChunkForced(chunk.x(), chunk.z(), false);
}
}
float sample(int x, int y, int z) {
return random.sampleBlockValueUncached(natural, x, y, z);
}
void export(String phase) throws IOException {
Path directory = Path.of("diagnostics");
Files.createDirectories(directory);
String stem = "surface-hydrology-seed-" + level.getSeed() + "-" + phase;
Map<String, Object> report = new LinkedHashMap<>();
report.put("seed", level.getSeed());
report.put("phase", phase);
report.put("actual_water_blocks", expectedWater.size());
report.put("actual_cane_blocks", caneBlocks);
report.put("checked_chunk_seam_adjacencies", seams);
report.put("all_water_site_chunks_confirmed_ticking", allWaterChunksTicking);
report.put("inspected_full_chunks", chunks.values().stream().map(chunk ->
Map.of("x", chunk.getPos().x(), "z", chunk.getPos().z())).toList());
report.put("features", plan.features());
report.put("shore_diagnostics_by_feature", shoreDiagnostics());
report.put("planned_columns", plan.cells());
report.put("scope", "actual blocks in completed site chunks and a one-chunk halo; uninspected map cells remain blank");
Files.writeString(directory.resolve(stem + ".json"), new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
exportMap(directory.resolve(stem + ".png"), phase);
}
Map<Long, Map<String, Object>> shoreDiagnostics() {
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
for (var feature : plan.features()) {
int dryBanks = 0;
int banksAtWaterLevel = 0;
int baseCanSurvive = 0;
int unobstructedBases = 0;
int viableUnobstructedBases = 0;
int actualCane = 0;
for (var cell : plan.cells()) {
if (cell.featureId() != feature.id() || cell.hasWater()) continue;
dryBanks++;
if (cell.bedY() == feature.waterY()) banksAtWaterLevel++;
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
boolean viable = Blocks.SUGAR_CANE.defaultBlockState().canSurvive(level, base);
boolean clear = level.getBlockState(base).isAir() || level.getBlockState(base).is(Blocks.SUGAR_CANE);
if (viable) baseCanSurvive++;
if (clear) unobstructedBases++;
if (viable && clear) viableUnobstructedBases++;
for (int height = 0; height < 4; height++) {
if (level.getBlockState(base.above(height)).is(Blocks.SUGAR_CANE)) actualCane++;
else break;
}
}
result.put(feature.id(), Map.of("kind", feature.kind().name(), "water_y", feature.waterY(),
"dry_bank_columns", dryBanks, "bank_surface_at_water_level", banksAtWaterLevel,
"bases_where_cane_can_survive", baseCanSurvive, "unobstructed_bases", unobstructedBases,
"viable_unobstructed_bases", viableUnobstructedBases,
"actual_cane_blocks_on_planned_banks", actualCane));
}
return result;
}
void exportMap(Path output, String phase) throws IOException {
int minX = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMinBlockX()).min().orElseThrow();
int maxX = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMaxBlockX()).max().orElseThrow();
int minZ = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMinBlockZ()).min().orElseThrow();
int maxZ = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMaxBlockZ()).max().orElseThrow();
int scale = Math.max(1, Math.min(3, 1000 / Math.max(maxX - minX + 1, maxZ - minZ + 1)));
int width = (maxX - minX + 1) * scale;
int height = (maxZ - minZ + 1) * scale;
BufferedImage image = new BufferedImage(Math.max(width + 40, 920), height + 110, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
graphics.setColor(new Color(0xF0F0EC));
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
graphics.setColor(new Color(0x17222C));
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 17));
graphics.drawString("Actual FULL chunks | seed " + level.getSeed() + " | " + phase, 20, 24);
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
graphics.drawString("Water blue; sand gold; clay teal; gravel brown; cane bright green; terrain height grey. Canopy skipped.", 20, 45);
graphics.drawString("Not a Minecraft render. X=" + minX + ".." + maxX + ", Z=" + minZ + ".." + maxZ
+ ". Dark blue = void; dark brown = not inspected; faint lines = chunk seams.", 20, 63);
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (int x = minX; x <= maxX; x++) {
for (int z = minZ; z <= maxZ; z++) {
LevelChunk chunk = chunks.get(ChunkPos.pack(x >> 4, z >> 4));
int color = 0x3A3030;
if (chunk != null) {
color = 0x142738;
for (int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE, x, z); y >= level.getMinY(); y--) {
position.set(x, y, z);
BlockState state = chunk.getBlockState(position);
if (state.getFluidState().is(FluidTags.WATER)) { color = 0x207BD4; break; }
if (state.is(Blocks.SUGAR_CANE)) { color = 0x7AFF38; break; }
if (state.is(Blocks.SAND)) { color = 0xD5B668; break; }
if (state.is(Blocks.CLAY)) { color = 0x6D9FAD; break; }
if (state.is(Blocks.GRAVEL)) { color = 0x9B8878; break; }
if (state.isAir() || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|| state.getCollisionShape(level, position).isEmpty()) continue;
int value = 40 + Math.clamp(y, 0, level.getHeight()) * 180 / level.getHeight();
color = value << 16 | value << 8 | value;
break;
}
}
graphics.setColor(new Color(color));
graphics.fillRect(20 + (x - minX) * scale, 80 + (z - minZ) * scale, scale, scale);
}
}
graphics.setColor(new Color(255, 255, 255, 35));
for (int x = minX; x <= maxX; x += 16) graphics.drawLine(20 + (x - minX) * scale, 80, 20 + (x - minX) * scale, 80 + height);
for (int z = minZ; z <= maxZ; z += 16) graphics.drawLine(20, 80 + (z - minZ) * scale, 20 + width, 80 + (z - minZ) * scale);
graphics.dispose();
ImageIO.write(image, "png", output.toFile());
}
}
}
@@ -23,6 +23,17 @@ import net.minecraft.world.level.storage.LevelData;
public final class SanctuaryWorldGameTests {
public static LevelData.RespawnData initialSpawn;
@GameTest(maxTicks = 400)
public void generatedSurfaceWaterRemainsContained(GameTestHelper helper) throws java.io.IOException {
HydrologyDiagnostics.start(helper);
}
@GameTest(maxTicks = 200)
public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) {
HydrologyDiagnostics.verifyLegacyIsolation(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void generatedIslandAndInitialSpawn(GameTestHelper helper) {
ServerLevel level = helper.getLevel();
@@ -1,6 +1,7 @@
package fr.koka.sanctuary;
import fr.koka.sanctuary.worldgen.MainIslandDensity;
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
import net.fabricmc.api.ModInitializer;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -19,6 +20,7 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("shore_sugar_cane"), ShoreSugarCaneFeature.CODEC);
LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available.");
}
}
@@ -0,0 +1,35 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.minecraft.core.Holder;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** The terrain future completes before decoration starts; never reads or writes another chunk. */
@Mixin(NoiseBasedChunkGenerator.class)
public abstract class SurfaceHydrologyMixin {
@Inject(method = "buildTerrain", at = @At("RETURN"), cancellable = true)
private void sanctuary$surfaceWater(ChunkAccess chunk, Blender blender, RandomState randomState,
StructureManager structures, BiomeManager biomeManager, WorldGenRegion region,
Set<Holder<Biome>> biomes, CallbackInfoReturnable<CompletableFuture<ChunkAccess>> callback) {
var generator = (NoiseBasedChunkGenerator) (Object) this;
if (HydrologyRuntime.enabled(generator)) {
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
HydrologyRuntime.apply(generator, randomState, generated);
return generated;
}));
}
}
}
@@ -0,0 +1,87 @@
package fr.koka.sanctuary.worldgen;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
/** One immutable plan per world random state. The value retains neither that state nor its sampler. */
public final class HydrologyRuntime {
private static final Map<RandomState, SurfaceHydrology.Plan> PLANS =
Collections.synchronizedMap(new WeakHashMap<>());
private HydrologyRuntime() {}
public static boolean enabled(NoiseBasedChunkGenerator generator) {
return generator.generatorSettings().is(SanctuarySpawn.HYDROLOGY_SETTINGS);
}
public static SurfaceHydrology.Plan plan(ServerLevel level) {
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
level.getChunkSource().randomState());
}
public static SurfaceHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
if (!enabled(generator)) throw new IllegalArgumentException("Hydrology is not enabled for this generator");
return PLANS.computeIfAbsent(randomState, state -> {
long start = System.nanoTime();
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
var plan = SurfaceHydrology.create(state.seed(),
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
SanctuaryMod.LOGGER.info("Sanctuary surface hydrology: seed {}, {} features, {} columns, {} ms",
state.seed(), plan.features().size(), plan.cells().size(), (System.nanoTime() - start) / 1_000_000);
return plan;
});
}
/** Executed during fresh terrain generation only, before ores, trees and shoreline plants. */
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
if (!enabled(generator)) return;
var chunkPos = chunk.getPos();
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
var cells = plan(generator, randomState).cellsInChunk(chunkPos.x(), chunkPos.z());
if (cells.isEmpty()) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
// Validate this chunk's entire foundation before the first write. The planner may only
// replace existing terrain; a mismatch must fail rather than leave a partially applied bed.
for (var cell : cells) {
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
pos.set(cell.x(), y, cell.z());
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
throw new IllegalStateException("Hydrology bed differs from natural terrain at " + pos
+ " for seed " + randomState.seed());
}
}
}
for (var cell : cells) {
pos.set(cell.x(), cell.bedY(), cell.z());
BlockState material = switch (cell.material()) {
case SAND -> Blocks.SAND.defaultBlockState();
case GRAVEL -> Blocks.GRAVEL.defaultBlockState();
case CLAY -> Blocks.CLAY.defaultBlockState();
};
chunk.setBlockState(pos, material, 0);
if (!cell.hasWater()) continue;
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
pos.set(cell.x(), y, cell.z());
chunk.setBlockState(pos, y <= cell.waterY()
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
if (y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
}
}
// buildTerrain's incoming proto status may not yet maintain the WG heightmaps. Decoration
// must see the new bed and waterline so trees are placed on dry shores, not below the lake.
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
Heightmap.Types.OCEAN_FLOOR_WG));
}
}
@@ -19,13 +19,16 @@ public final class SanctuarySpawn {
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary"));
public static final ResourceKey<NoiseGeneratorSettings> RAISED_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_384"));
public static final ResourceKey<NoiseGeneratorSettings> HYDROLOGY_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_hydrology"));
private SanctuarySpawn() {}
public static boolean usesSanctuaryGenerator(ServerLevel level) {
return level.dimension() == Level.OVERWORLD
&& level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator generator
&& (generator.generatorSettings().is(SETTINGS) || generator.generatorSettings().is(RAISED_SETTINGS));
&& (generator.generatorSettings().is(SETTINGS) || generator.generatorSettings().is(RAISED_SETTINGS)
|| generator.generatorSettings().is(HYDROLOGY_SETTINGS));
}
/** Called only during first creation; saved spawns and /setworldspawn survive subsequent loads. */
@@ -0,0 +1,41 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import net.minecraft.core.BlockPos;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.feature.Feature;
/** A few plants on existing suitable shores, after the forest has been decorated. */
public final class ShoreSugarCaneFeature implements Feature {
public static final MapCodec<ShoreSugarCaneFeature> CODEC = MapCodec.unit(ShoreSugarCaneFeature::new);
@Override
public MapCodec<ShoreSugarCaneFeature> codec() {
return CODEC;
}
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !HydrologyRuntime.enabled(noise)) return false;
var plan = HydrologyRuntime.plan(noise, level.getLevel().getChunkSource().randomState());
var cane = Blocks.SUGAR_CANE.defaultBlockState();
boolean placed = false;
for (var cell : plan.cellsInChunk(origin.getX() >> 4, origin.getZ() >> 4)) {
if (cell.hasWater() || random.nextInt(7) != 0) continue;
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
if (!level.getBlockState(base).isAir() || !cane.canSurvive(level, base)) continue;
int height = 1 + random.nextInt(3);
for (int y = 0; y < height; y++) {
BlockPos pos = base.above(y);
if (!level.getBlockState(pos).isAir() || !cane.canSurvive(level, pos)) break;
setBlock(level, pos, cane);
placed = true;
}
}
return placed;
}
}
@@ -0,0 +1,465 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
/** A seed-only surface-water plan. No chunks, random generation order or Minecraft objects are retained. */
public final class SurfaceHydrology {
public static final int RADIUS = 192;
public static final int MAX_CARVE = 6;
public static final int MAX_DEPTH = 4;
private static final int GRID = 8;
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
private SurfaceHydrology() {}
@FunctionalInterface
public interface Sampler { float sample(int x, int y, int z); }
public enum Kind { POND, LAKE, STREAM }
public enum ShoreMaterial { SAND, GRAVEL, CLAY }
public record Point(int x, int z) {}
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
/** bedY is an existing solid block. carveTop includes waterY when a natural hollow is filled. */
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
ShoreMaterial material, long featureId) {
public boolean hasWater() { return waterY >= 0; }
}
public record Feature(long id, Kind kind, int waterY, List<Point> path, int waterCells, Bounds bounds) {
public Feature { path = List.copyOf(path); }
}
public static final class Plan {
private final Map<Long, Cell> cells;
private final Map<Long, List<Cell>> chunks;
private final List<Feature> features;
private final int sampledColumns;
private final long densitySamples;
private Plan(Map<Long, Cell> source, List<Feature> features, int columns, long samples) {
List<Cell> sorted = source.values().stream()
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
Map<Long, Cell> ordered = new LinkedHashMap<>();
Map<Long, List<Cell>> byChunk = new HashMap<>();
for (Cell cell : sorted) {
ordered.put(key(cell.x(), cell.z()), cell);
byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell);
}
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
cells = Collections.unmodifiableMap(ordered);
chunks = Map.copyOf(byChunk);
this.features = List.copyOf(features);
sampledColumns = columns;
densitySamples = samples;
}
public Cell cellAt(int x, int z) { return cells.get(key(x, z)); }
public Collection<Cell> cells() { return cells.values(); }
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
public List<Feature> features() { return features; }
public int sampledColumns() { return sampledColumns; }
public long densitySamples() { return densitySamples; }
}
public static Plan create(long seed, Sampler sampler) {
return new Planner(seed, sampler).build();
}
private record Column(int top, int solidBottom) {}
private record Candidate(int x, int z, double score) {}
private record Node(int x, int z, double cost) {}
private static final Comparator<Node> NODE_ORDER = Comparator.comparingDouble(Node::cost)
.thenComparingInt(Node::x).thenComparingInt(Node::z);
private static final class Planner {
private final long seed;
private final Sampler sampler;
private final Map<Long, Column> columns = new HashMap<>();
private final Map<Long, Integer> coarse = new HashMap<>();
private final Map<Long, Cell> cells = new HashMap<>();
private final List<Feature> features = new ArrayList<>();
private long samples;
Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; }
Plan build() {
List<Candidate> candidates = candidates();
int attempts = 0;
for (Candidate candidate : candidates) {
if (features.size() >= 5 || attempts++ >= 96) break;
Point center = new Point(candidate.x(), candidate.z());
if (nearWater(center.x(), center.z(), 38)) continue;
Column column = column(center.x(), center.z());
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
Kind kind = features.size() < 2 ? Kind.LAKE : Kind.POND;
int target = kind == Kind.LAKE ? 150 + (int) (random(seed, center.x(), center.z()) * 100)
: 40 + (int) (random(seed + 1, center.x(), center.z()) * 45);
// Prefer an existing low spot; a shallow cut is the fallback on level terrain.
for (int level : new int[]{column.top() + 1, column.top() - 1, column.top() - 2}) {
Set<Long> footprint = growBasin(center, level, target);
if (footprint == null) continue;
long id = featureId(features.size());
Map<Long, Cell> wet = waterCells(footprint, level, id);
if (wet == null || !contained(wet)) continue;
addFeature(id, kind, level, List.of(center), wet);
break;
}
}
// One quiet stream connects a basin to a nearby surface refuge at the same water level.
// Its route follows low excavation cost through the relief; it has no falling outlets.
boolean stream = false;
for (Feature source : List.copyOf(features)) if (addStream(source)) { stream = true; break; }
if (!stream) addPlateauStream(candidates);
addBanks();
return new Plan(cells, features, columns.size(), samples);
}
private List<Candidate> candidates() {
for (int x = -RADIUS; x <= RADIUS; x += GRID) {
for (int z = -RADIUS; z <= RADIUS; z += GRID) {
if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z));
}
}
List<Candidate> result = new ArrayList<>();
for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) {
for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) {
int height = coarse.getOrDefault(key(x, z), -1);
if (height < 64 || !inside(x, z, RADIUS - 24)) continue;
double average = 0;
boolean safe = true;
for (int[] d : CARDINALS) {
int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1);
if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false;
average += neighbor * 0.25;
}
if (safe) result.add(new Candidate(x, z,
(average - height) * 2.0 + random(seed + 2, x, z) * 3.0));
}
}
result.sort(Comparator.comparingDouble(Candidate::score).reversed()
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
return result;
}
private int coarseTop(int x, int z) {
for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y;
return -1;
}
private Column column(int x, int z) {
long key = key(x, z);
Column old = columns.get(key);
if (old != null) return old;
int top = -1;
// Exact scan, including everything above the planned water: a cave roof cannot be
// mistaken for a surface. Only candidates and their nearby shore need this refinement.
for (int y = 383; y >= 24; y--) {
if (solid(x, y, z)) { top = y; break; }
}
int bottom = top;
if (top >= 0) {
while (bottom > Math.max(16, top - 16) && solid(x, bottom - 1, z)) bottom--;
}
Column created = new Column(top, bottom);
columns.put(key, created);
return created;
}
private boolean eligible(int x, int z, int level) {
if (level < 32 || level >= 383 || !inside(x, z, RADIUS - 2)) return false;
Cell old = cells.get(key(x, z));
if (old != null && old.hasWater() && old.waterY() != level) return false;
Column column = column(x, z);
return column.top() >= level - 3 && column.top() < level + MAX_CARVE
&& column.solidBottom() <= Math.min(column.top(), level - 1) - 2;
}
private Set<Long> growBasin(Point center, int level, int target) {
if (!eligible(center.x(), center.z(), level)) return null;
Set<Long> wet = new HashSet<>();
Map<Long, Double> costs = new HashMap<>();
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
queue.add(new Node(center.x(), center.z(), 0));
costs.put(key(center.x(), center.z()), 0.0);
while (!queue.isEmpty() && wet.size() < target) {
Node current = queue.remove();
if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue;
if (!eligible(current.x(), current.z(), level)) continue;
wet.add(key(current.x(), current.z()));
for (int[] d : NEIGHBORS) {
int x = current.x() + d[0], z = current.z() + d[1];
if (Math.hypot(x - center.x(), z - center.z()) > 24 || wet.contains(key(x, z))) continue;
if (!eligible(x, z, level)) continue;
double step = Math.hypot(d[0], d[1]);
double cost = current.cost() + step * (1 + Math.max(0, column(x, z).top() - level) * 0.8
+ terrainNoise(x, z) * 2.0);
if (cost >= costs.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
costs.put(key(x, z), cost);
queue.add(new Node(x, z, cost));
}
}
wet = connectedTo(wet, key(center.x(), center.z()));
if (wet.size() < Math.min(target, 30)) return null;
// Filling a hollow requires every lower connected outlet to be included. Abort rather
// than place a dam if this natural basin runs to a cliff, cave or an oversized catchment.
List<Long> work = new ArrayList<>(wet);
for (int i = 0; i < work.size(); i++) {
Point point = point(work.get(i));
for (int[] d : CARDINALS) {
int x = point.x() + d[0], z = point.z() + d[1];
long neighbor = key(x, z);
if (wet.contains(neighbor) || column(x, z).top() >= level) continue;
if (wet.size() >= 420 || Math.hypot(x - center.x(), z - center.z()) > 28
|| !eligible(x, z, level)) return null;
wet.add(neighbor);
work.add(neighbor);
}
}
return wet;
}
private Map<Long, Cell> waterCells(Set<Long> footprint, int level, long id) {
Map<Long, Cell> result = new HashMap<>();
for (long key : footprint) {
Point p = point(key);
if (!eligible(p.x(), p.z(), level)) return null;
boolean edge = false;
for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1]));
Column column = column(p.x(), p.z());
int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0));
depth = Math.min(depth, MAX_CARVE - Math.max(0, column.top() - level));
if (depth < 1 || depth > MAX_DEPTH) return null;
int bed = level - depth;
if (column.top() < bed || column.solidBottom() > bed - 2) return null;
ShoreMaterial material = edge ? ShoreMaterial.SAND
: terrainNoise(p.x() + 73, p.z() - 19) > 0.65 ? ShoreMaterial.CLAY : ShoreMaterial.GRAVEL;
result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()), material, id));
}
return result;
}
private boolean contained(Map<Long, Cell> wet) {
for (Cell cell : wet.values()) {
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
if (!solid(cell.x(), y, cell.z())) return false;
}
for (int[] d : CARDINALS) {
int x = cell.x() + d[0], z = cell.z() + d[1];
Cell neighbor = wet.get(key(x, z));
if (neighbor == null) neighbor = cells.get(key(x, z));
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
if (neighbor != null && neighbor.hasWater()) {
if (neighbor.waterY() != cell.waterY()) return false;
if (y > neighbor.bedY()) continue;
}
if (!solid(x, y, z)) return false;
}
}
}
return true;
}
private void addFeature(long id, Kind kind, int level, List<Point> path, Map<Long, Cell> wet) {
int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
int count = 0;
for (Cell cell : wet.values()) {
Cell old = cells.get(key(cell.x(), cell.z()));
if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; }
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z());
}
features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ)));
}
private boolean addStream(Feature source) {
Point start = source.path().getFirst();
int level = source.waterY();
Map<Long, Double> costs = new HashMap<>();
Map<Long, Long> parent = new HashMap<>();
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
List<Node> refuges = new ArrayList<>();
long startKey = key(start.x(), start.z());
costs.put(startKey, 0.0);
queue.add(new Node(start.x(), start.z(), 0));
int visited = 0;
while (!queue.isEmpty() && visited++ < 1200) {
Node node = queue.remove();
long at = key(node.x(), node.z());
if (node.cost() > costs.getOrDefault(at, Double.POSITIVE_INFINITY)) continue;
double distance = Math.hypot(node.x() - start.x(), node.z() - start.z());
if (distance >= 44 && distance <= 76 && column(node.x(), node.z()).top() <= level + 3) refuges.add(node);
for (int[] d : NEIGHBORS) {
int x = node.x() + d[0] * 4, z = node.z() + d[1] * 4;
if (Math.hypot(x - start.x(), z - start.z()) > 88 || !eligible(x, z, level)) continue;
long next = key(x, z);
double cost = node.cost() + Math.hypot(d[0], d[1])
* (4 + Math.abs(column(x, z).top() - level) * 1.4 + terrainNoise(x, z) * 4);
if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue;
costs.put(next, cost); parent.put(next, at); queue.add(new Node(x, z, cost));
}
}
refuges.sort(Comparator.comparingDouble((Node n) -> n.cost() / Math.hypot(n.x() - start.x(), n.z() - start.z()))
.thenComparingInt(Node::x).thenComparingInt(Node::z));
int attempts = 0;
for (Node refuge : refuges) {
if (attempts++ >= 24) break;
List<Point> path = new ArrayList<>();
long cursor = key(refuge.x(), refuge.z());
while (cursor != startKey) { path.add(point(cursor)); cursor = parent.get(cursor); }
path.add(start); Collections.reverse(path);
Set<Long> corridor = new HashSet<>();
for (int i = 1; i < path.size(); i++) {
Point a = path.get(i - 1), b = path.get(i);
int length = Math.max(Math.abs(b.x() - a.x()), Math.abs(b.z() - a.z()));
for (int step = 0; step <= length; step++) {
int x = a.x() + (b.x() - a.x()) * step / length;
int z = a.z() + (b.z() - a.z()) * step / length;
int width = terrainNoise(x + 17, z + 29) > 0.65 ? 2 : 1;
for (int dx = -width; dx <= width; dx++) for (int dz = -width; dz <= width; dz++) {
if (dx * dx + dz * dz <= width * width + 1) corridor.add(key(x + dx, z + dz));
}
}
}
Set<Long> refugePool = growBasin(new Point(refuge.x(), refuge.z()), level, 36);
if (refugePool == null) continue;
corridor.addAll(refugePool);
if (!closeStreamOutlets(corridor, level)) continue;
long id = featureId(features.size());
Map<Long, Cell> wet = waterCells(corridor, level, id);
if (wet == null || !contained(wet)) continue;
addFeature(id, Kind.STREAM, level, path, wet);
return true;
}
return false;
}
/** Low lake floors may be trapped below every nearby plateau. Try a small natural surface
* refuge as a source too, retaining it only if a fully contained stream can actually leave it. */
private boolean addPlateauStream(List<Candidate> candidates) {
List<Candidate> plateaus = candidates.stream().filter(candidate -> flatness(candidate.x(), candidate.z()) <= 6)
.sorted(Comparator.comparingDouble((Candidate c) -> flatness(c.x(), c.z())
- random(seed + 91, c.x(), c.z()) * 2).thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)).toList();
int attempts = 0;
for (Candidate candidate : plateaus) {
if (attempts++ >= 20) break;
Point center = new Point(candidate.x(), candidate.z());
if (nearWater(center.x(), center.z(), 24)) continue;
Column column = column(center.x(), center.z());
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
for (int level : new int[]{column.top() - 1, column.top() - 2}) {
Set<Long> footprint = growBasin(center, level, 40);
if (footprint == null) continue;
long id = featureId(features.size());
Map<Long, Cell> wet = waterCells(footprint, level, id);
if (wet == null || !contained(wet)) continue;
addFeature(id, Kind.POND, level, List.of(center), wet);
if (addStream(features.getLast())) return true;
features.removeLast();
cells.values().removeIf(cell -> cell.featureId() == id);
}
}
return false;
}
private int flatness(int x, int z) {
int low = coarse.getOrDefault(key(x, z), -1000), high = low;
for (int[] d : CARDINALS) {
int value = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1000);
low = Math.min(low, value); high = Math.max(high, value);
}
return high - low;
}
/** Include small connected low spots beside a channel; reject large/deep/open outlets.
* This adds water to a verified natural hollow, never rock to close a leak. */
private boolean closeStreamOutlets(Set<Long> corridor, int level) {
int maximum = Math.min(900, corridor.size() + 180);
List<Long> work = new ArrayList<>(corridor);
for (int i = 0; i < work.size(); i++) {
Point point = point(work.get(i));
for (int[] d : CARDINALS) {
int x = point.x() + d[0], z = point.z() + d[1];
long next = key(x, z);
Cell old = cells.get(next);
if (corridor.contains(next) || old != null && old.hasWater() && old.waterY() == level
|| column(x, z).top() >= level) continue;
if (corridor.size() >= maximum || !eligible(x, z, level)) return false;
corridor.add(next); work.add(next);
}
}
return true;
}
private static Set<Long> connectedTo(Set<Long> footprint, long start) {
Set<Long> connected = new HashSet<>();
List<Long> work = new ArrayList<>();
connected.add(start); work.add(start);
for (int i = 0; i < work.size(); i++) {
Point point = point(work.get(i));
for (int[] d : CARDINALS) {
long next = key(point.x() + d[0], point.z() + d[1]);
if (footprint.contains(next) && connected.add(next)) work.add(next);
}
}
return connected;
}
private void addBanks() {
List<Cell> water = cells.values().stream().filter(Cell::hasWater)
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
for (Cell cell : water) {
for (int[] d : CARDINALS) {
int x = cell.x() + d[0], z = cell.z() + d[1];
if (cells.containsKey(key(x, z))) continue;
Column column = column(x, z);
if (column.top() < cell.waterY() || column.top() > cell.waterY() + 2
|| column.solidBottom() > column.top() - 2) continue;
cells.put(key(x, z), new Cell(x, z, -1, column.top(), column.top(),
ShoreMaterial.SAND, cell.featureId()));
}
}
}
private boolean nearWater(int x, int z, int distance) {
for (Feature feature : features) {
Point point = feature.path().getFirst();
if (Math.hypot(x - point.x(), z - point.z()) < distance) return true;
}
return false;
}
private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; }
private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); }
private double terrainNoise(int x, int z) {
int gx = Math.floorDiv(x, 8), gz = Math.floorDiv(z, 8);
double tx = smooth(Math.floorMod(x, 8) / 8.0), tz = smooth(Math.floorMod(z, 8) / 8.0);
double a = random(seed + 31, gx, gz) * (1 - tx) + random(seed + 31, gx + 1, gz) * tx;
double b = random(seed + 31, gx, gz + 1) * (1 - tx) + random(seed + 31, gx + 1, gz + 1) * tx;
return a * (1 - tz) + b * tz;
}
}
private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; }
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
private static Point point(long key) { return new Point((int) (key >> 32), (int) key); }
private static double smooth(double t) { return t * t * (3 - 2 * t); }
private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; }
private static long mix(long value) {
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return value ^ (value >>> 31);
}
}
@@ -1,4 +1,5 @@
{
"generator.sanctuary.sanctuary": "Sanctuary",
"biome.sanctuary.starter_forest": "Sanctuary Forest"
"biome.sanctuary.starter_forest": "Sanctuary Forest",
"biome.sanctuary.starter_forest_hydrology": "Sanctuary Forest and Shores"
}
@@ -1,4 +1,5 @@
{
"generator.sanctuary.sanctuary": "Sanctuary",
"biome.sanctuary.starter_forest": "For\u00eat de Sanctuary"
"biome.sanctuary.starter_forest": "Forêt de Sanctuary",
"biome.sanctuary.starter_forest_hydrology": "Forêt et rives de Sanctuary"
}
@@ -0,0 +1,171 @@
{
"attributes": {
"minecraft:audio/background_music": {
"default": {
"max_delay": 24000,
"min_delay": 12000,
"sound": "minecraft:music.overworld.forest"
}
},
"minecraft:gameplay/natural_mob_spawns": {
"argument": {
"spawn_costs": {},
"spawns_by_category": {
"ambient": [
{
"type": "minecraft:bat",
"count": 8,
"weight": 10
}
],
"creature": [
{
"type": "minecraft:sheep",
"count": 4,
"weight": 12
},
{
"type": "minecraft:pig",
"count": 4,
"weight": 10
},
{
"type": "minecraft:chicken",
"count": 4,
"weight": 10
},
{
"type": "minecraft:cow",
"count": 4,
"weight": 8
},
{
"type": "minecraft:wolf",
"count": 4,
"weight": 5
}
],
"monster": [
{
"type": "minecraft:spider",
"count": 4,
"weight": 100
},
{
"type": "minecraft:zombie",
"count": 4,
"weight": 95
},
{
"type": "minecraft:zombie_villager",
"count": 1,
"weight": 5
},
{
"type": "minecraft:skeleton",
"count": 4,
"weight": 100
},
{
"type": "minecraft:creeper",
"count": 4,
"weight": 100
},
{
"type": "minecraft:slime",
"count": 4,
"weight": 100
},
{
"type": "minecraft:enderman",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 4,
"min_inclusive": 1
},
"weight": 10
},
{
"type": "minecraft:witch",
"count": 1,
"weight": 5
}
],
"underground_water_creature": [
{
"type": "minecraft:glow_squid",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 6,
"min_inclusive": 4
},
"weight": 10
}
]
}
},
"modifier": "overlay"
},
"minecraft:visual/sky_color": "#79a6ff"
},
"carvers": [],
"downfall": 0.8,
"effects": {
"water_color": "#3f76e4"
},
"features": [
[],
[],
[],
[],
[],
[],
[
"minecraft:ore_dirt",
"minecraft:ore_granite_upper",
"minecraft:ore_granite_lower",
"minecraft:ore_diorite_upper",
"minecraft:ore_diorite_lower",
"minecraft:ore_andesite_upper",
"minecraft:ore_andesite_lower",
"minecraft:ore_tuff",
"minecraft:ore_coal_upper",
"minecraft:ore_coal_lower",
"minecraft:ore_iron_upper",
"minecraft:ore_iron_middle",
"minecraft:ore_iron_small",
"minecraft:ore_gold",
"minecraft:ore_gold_lower",
"minecraft:ore_redstone",
"minecraft:ore_redstone_lower",
"minecraft:ore_diamond",
"minecraft:ore_diamond_medium",
"minecraft:ore_diamond_large",
"minecraft:ore_diamond_buried",
"minecraft:ore_lapis",
"minecraft:ore_lapis_buried",
"minecraft:ore_copper"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:forest_flowers",
"minecraft:trees_birch_and_oak_leaf_litter",
"minecraft:patch_bush",
"minecraft:flower_default",
"minecraft:patch_grass_forest",
"minecraft:brown_mushroom_normal",
"minecraft:red_mushroom_normal",
"minecraft:patch_pumpkin",
"minecraft:patch_sugar_cane",
"minecraft:patch_firefly_bush_near_water",
"sanctuary:shore_sugar_cane"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,23 @@
{
"default_block": "minecraft:stone",
"default_fluid": "minecraft:air",
"disable_mob_generation": false,
"legacy_random_source": true,
"material_rule": "sanctuary:starter_island",
"noise": {
"height": 384,
"min_y": 0
},
"noise_router": {
"chunk_surface_level": 0.0,
"continents": 0.0,
"depth": 0.0,
"erosion": 0.0,
"final_density": "sanctuary:final_density_384",
"ridges": 0.0,
"temperature": 0.0,
"vegetation": 0.0
},
"sea_level": -64,
"spawn_target": []
}
@@ -0,0 +1,6 @@
{
"feature": {
"type": "sanctuary:shore_sugar_cane"
},
"placement": []
}
@@ -6,9 +6,9 @@
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:fixed",
"biome": "sanctuary:starter_forest"
"biome": "sanctuary:starter_forest_hydrology"
},
"settings": "sanctuary:sanctuary_384"
"settings": "sanctuary:sanctuary_hydrology"
}
},
"minecraft:the_end": {
@@ -3,7 +3,8 @@
"package": "fr.koka.sanctuary.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"InitialSpawnMixin"
"InitialSpawnMixin",
"SurfaceHydrologyMixin"
],
"client": [
"client.SanctuarySkyMixin",
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.worldgen;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Determinism and containment checks independent of Minecraft, using controlled floating terrain. */
public final class SurfaceHydrologySmoke {
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
public static void main(String[] args) {
SurfaceHydrology.Sampler terrain = (x, y, z) -> {
int top = 200 + (int) Math.round(2 * Math.sin(x / 24.0) + 2 * Math.cos(z / 31.0));
return Math.hypot(x, z) < 220 && y <= top && y >= 145 ? 0.45F : -0.45F;
};
var first = SurfaceHydrology.create(0, terrain);
var replay = SurfaceHydrology.create(0, terrain);
var changed = SurfaceHydrology.create(42, terrain);
require(!first.features().isEmpty(), "The safe rolling surface should contain surface water");
require(List.copyOf(first.cells()).equals(List.copyOf(replay.cells())) && first.features().equals(replay.features()),
"The same seed and density must replay exactly");
require(!List.copyOf(first.cells()).equals(List.copyOf(changed.cells())), "The world seed must influence water placement");
verify(first, terrain);
verify(changed, terrain);
SurfaceHydrology.Sampler pierced = (x, y, z) ->
Math.floorMod(x, 37) < 2 && Math.floorMod(z, 41) < 2 ? -0.45F : terrain.sample(x, y, z);
var aroundShafts = SurfaceHydrology.create(0, pierced);
require(!aroundShafts.features().isEmpty(), "Safe areas around natural shafts should remain usable");
verify(aroundShafts, pierced);
require(SurfaceHydrology.create(0, (x, y, z) -> -1).cells().isEmpty(), "An empty world must not acquire a guaranteed basin");
var roof = SurfaceHydrology.create(0, (x, y, z) ->
Math.hypot(x, z) < 220 && (y == 240 || y >= 145 && y <= 200) ? 0.45F : -0.45F);
require(roof.cells().isEmpty(), "Do not create underground lakes below a thin higher roof");
var slab = SurfaceHydrology.create(0, (x, y, z) ->
Math.hypot(x, z) < 220 && y >= 199 && y <= 200 ? 0.45F : -0.45F);
require(slab.cells().isEmpty(), "A thin floating slab cannot safely support water");
var flat = SurfaceHydrology.create(0, (x, y, z) ->
Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F);
require(flat.features().stream().anyMatch(feature -> feature.kind() == SurfaceHydrology.Kind.STREAM),
"The controlled safe plateau must exercise the stream planner");
System.out.println("SurfaceHydrologySmoke: deterministic lakes/stream, sealed floors/walls, chunk order, no roof/void/thin-slab water passed.");
}
private static void verify(SurfaceHydrology.Plan plan, SurfaceHydrology.Sampler terrain) {
Map<String, SurfaceHydrology.Cell> byChunks = new HashMap<>();
// Reverse chunk traversal must reconstruct the same cells without generating neighbours.
for (int cx = 12; cx >= -12; cx--) for (int cz = 12; cz >= -12; cz--) {
var chunk = plan.cellsInChunk(cx, cz);
SurfaceHydrology.Cell previous = null;
for (var cell : chunk) {
require(cell.x() >> 4 == cx && cell.z() >> 4 == cz, "A chunk slice writes outside its chunk");
require(previous == null || previous.x() < cell.x() || previous.x() == cell.x() && previous.z() < cell.z(),
"Cell order must be stable for seeded shore vegetation");
previous = cell;
require(byChunks.put(cell.x() + "," + cell.z(), cell) == null, "A cell belongs to multiple chunk slices");
}
}
require(byChunks.size() == plan.cells().size(), "Chunk slices omitted planned cells");
for (var cell : plan.cells()) {
require(cell.equals(byChunks.get(cell.x() + "," + cell.z())), "Chunk traversal changed the plan");
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
require(terrain.sample(cell.x(), y, cell.z()) > 0, "Bed material requires three existing solid blocks");
}
if (!cell.hasWater()) continue;
require(cell.waterY() - cell.bedY() >= 1 && cell.waterY() - cell.bedY() <= 4, "Invalid shallow-water depth");
require(cell.carveTop() - cell.bedY() <= SurfaceHydrology.MAX_CARVE, "Excavation exceeds six blocks");
for (int y = cell.carveTop() + 1; y < 384; y++) {
require(terrain.sample(cell.x(), y, cell.z()) <= 0, "Water placed below a natural roof");
}
for (int[] d : CARDINALS) {
int x = cell.x() + d[0], z = cell.z() + d[1];
var neighbor = plan.cellAt(x, z);
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
boolean plannedWater = neighbor != null && neighbor.hasWater()
&& y > neighbor.bedY() && y <= neighbor.waterY();
boolean carvedAir = neighbor != null && y > neighbor.bedY() && y <= neighbor.carveTop()
&& !plannedWater;
require(plannedWater || !carvedAir && terrain.sample(x, y, z) > 0,
"A water cell has an open horizontal outlet, including excavated banks");
}
}
}
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ Pour produire un fichier importable par un lanceur compatible Modrinth :
```sh
cd build/packwiz # depuis la racine du dépôt
packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.3.mrpack
packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.4.mrpack
```
L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API.
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "0.1.0-alpha.3"
version = "0.1.0-alpha.4"
pack-format = "packwiz:1.1.0"
[index]