Compare commits

...
Author SHA1 Message Date
koka f1e48fa969 Add layered meadows, rocky shores and lower wall springs
Build Sanctuary / build (push) Canceled after 0s
2026-09-08 19:16:45 +02:00
koka f621f921a1 Add natural shores, rock springs and temperate island biomes
Build Sanctuary / build (push) Canceled after 0s
2026-09-08 16:10:11 +02:00
koka 6e94c1c539 Add contained surface hydrology to Sanctuary Island
Build Sanctuary / build (push) Canceled after 0s
2026-09-08 14:24:56 +02:00
85 changed files with 10156 additions and 52 deletions
+66
View File
@@ -1,5 +1,71 @@
# Changelog
## 0.1.0-alpha.6 — 2026-09-08
- Palette tempérée plus douce, avec des couleurs communes pour lherbe, le
feuillage et leau. Dappled Forest quitte l’île initiale et reste réservé
aux futurs continents ; les sauvegardes alpha.5 le conservent.
- Prairies fleuries et affleurements rocheux plus étendus, avec une répartition
des biomes selon laltitude. Les corniches inférieures peuvent porter de la
mousse, des fougères, des fleurs et des petits arbustes.
- Sources recherchées aussi dans les parois des strates inférieures. Les
cascades peuvent rejoindre un palier ou descendre dans le vide.
- Retrait des courts ruisseaux à niveau constant : l’île conserve ses étangs
et petits lacs. La grande rivière avec source, chute et bassin reste à
développer ; elle nest pas activée dans cette livraison.
- Nouvelle clé `sanctuary:sanctuary_layered`, avec les anciennes générations
conservées. Créer un **nouveau monde Sanctuary** pour cet incrément.
Validation : dix tests serveur réussis sur chacune des graines `0`, `42` et
`8675309`, avec 1 800 ticks de fluides, contrôles des corniches et respect des
modifications ultérieures aux sources. `check build assemblePack` réussi.
## 0.1.0-alpha.5 — 2026-09-08
- Plages plus larges et irrégulières, en nappes de sable, gravier et roche, avec
des transitions herbeuses. Dépôts de 3 à 5 blocs au lieu dune seule couche.
- Étangs et lacs moins creusés, rives variées et petites terrasses ; la canne
à sucre reste soumise à ses conditions vanilla.
- Quelques sources sortent de niches rocheuses naturelles à différentes
altitudes. Leurs cascades peuvent descendre dans le vide et servir à circuler
verticalement ; seuls les blocs sources sont posés par la génération.
- Petite réserve de lave dans une niche rocheuse accessible lorsque le relief
fournit un fond, des parois et une voûte adaptés.
- Mosaïque de cinq biomes tempérés, dont Dappled Forest de Minecraft 26.3.
- Petits filons de charbon, fer, cuivre, or, redstone, lapis et diamant à des
altitudes adaptées à l’île. Aucun quota ajouté après comptage des ressources.
- Nouvelle clé de génération `sanctuary:sanctuary_natural` ; anciennes versions
de terrain et dhydrologie conservées pour les sauvegardes existantes.
Créer un **nouveau monde Sanctuary** pour tester cette génération. La hauteur
et le ciel validés en alpha.3/4 sont conservés. Laccès à lEnd, les expansions
et les grandes rivières en pente restent à développer.
Validation : dix tests serveur réussis sur chacune des graines 0, 42 et 8675309,
avec 1 800 ticks réels de fluides. Les bassins et les réserves de lave restent
stables ; les neuf cascades atteignent Y=0. `check build assemblePack` et les
contrôles des couches de sédiments, des anciens générateurs et du pack réussissent.
## 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
+22 -5
View File
@@ -22,20 +22,37 @@ décrits dans la vision ne sont pas encore implémentés.
- Mod `sanctuary` indépendant et pack Sanctuary construit avec packwiz.
- Preset **Sanctuary** sélectionnable à la création d'un monde, avec une île
flottante finie, un terrain naturel et un extérieur vide.
- Forêt de départ avec végétation, minerais et animaux vanilla. Les formes de
terrain reprennent l'approche du générateur 26.2, adaptée à l'API 26.3.
- Île tempérée aux teintes douces : prairies fleuries, bosquets, mousse et
affleurements rocheux, avec des biomes qui varient aussi selon laltitude. Les
formes de terrain reprennent le générateur 26.2, adapté à l'API 26.3.
- Petits filons de charbon, fer, cuivre, or, redstone, lapis et diamant à des
altitudes adaptées au terrain relevé. Les stocks se constatent après génération.
- Spawn recherché sur le sol de l'île lors de la création. Les mondes ordinaires
et les choix ultérieurs de spawn ne sont pas remplacé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.
- Étangs, petits lacs, plages plus larges et dépôts de
trois à cinq blocs de sable, gravier, pierre ou terre enherbée. La canne à
sucre apparaît lorsque les rives respectent ses règles vanilla.
- Les courts ruisseaux imposés sont retirés ; l’île garde ses étangs et petits
lacs. La grande rivière traversante reste à développer.
- Quelques sources dans des niches rocheuses naturelles, y compris aux étages
inférieurs : Minecraft fait couler leur eau en cascades, qui peuvent descendre jusque dans le vide.
- Une petite poche de lave couverte, accessible depuis une corniche naturelle,
peut fournir la matière nécessaire à lobsidienne lorsque le relief convient.
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 océans, les ruines et
les autres fonctionnalités restent à construire par tickets.
Lalpha.6 utilise de nouveaux paramètres de génération ; créer un **nouveau monde**
pour découvrir ses biomes par altitude et ses eaux. Les sauvegardes alpha.2
à alpha.5 conservent leur génération. Dappled Forest est désormais réservé aux
futurs continents et reste présent dans les anciennes sauvegardes alpha.5. La validation de cet incrément est suivie dans [Validation](docs/testing.md).
Le premier champ de recherche climatique est défini : nord froid, sud chaud,
ouest sec et est humide, avec leurs combinaisons diagonales. Un outil de
développement relève les ressources après génération ; le système d'expansion
@@ -58,7 +75,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.6 |
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 +102,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.6.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).
+7 -3
View File
@@ -7,9 +7,13 @@ provenance détaillée et le périmètre du portage.
Minecraft appartient à Mojang Studios / Microsoft. Sanctuary est un projet
communautaire indépendant. Le dépôt ne redistribue pas le jeu.
Le biome de départ reprend la configuration de la forêt vanilla 26.3-pre-2,
adaptée pour une île finie. Les références de blocs, végétation et minerais
sont résolues par Minecraft ; leurs textures et modèles ne sont pas inclus.
Les biomes de départ reprennent les configurations de forêt, plaine, forêt de
bouleaux, forêt fleurie et Dappled Forest de Minecraft 26.3-pre-2, adaptées à
une île finie. Les variantes alpha.6 réutilisent les végétations vanilla pour
les prairies fleuries et les bosquets ; Dappled Forest reste disponible pour
les sauvegardes alpha.5. Les règles des petits minerais sont adaptées à partir des
features vanilla de cette même version. Les références de blocs, végétation et
minerais sont résolues par Minecraft ; leurs textures et modèles ne sont pas inclus.
Fabric Loader : Apache-2.0. Fabric API : Apache-2.0. Fabric Loom et le wrapper
Gradle : Apache-2.0. Ces projets conservent leurs auteurs, notices et licences.
+38 -1
View File
@@ -118,10 +118,34 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
- Pour l'île initiale tempérée et légèrement humide : privilégier petits étangs,
lacs de surface, rivière et berges propices à la canne à sucre, avec quelques
plages plus sèches. Pas de grands bassins souterrains remplis d'eau.
- L'eau ne se répand pas de manière incontrôlée dans le vide lors du chargement et des mises à jour de blocs du scénario testé.
- Les étangs et les lacs retiennent leur eau après les mises à jour de blocs.
Des sources rocheuses déclarées peuvent former des cascades jusquau vide ;
leurs écoulements restent attribuables à ces sources, sans inondation globale.
- 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 retenue,
conservée pour les sauvegardes de cette version.
**Incrément île initiale — alpha.5 :** plages plus larges,
dépôts de trois à cinq couches sur support naturel, excavation limitée,
rives variées et sources rocheuses pouvant former des cascades jusque dans le
vide. Le contrat et lisolation des anciennes générations sont décrits dans
[Génération](worldgen.md). Ce travail prépare les eaux des continents sans clore
WG-06 : océans, grandes rivières et ouverture des continents restent à développer.
**Incrément île initiale — alpha.6 :** retrait des courts ruisseaux, conservation
des bassins, présence de roche élargie et sources de paroi dans les strates
inférieures. Les écoulements sont contrôlés dans le moteur et les anciennes
générations sont préservées.
**Ticket suivant — grande rivière facultative :** chercher un long parcours qui
épouse l’île, avec source, chute et bassin, rives de sable, gravier et argile.
Le démontrer sur la vraie densité Minecraft puis après décoration et ticks de
fluide, avec une graine et des coordonnées reproductibles. Les essais du
prototype alpha.6 nont fourni aucun parcours retenu sur les graines de référence ;
ce prototype nest pas distribué. Ne pas imposer de rivière si le relief ne sy prête pas.
### WG-07 — Introduire un premier biome distinct et une structure
**Dépendance :** WG-06.
@@ -136,6 +160,19 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
- La reprise du catalogue TerraMix natif d'Another World 26.2 est évaluée séparément ; « plus de cent biomes » reste une ambition tant que son adaptation n'est pas vérifiée.
- Les limites des futures zones Lost Cities et des structures uniques sont identifiées sans imposer leur livraison dans ce ticket.
**Incrément île initiale — alpha.5 :** cinq variantes
tempérées à dominante forestière, dont Dappled Forest vanilla 26.3, et petits
filons de sept minerais à des altitudes adaptées. Leurs stocks sont observés
après génération. Cela ne clôture pas WG-07 : aucune structure de référence ni
reprise du catalogue TerraMix nest encore fournie. La survie initiale doit
permettre de produire et progresser au-delà du bois et de la pierre ; laccès
à toute la progression Minecraft nest pas encore garanti pour chaque seed.
**Ticket palette — alpha.6 :** réserver les biomes très contrastés de la 26.3,
dont Dappled Forest, aux futurs continents. L’île combine prairies fleuries,
bosquets et zones rocheuses, avec des teintes cohérentes et une végétation
effective sur les corniches inférieures. Cela ne livre pas encore les continents.
## Réserve de thèmes futurs
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
+31 -6
View File
@@ -30,18 +30,27 @@ de sélection des biomes feront partie du ticket de génération des continents.
Les ressources et cultures futures, notamment celles d'It's Alive !, pourront
ainsi demander l'exploration de plusieurs climats.
L'île initiale utilise encore un biome de départ spécifique. Une température
ou une humidité du biome ne constitue pas à elle seule une carte climatique
directionnelle : cette carte et la sélection des biomes des continents restent
des travaux distincts.
Lalpha.5 proposait cinq biomes tempérés dont Dappled Forest. Lalpha.6 réserve
ce dernier aux futurs continents et donne à l’île une palette plus douce de
prairies, prairies fleuries, bosquets, corniches moussues et zones rocheuses.
La répartition tient compte de laltitude, y compris dans les strates inférieures.
La variation locale et lhumidité dépendent de la graine ; elles ne
constituent pas encore le champ de recherche directionnel des continents.
## Eau de surface et exploration
L'île de départ doit pouvoir accueillir naturellement des étangs et lacs de
surface, des berges propices à la canne à sucre et, lorsque le relief s'y prête,
des rivières. L'eau s'inscrit dans les creux et les chemins du terrain existant.
Les rivières flottantes et les cascades doivent tenir compte de la continuité
de leur lit et du vide sous les îles.
Les bassins gardent un fond et des berges fermés. Une grande rivière reste
facultative : elle doit suivre le relief et former un parcours lisible avec
source, chute et bassin. Ses exutoires peuvent laisser couler leau dans le vide.
Sans parcours suffisamment long, l’île conserve seulement ses petits bassins.
Lalpha.6 livre cette variante à bassins seuls ; la grande rivière reste à développer.
Lalpha.5 ajoute séparément quelques sources sortant dune niche rocheuse
naturelle : leurs cascades peuvent descendre dans le vide et servir à remonter
verticalement. Cet écoulement est volontaire ; il ne doit pas entraîner des
fuites sur toutes les rives.
Le contrat ne demande ni un bassin souterrain imposé pour obtenir un stock
d'eau, ni une cuvette ajoutée systématiquement à une position fixe. Une surface
@@ -76,6 +85,22 @@ rapports de continents devront conserver leur propre graine, version, emprise
et état de génération, afin d'additionner seulement des régions connues et
disjointes lorsque le besoin économique sera défini.
## Ressources de départ — depuis lalpha.5
L’île vise les premières étapes de survie, les outils, lenchantement et la
redstone. Les sept minerais (charbon, fer, cuivre, or, redstone, lapis et diamant)
utilisent des petites veines à des altitudes compatibles avec le terrain relevé.
Le charbon et le fer ne reprennent plus leurs fréquences vanilla beaucoup trop
généreuses dans cette géométrie. Une petite réserve de lave peut apparaître dans une niche rocheuse accessible
pour les premières productions et laccès au Nether. Le bois, la pierre, les animaux et les cultures
renouvelables conservent leur fonctionnement Minecraft : ce réglage des minerais
ne constitue pas une interdiction des grandes constructions.
Les placements restent probabilistes, sans quota de rattrapage après comptage.
Une graine peut donc manquer dune ressource ; la présence de tout le parcours
Minecraft nest pas garantie. Les structures donnant accès à lEnd et leur lien
avec lexpansion restent à concevoir.
## Relevé de développement disponible
`ResourceSurveyDiagnostics`, dans `src/gametest`, lit les chunks au statut
+11 -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.6.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.6/sanctuary-0.1.0-alpha.6.jar
```
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
@@ -115,10 +115,18 @@ 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 nouvelles plages, cascades,
biomes et petits filons de lalpha.5 utilisent à leur tour un identifiant
séparé : créer un nouveau monde Sanctuary pour les découvrir. 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.
Lalpha.6 utilise à son tour `sanctuary:sanctuary_layered` pour ses biomes par
altitude et sa nouvelle hydrologie. Créer un nouveau monde Sanctuary ; les
sauvegardes alpha.5 conservent notamment leur palette avec Dappled Forest.
Références officielles : [installation packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/),
[commandes Prism](https://prismlauncher.org/wiki/help-pages/custom-commands/),
[bootstrap v0.0.3](https://github.com/packwiz/packwiz-installer-bootstrap/releases/tag/v0.0.3).
+274 -2
View File
@@ -27,6 +27,18 @@ 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.
Les tâches `:sanctuary:naturalHydrologySmoke` et `:sanctuary:starterLavaSmoke`
complètent ces contrôles pour lalpha.5 : dépôts de trois à cinq couches sur
support naturel, terrasses peu creusées, niches de source, parcours de cascade
et petite réserve de lave avec accès sec. Elles vérifient le déterminisme et
labsence de remplissage de secours lorsque la géométrie ne convient pas.
## Tests dans le moteur Minecraft
`runGameTest` démarre le serveur de test headless officiel de Fabric dans
@@ -52,7 +64,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
@@ -68,10 +80,18 @@ Les sept tests Sanctuary vérifient :
5. Le nouveau monde et son bruit utilisent bien 384 blocs, à partir de Y=0,
tandis que les anciens paramètres restent à 256 blocs. Les nuages sont
au-dessus de l'île, à Y=352,33.
6. Les chunks complets inspectés gardent une marge vide sous l'île, à Y=0..15.
6. Aucun plancher solide napparaît sous lîle, à Y=0..15. Dans les générations
alpha.5 et alpha.6, seules les eaux des cascades déclarées peuvent traverser cette marge.
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 :
profils épais de sédiments, bassins retenus, raccords entre chunks, canne à
sucre valide, niches rocheuses et descente réelle des cascades. Les cinq
biomes et les minerais sont inspectés sur le terrain. Une réserve de lave
présente conserve ses 18 sources, ses supports et son accès après simulation.
9. Les anciennes clés gardent leur traitement dorigine. Seuls les nouveaux
paramètres activent les biomes par altitude et les eaux de lalpha.6.
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 +129,47 @@ 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.
Pour lalpha.5, les chunks de l’île sont maintenus actifs, puis le test attend
`ServerLevel.isPositionTickingWithEntitiesLoaded` sur chaque chunk de fluide.
Ce prédicat, réellement utilisé par le planificateur des ticks en 26.3, exige
à la fois le statut de simulation et le chargement des entités. `FULL` et
`shouldTickBlocksAt` seuls ne prouvent pas que les ticks programmés sexécutent.
Un témoin deau temporaire est vérifié dans chaque chunk de source et de lave,
puis retiré entièrement. La simulation des sites dure **1 800 ticks** après cette
activation. La graine 0 doit notamment démontrer une cascade qui atteint `Y=0`.
Les fichiers `natural-hydrology-seed-<graine>-generated.json/.png` et
`natural-hydrology-seed-<graine>-after-1800-ticks.json/.png` sont écrits dans
`mods/sanctuary/build/run/gameTest/diagnostics/`, avec des profils PNG des
cascades. Ils décrivent les sites, dépôts, fluides, biomes et ressources des
chunks inspectés, ainsi que la graine et la version. Ce relevé de ressources
reste local à ces chunks et ne représente pas le stock total de l’île.
Le diagnostic alpha.4 est conservé séparément, avec ses fichiers
`surface-hydrology-…-after-200-ticks` et ses eaux entièrement retenues.
Le nombre de blocs deau ne mesure pas un 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 général (neuf chunks ou île entière). Le diagnostic hydrologique
garde ses propres comptes locaux. Les options 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 +188,217 @@ 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.
## Contrôles alpha.6
`layeredHydrologySmoke` vérifie les bassins sans ruisseau de secours, les
supports des dépôts, leur déterminisme et les sources sur les faces des
strates inférieures. Les reliefs vides, trop minces ou percés ne sont pas comblés.
`layeredEcologySmoke` vérifie les sols et le dégagement des corniches,
lexclusion du sommet et du vide, ainsi que la variation verticale des biomes.
`LayeredHydrologyDiagnostics` inspecte les nouveaux paramètres et refuse
Dappled Forest dans leur palette. Il vérifie les couleurs communes, les biomes
des surfaces hautes et basses, la végétation réelle et les surfaces rocheuses.
Les plantes vanilla sur une corniche mince sont relevées séparément : elles
ne constituent pas une preuve des trois supports exigés par la nouvelle
décoration moussue. Leurs coordonnées et leurs supports sont exportés.
Les tickets de toute lemprise sont posés avant le chargement des chunks `FULL`.
Le test appelle ensuite `ServerLevel.waitForEntities(chunk, 0)` sur cette même
emprise : le serveur GameTest accélère ses ticks et un délai en ticks seul ne
prouve pas que les lectures asynchrones dentités sont terminées. Le prédicat
`isPositionTickingWithEntitiesLoaded` et les témoins d’écoulement restent requis.
Le diagnostic simule 1 800 ticks dans les chunks actifs. Les bassins doivent
retenir leur eau. Les écoulements extérieurs doivent être reliés à une source
rocheuse déclarée par des connexions deau réelles, sans eau orpheline ni voisin
horizontal en dehors de lemprise inspectée. Les seuls sites de surface sont
des étangs et des lacs ; aucune rivière nest activée dans cette livraison.
Les ouvertures des sources sont vérifiées après toute la décoration ; une
source bouchée par du lichen ne peut pas satisfaire le contrôle de descente.
Après ces mesures, un test de réentrée pose temporairement un lichen à une
bouche dont les marqueurs de génération ont été consommés. Un nouvel appel au
post-traitement doit le conserver ; le test restaure ensuite le bloc dorigine.
La preuve est dans `layered-hydrology-seed-<seed>-postprocessing-reentry.json`.
Les cartes `layered-hydrology-seed-<seed>-<phase>*.png` montrent les surfaces,
les corniches et les profils de sources. Les JSON associés contiennent les
résultats et leur emprise. Ces lectures de blocs ne remplacent pas lexamen
du paysage dans le client.
## Validation alpha.6 — 8 septembre 2026
Les dix tests serveur passent sur chacune des graines `0`, `42` et `8675309`,
dans des mondes neufs Minecraft 26.3-pre-2 avec Java 25, sur Apple M1 à 8 Go.
Chaque emprise est réellement active pendant 1 800 ticks mesurés. La commande
finale `check build assemblePack`, avec les exports de densité et le relevé
ressources général facultatifs désactivés, passe en **3 min 30 s** sur la graine 0.
Les six tests de forme, hydrologie, lave et écologie passent également.
| Graine | Spawn naturel | Eau des bassins | Eau des cascades | Canne | Lave | Chunks FULL | Plantes sur corniches épaisses |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `0` | `(0, 252, 0)` | 1 533 | 414 | 20 | 18 | 151 | 11 021 |
| `42` | `(0, 249, 0)` | 1 376 | 913 | 4 | 18 | 149 | 6 334 |
| `8675309` | `(0, 247, 0)` | 1 590 | 2 350 | 10 | 18 | 160 | 9 532 |
Ces nombres décrivent uniquement les chunks inspectés, après décoration et
simulation. Les plantes incluent la végétation vanilla et la nouvelle décoration,
sur des corniches avec trois supports naturels ; les plantes de corniches
plus minces sont comptées séparément. Les cinq variantes de biome sont observées
sur les surfaces inférieures, avec respectivement cinq, trois et deux changements
verticaux contrôlés. Les surfaces inspectées comportent 5 805, 4 765 et 10 045
colonnes de pierre au sommet ; il ne sagit pas dun quota pour l’île entière.
Sur la graine 0, les sources `(-112, 103, 0)` et `(-108, 227, -96)` atteignent
Y=0 ; celle de `(-110, 148, -46)` rejoint un palier à Y=130. La première traverse
une frontière de chunks. Sur la graine 42, la source `(-62, 151, -82)` dégage
correctement son ouverture autrefois obstruée par du lichen et descend de
49 blocs ; les deux autres descendent de 103 et 236 blocs. Les trois cascades
de la graine 8675309 atteignent Y=0. Tous les écoulements observés sont reliés
à leur source, sans eau orpheline ni dépassement horizontal de lemprise.
Les bassins conservent leur eau ; les sédiments et leurs supports résistent aux
ticks de gravité. Les trois niches de lave gardent leurs 18 sources, leur coque
et leur accès sec. Le test de réentrée après consommation des marqueurs réussit
sur les trois graines : une modification ultérieure de la bouche reste intacte.
Les 52 fichiers historiques de génération suivis pour cette livraison gardent
leur SHA-256 davant modification.
Les cartes de surface, de corniches et les profils de cascade ont été examinés.
Ce sont des lectures de blocs ; le rendu dans le client reste à apprécier en
jeu. La grande rivière traversante nest pas activée : cette livraison adopte
les petits bassins et les sources de paroi. Les ressources restent observées
après génération, sans garantie de toute la progression Minecraft pour chaque graine.
## Validation alpha.5 — 8 septembre 2026
Les essais utilisent Minecraft 26.3-pre-2, Java 25 et le preset de production
sur des mondes neufs, sur Apple M1 à 8 Go. Le diagnostic attend les chunks
réellement simulables avant de mesurer 1 800 ticks de fluides.
Les dix tests serveur réussissent sur chacune des graines `0`, `42` et
`8675309`. La vérification finale `check build assemblePack`, avec les cartes de
densité et le relevé général facultatifs désactivés, réussit en **3 min 11 s**
sur la graine 0. Les contrôles ciblés de ressources, de biomes et de fluides
restent actifs. Les tâches de forme, dhydrologie et de lave réussissent aussi.
| Graine | Spawn naturel | Eau des bassins | Eau des cascades | Canne à sucre | Lave | Chunks FULL inspectés |
| --- | --- | ---: | ---: | ---: | ---: | ---: |
| `0` | `(0, 252, 0)` | 1 930 | 1 649 | 52 | 18 | 189 |
| `42` | `(0, 249, 0)` | 1 859 | 1 058 | 25 | 18 | 177 |
| `8675309` | `(0, 247, 0)` | 1 830 | 3 960 | 16 | 18 | 195 |
Sur la graine 0, les trois sources partent de `(-108, 227, -96)`,
`(-26, 203, -141)` et `(-82, 176, 141)` et atteignent toutes **Y=0**. Les trois
sources des graines 42 et 8675309 atteignent également Y=0. Les bassins conservent leur
volume ; la niche de lave conserve 18 sources et son accès praticable, sans
incendie détecté. Le cours deau reste continu à travers les frontières de chunks.
Les dépôts ont trois à cinq couches et deux supports naturels en dessous.
La rive sèche la plus éloignée de leau atteint une distance de Manhattan de
20 blocs sur la graine 0, 28 sur la graine 42 et 27 sur la graine 8675309. Les cartes montrent des plages
larges et asymétriques, des bandes de gravier et des interruptions par la roche
ou lherbe. Quelques petites poches restent anguleuses à l’échelle du bloc.
Les profils montrent les cascades réelles ; ces lectures de blocs ne remplacent
pas lappréciation du relief et des couleurs dans le client.
Les cinq biomes sont vérifiés dans des chunks réels. Dans les **189 chunks
inspectés de la graine 0**, le relevé compte 754 minerais de charbon, 464 de fer,
197 de cuivre, 215 dor, 210 de redstone, 23 de lapis et 14 de diamant. Ce sont
des blocs présents, pas des rendements de minage ni un total pour l’île entière.
Le bois, la pierre et les ressources renouvelables nont pas de plafond de
construction ajouté. La progression complète et l’équilibrage coopératif restent
à éprouver en partie.
Le calcul initial du plan hydrologique prend 23 623 ms dans la vérification
finale de la graine 0 et 24 475 ms dans lessai de la graine 42 ; la recherche
de lave ajoute respectivement 1 075 ms et 1 568 ms. Le plan est mis en cache par état aléatoire de monde,
sans charger de chunks voisins. Ces durées sont distinctes du chargement des
chunks de diagnostic.
Le premier essai court a révélé un défaut du banc de test : un chunk FULL muni
dun ticket pouvait encore attendre ses entités et ne pas exécuter ses ticks de
fluide. Le contrôle utilise désormais le prédicat exact du moteur et un témoin
indépendant par chunk de source. Un autre essai a rencontré trois blocs de chêne couché vanilla au niveau
supérieur dun étang. Le diagnostic conserve cette décoration et distingue le
volume deau prévu des sources restantes : seuls des troncs horizontaux de
surface jouxtant une source, avec de leau dessous ou le fond peu profond
plein, sont acceptés. Leur position et leur état exacts, lappui ou leau sous
le bois, les parois et le reste des sources sont vérifiés
après simulation. Le contrôle de continuité du cours deau reste strict.
Le dernier essai de la graine 8675309 conserve les 1 830 sources prévues sans
tronc dans l’étang ; le décor vanilla peut varier avec lordre de génération
des chunks, tandis que le plan Sanctuary reste identique.
Deux arbres sur des paliers de la troisième graine dévient aussi les cascades.
Le trajet sur densité nue ne prévoit pas ces feuilles. Le diagnostic final
compare cette prévision aux blocs réels, puis vérifie les composantes deau
par connexions de face : toute composante hors bassin doit rejoindre une source
déclarée. Aucune eau ne doit toucher un chunk horizontal non inspecté. Lemprise
reste celle prévue initialement avec son halo dun chunk, sans élargissement
après observation des écoulements. Sur la graine 8675309, les trois composantes
comptent 2 445, 1 152 et 363 blocs deau après simulation ; toutes rejoignent
leur source et restent entièrement dans les chunks inspectés.
Les anciennes classes et ressources de terrain et dhydrologie sont également
comparées octet par octet avec lalpha.4.
Journaux : `build/alpha5-build.log`, `build/alpha5-seed42-final.log` et
`build/alpha5-seed8675309-provenance.log`. Les exports JSON/PNG sont dans le dossier
`diagnostics` décrit plus haut. Le dernier contrôle par composantes deau est
exécuté sur les graines 8675309 et 0 ; lessai réussi de la graine 42 utilisait
encore le contrôle plus restrictif du trajet prévu sur densité nue. Le code de
génération est identique entre ces essais.
## 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 :
+29 -2
View File
@@ -51,12 +51,39 @@ Les intégrations communautaires envisagées comprennent Fabric API, Sodium, Iri
L'île de Sanctuary est le point de départ commun. Elle flotte dans un vide qui ne doit pas être rempli par la génération habituelle du monde. Les continents suivants sont de vastes terres suspendues, créées progressivement selon une taille et une direction maîtrisées.
L'île initiale doit être **tempérée et légèrement humide**, avec quelques zones
plus sèches, notamment des plages. Une rivière et de petits étangs ou lacs de
surface peuvent accueillir de la canne à sucre. Les grands bassins souterrains
plus sèches, notamment de larges plages et dépôts irréguliers de sable, gravier,
pierre et terre enherbée. De petits étangs ou lacs de surface
peuvent accueillir de la canne à sucre. Lorsque le relief le permet, une grande
rivière suit la forme de l’île, depuis une source jusqu’à une cascade et un
bassin, avec des rives de sable, de gravier et dargile. Ce parcours doit former
un lieu reconnaissable ; il reste facultatif. À défaut, l’île conserve ses
petits bassins, sans imposer de courts ruisseaux. Le terrain est traité près de la surface,
avec des dépôts épais et des rives variées. De rares sources sortent de niches
rocheuses naturelles et leurs cascades peuvent descendre jusque dans le vide ;
les bassins restent retenus et ne doivent pas provoquer une inondation globale.
Une rivière peut avoir des échappements et des chutes déclarés. Les grands bassins souterrains
remplis d'eau ne sont pas retenus pour cette île. Le terrain doit laisser voir un
dessous sculpté et dégagé, dans un monde de **384 blocs de hauteur**, avec des
nuages relevés et sans disque noir artificiel apparaissant sous un seuil d'altitude.
Lambiance de départ doit rester douce et tempérée, avec des bosquets,
des prairies fleuries et de larges zones rocheuses. Les biomes très contrastés
de Minecraft 26.3, dont Dappled Forest, sont réservés aux futurs continents.
Les biomes et la végétation varient aussi en hauteur : les corniches inférieures
peuvent porter des bosquets, de la mousse et des fleurs lorsque leur surface
sy prête. Quelques sources de paroi et ruissellements relient ces étages.
Les plages sinspirent des nappes de
sable des générations Alpha/Beta : elles peuvent relier des poches deau, tandis
que dautres rives restent rocheuses ou enherbées. Les quelques cascades servent
également dascenseurs deau pour circuler verticalement.
L’île doit offrir les ressources nécessaires aux premières étapes de Minecraft,
en quantités modestes pour pousser à explorer les expansions lors des grands
projets. Les minerais doivent être trouvables aux altitudes du terrain. Les
stocks sont observés après génération et les ressources renouvelables gardent
leurs règles Minecraft. Le réglage précis de ces quantités reste à éprouver en
partie coopérative. L’état livré figure dans [Génération](worldgen.md).
Le premier champ de recherche des expansions associe les **huit directions**
autour de Sanctuary à des signatures de température et d'humidité. En regardant
la carte avec le nord en haut : **nord froid, sud chaud, ouest sec et est humide**.
+245 -16
View File
@@ -34,30 +34,35 @@ 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 traite ensuite les lits et les rives près de la 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.
La côte réelle apparaît plus tôt, selon la densité du terrain et la seed.
Il reste du vide permanent au-delà de **288 blocs**, sans répétition d'îles.
Aucun terrain ne réapparaît au-delà de **288 blocs**, sans répétition d'îles.
Les arbres peuvent dépasser la côte rocheuse de quelques blocs.
- Les nouveaux mondes utilisent **384 blocs de hauteur**, de `Y=0` à `Y=383`,
pour la dimension et le générateur. La roche disparaît avant leurs limites.
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
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.
- Les carvers, géodes, donjons, lacs de lave et sources sont retirés de cette
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.
- Les nouveaux mondes utilisent une palette tempérée de prairies, de bosquets,
de corniches moussues et de zones rocheuses, répartie aussi en hauteur.
Dappled Forest est réservé aux futurs continents. Leur végétation et leurs animaux proviennent
de Minecraft 26.3-pre-2. Les nouveaux identifiants ne reprennent pas les tags
de structures vanilla, afin de ne pas placer leurs structures dans le vide.
- Les carvers, géodes, donjons, grands lacs de lave et sources vanilla sont
retirés. Leau de Sanctuary suit son propre plan de surface et de niches
rocheuses. Les arbres et les petits filons ont besoin du terrain existant.
Aucun mod de biomes externe nest 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 ;
leur abondance n'est donc pas encore équilibrée pour une île flottante.
Lalpha.6 associe ces biomes aux plages, aux eaux et aux petits filons décrits
ci-dessous. Les ressources de survie ne se limitent pas au bois et à la pierre,
mais cet incrément ne garantit pas encore une partie Minecraft complète sur
chaque seed. Laccès à chaque progression vanilla et les ressources renouvelables
nécessitent des vérifications concrètes. Les stocks réels sont relevés après la
génération, sans corriger une île pour atteindre un quota.
## Sculpture du pourtour — conservée depuis la génération v2
@@ -118,8 +123,9 @@ ce gradient inférieur. Une nouvelle érosion travaille sur une transition de
deux bruits de sculpture existants. Elle laisse les lobes naturels se poursuivre
vers le bas, puis les amincit à différentes hauteurs ; elle n'ajoute ni socle ni
surface garantie. La transition n'affecte plus le terrain à partir de `Y=144`.
Une marge vide protège le bas de la dimension ; le contrôle des chunks terminés
vérifie que la tranche `Y=0..15` ne contient aucun bloc.
Une marge vide protège le bas de la dimension : aucun plancher rocheux ne doit
apparaître dans `Y=0..15`. À partir de lalpha.5, seules les eaux descendantes de
sources explicitement déclarées peuvent traverser cette marge.
Les champs optionnels du codec `vertical_offset`, `height` et `underside` valent
respectivement `0`, `256` et un bruit nul par défaut. Seule la nouvelle fonction
@@ -134,6 +140,227 @@ 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. Ce contrat alpha.4 reste celui de ses sauvegardes. Les sources en cascade sont
ajoutées séparément dans la génération v5 ; les grandes rivières en pente, océans,
aquifères, climats multiples et continents restent des incréments distincts.
## Plages, sources et mosaïque tempérée — génération v5
Limplémentation **0.1.0-alpha.5** sélectionne la nouvelle clé de paramètres
`sanctuary:sanctuary_natural` (`SanctuarySpawn.NATURAL_SETTINGS`). Elle conserve
la densité relevée, les 384 blocs de hauteur et le ciel de lalpha.3. La classe
`NaturalHydrology` et son application sont séparées de `SurfaceHydrology` et
`HydrologyRuntime` : le code, les paramètres `sanctuary:sanctuary_hydrology` et
le biome `sanctuary:starter_forest_hydrology` de lalpha.4 restent inchangés.
Les clés alpha.2 et alpha.3 restent également disponibles avec leur comportement
antérieur. **Ces changements concernent uniquement les nouveaux mondes** : aucune
conversion de sauvegarde, régénération de chunk ou expansion nest effectuée.
### Dépôts et eau de surface
Le plan recherche des étangs, de petits lacs et un cours deau calme dans le
relief naturel. Lexcavation maximale passe à **quatre blocs**, pour une eau de
**trois blocs de profondeur** au plus. Les bassins gardent un fond et des parois
naturels pleins ; aucune digue nest ajoutée et aucune chambre souterraine nest
remplie. Le cours deau reste à niveau constant : il ne modélise pas un débit
ni une pente continue vers laval.
Les plages sont des nappes irrégulières qui suivent les faibles variations du
relief autour des plans deau. Elles peuvent s’étendre sur plusieurs blocs,
se rapprocher dune autre poche deau et retrouver progressivement lherbe ou
la pierre. Les matériaux varient entre sable, gravier, pierre et terre enherbée ;
largile peut apparaître dans les fonds. Le traitement dune terrasse sèche
retire au plus **deux blocs** du relief existant.
Chaque dépôt remplace **trois à cinq couches** de terrain déjà plein et garde
**deux couches naturelles de support** en dessous. Ce support permet notamment
au sable et au gravier de rester en place après les mises à jour de gravité.
Les veines de gravier, disques de sédiments et magma sous-marin vanilla ne sont
pas réintroduits dans ces nouveaux biomes. Les autres décorations rocheuses et
les minerais peuvent encore remplacer la pierre ; ils ne remplacent pas les
couches de sable, gravier ou argile. Les rives compatibles peuvent porter de la
canne à sucre après la végétation. Les troncs couchés vanilla peuvent occuper
quelques blocs de la surface dun étang ; les contrôles distinguent cette
décoration de leau restante et vérifient leur fond ou leau sous les troncs,
les sources voisines et la rétention du bassin.
### Sources rocheuses et cascades
Le plan peut retenir jusqu’à **trois sources** dans des niches rocheuses
naturelles, séparées des dépôts modifiés. Une source remplace un seul bloc de
roche, sous un toit, sur un fond et entre trois côtés déjà pleins. Son unique
sortie horizontale est naturellement ouverte sur une chute dau moins huit
blocs. Le générateur ne construit ni pylône, ni corniche, ni tunnel pour obtenir
ce résultat.
Seul le bloc source est généré. Les ticks de fluide de Minecraft forment la
cascade et ses éventuels passages sur des paliers. Ces écoulements peuvent
descendre **jusque dans le vide**, conformément au comportement demandé. Cette
exception concerne les sources déclarées. Le trajet calculé dans la roche nue
est une prévision : les arbres générés ensuite peuvent dévier une chute. Les
diagnostics vérifient donc les connexions réelles de chaque bloc deau vers une
source, dans lemprise des chunks inspectés et de leur halo. Ils échouent si de
leau touche une frontière non inspectée ou si un bassin perd sa coque. Les
déviations par rapport à la prévision restent mesurées dans le rapport. Le
comportement des fluides reste celui de Minecraft, y compris lorsque les joueurs
modifient une rive.
### Petite réserve de lave
`StarterLavaDeposit` recherche au plus une niche de **18 blocs sources** dans
la roche existante, accessible par un passage sec de deux blocs de haut depuis
une corniche naturelle. Le fond garde trois blocs pleins, les parois deux et
la couverture huit ; ces supports sont vérifiés avant les écritures. La niche
reste éloignée des eaux et de leurs parcours. Elle fournit de la lave prélevable
pour les premières productions et un portail du Nether. Si aucun site ne
convient, le plan reste vide : aucun bassin artificiel nest ajouté dans le vide.
Les tests vérifient laccès praticable, la rétention de la lave et labsence
dincendie après simulation.
### Biomes et minerais
Le biome source `sanctuary:temperate_island` utilise deux champs de bruit
initialisés par la seed pour répartir cinq variantes horizontales. Une rive et
la roche sous elle restent dans la même variante :
| Identifiant | Variante |
| --- | --- |
| `sanctuary:temperate_forest` | Forêt dominante |
| `sanctuary:temperate_plains` | Clairières et plaines tempérées |
| `sanctuary:temperate_birch_forest` | Forêt de bouleaux |
| `sanctuary:temperate_flower_forest` | Forêt fleurie |
| `sanctuary:temperate_dappled_forest` | Dappled Forest, peupliers et végétation associée |
Dappled Forest est un biome vanilla introduit dans
[Minecraft 26.3 Snapshot 1](https://www.minecraft.net/en-us/article/minecraft-26-3-snapshot-1).
Cette variante reprend ses données de **26.3-pre-2**, avec une humidité légèrement
relevée pour Sanctuary. Elle najoute ni dépendance externe ni structure de camp.
La palette tempérée ne constitue pas encore le catalogue TerraMix ni le champ
climatique des futurs continents.
Les nouveaux biomes remplacent les filons vanilla de métaux par de petites
features `sanctuary:island_ore_*`, à des altitudes adaptées à l’île relevée :
| Ressource | Taille du filon | Tentatives par chunk | Plage Y |
| --- | --- | --- | --- |
| Charbon | 6 | 2 | 96288 |
| Fer | 4 | 2 | 88248 |
| Cuivre | 4 | 1 | 96248 |
| Or | 4 | 1 | 104224 |
| Redstone | 4 | 1 | 96224 |
| Lapis | 3 | Une tentative, chance 1/2 | 112232 |
| Diamant | 3 | Une tentative, chance 1/4 | 104208 |
Ces paramètres décrivent des tentatives, pas des quantités promises : le filon
doit rencontrer la roche existante, et sa forme dépend du hasard de génération.
La sélection des biomes et des eaux ne cherche aucun stock cible. Un relevé
partiel reste un relevé partiel ; la présence des sept ressources sur une seed
de référence ne garantit pas leur présence sur toutes les seeds.
La validation de lalpha.5 est suivie dans [Validation](testing.md). Ses diagnostics
sont conçus pour inspecter les blocs décorés, les profils des dépôts, les
jointures de chunks, les biomes et les ressources, puis simuler réellement les
fluides dans les chunks des sites. Les cartes et profils PNG montrent des
lectures de blocs, pas un rendu Minecraft. La descente des
cascades est contrôlée après activation effective des chunks qui les simulent.
## Prairies et corniches — génération v6
Lalpha.6 utilise `sanctuary:sanctuary_layered` et le biome source
`sanctuary:layered_island`. Les formes et le ciel restent ceux de la génération
relevée. Les anciennes classes, biomes et règles de matériaux restent disponibles
pour les sauvegardes alpha.2 à alpha.5 ; seul le preset de création choisit v6.
### Une palette qui varie aussi en hauteur
Les cinq variantes partagent leurs teintes dherbe et de feuillage. Les champs
de climat liés à la seed déforment de larges bandes daltitude, autour de Y154
et Y214 : prairies et prairies fleuries sur le dessus, bosquets et corniches
moussues plus bas. Des zones rocheuses traversent ces bandes et remplacent la
couverture de terre par de la pierre sur le relief existant. Les prairies gardent
quelques arbres épars et leur végétation vanilla. Dappled Forest nappartient
plus à cette palette ; son utilisation sur de futurs continents reste à développer.
| Identifiant | Ambiance |
| --- | --- |
| `sanctuary:layered_meadow` | Prairie tempérée |
| `sanctuary:layered_flower_meadow` | Prairie fleurie |
| `sanctuary:layered_lower_grove` | Bosquet inférieur |
| `sanctuary:layered_mossy_terraces` | Corniches moussues |
| `sanctuary:layered_rocky_terraces` | Affleurements rocheux |
`LowerTerraceVegetationFeature` parcourt aussi les sols naturels sous les
surplombs, au moins huit blocs sous une surface supérieure. Elle demande trois
blocs pleins de support et deux blocs libres au-dessus. Elle peut remplacer la
surface existante par de la mousse et planter fougères, fleurs ou petits arbustes
lorsque leurs règles de survie le permettent. Les cellules hydrologiques, les
sources, les parcours deau prévus et les supports de lave sont protégés.
Il ny a ni corniche construite dans lair ni ajout d’éclairage artificiel.
### Bassins et sources de paroi
`LayeredHydrology` conserve les petits bassins et les dépôts épais, avec une
plus grande présence de pierre. Le court `STREAM` à niveau constant de lalpha.5
nest pas repris. Cette version retient uniquement les étangs et les petits
lacs ; elle ne cherche pas de grande rivière. Le parcours traversant avec
source, cascade et bassin reste un ticket distinct, à démontrer sur le relief
Minecraft réel. Aucun chenal de secours nest creusé.
La profondeur reste limitée à trois blocs et lincision à quatre. Le plan
vérifie le fond et les rives avant dappliquer ses écritures, dans les nouveaux
chunks seulement. Le sable, le gravier, largile et la roche suivent les rives.
Des niches de source sont également recherchées dans les strates inférieures,
et pas seulement près de la surface la plus haute. Leurs chutes sont produites
par les ticks de Minecraft à partir dun seul bloc source. Le post-traitement
des nouveaux chunks libère uniquement leur ouverture, déjà vide dans la densité
naturelle, pour quun lichen ou une plante ne bouche pas la source. Les positions
sont marquées dans la file vanilla pendant la génération, puis consommées une
seule fois. Un rechargement ne dégage pas une bouche modifiée par un joueur.
Aucune chute nest préremplie et aucun nouveau passage nest creusé dans la roche.
Les parcours, les écoulements et la végétation réellement observés, ainsi que
les limites du modèle, sont consignés dans [Validation](testing.md).
## Apparition commune
Une injection limitée à la création initiale cherche du sol naturel depuis
@@ -142,7 +369,9 @@ 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`, `sanctuary:sanctuary_natural` ou
`sanctuary:sanctuary_layered`.
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.6
pack_version=0.1.0-alpha.6
maven_group=fr.koka.sanctuary
+52
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,56 @@ 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 }
tasks.register('naturalHydrologySmoke', JavaExec) {
group = 'verification'
description = 'Check broad shores, supported sediments and natural rock springs.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.NaturalHydrologySmoke'
}
tasks.named('check') { dependsOn('naturalHydrologySmoke') }
tasks.register('starterLavaSmoke', JavaExec) {
group = 'verification'
description = 'Check a small contained lava niche in naturally supported rock.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.StarterLavaDepositSmoke'
}
tasks.named('check') { dependsOn('starterLavaSmoke') }
// Alpha.6 has separate contracts so saved alpha.5 worlds keep their generation.
tasks.register('layeredHydrologySmoke', JavaExec) {
group = 'verification'
description = 'Check retained basins, broad rock shores and lower wall springs.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.LayeredHydrologySmoke'
}
tasks.named('check') { dependsOn('layeredHydrologySmoke') }
tasks.register('layeredEcologySmoke', JavaExec) {
group = 'verification'
description = 'Check altitude biome variation and naturally supported lower terraces.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.LayeredEcologySmoke'
}
tasks.named('check') { dependsOn('layeredEcologySmoke') }
@@ -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());
}
}
}
@@ -1,6 +1,10 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime;
import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import fr.koka.sanctuary.worldgen.IslandShape;
import fr.koka.sanctuary.worldgen.MainIslandDensity;
import fr.koka.sanctuary.SanctuaryMod;
@@ -23,6 +27,23 @@ import net.minecraft.world.level.storage.LevelData;
public final class SanctuaryWorldGameTests {
public static LevelData.RespawnData initialSpawn;
@GameTest(maxTicks = 3000)
public void generatedSurfaceWaterRemainsContained(GameTestHelper helper) throws java.io.IOException {
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.start(helper);
else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.start(helper);
else HydrologyDiagnostics.start(helper);
}
@GameTest(maxTicks = 200)
public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) {
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.verifyLegacyIsolation(helper);
else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.verifyLegacyIsolation(helper);
else HydrologyDiagnostics.verifyLegacyIsolation(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void generatedIslandAndInitialSpawn(GameTestHelper helper) {
ServerLevel level = helper.getLevel();
@@ -93,8 +114,24 @@ public final class SanctuaryWorldGameTests {
for (int[] coordinate : new int[][]{{0, 0}, {-8, 0}, {8, 0}, {0, -8}, {0, 8}}) {
ChunkAccess chunk = helper.getLevel().getChunkSource().getChunk(
coordinate[0], coordinate[1], ChunkStatus.FULL, true);
helper.assertTrue(chunk != null && chunk.getSection(0).hasOnlyAir(),
"The fully generated island must leave Y0..15 empty, without a floor cut at the dimension bound");
helper.assertTrue(chunk != null, "The lower-margin test needs a fully generated chunk");
if (chunk.getSection(0).hasOnlyAir()) continue;
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(NaturalHydrologyRuntime.enabled(generator) || LayeredHydrologyRuntime.enabled(generator),
"The legacy island must leave its lower margin entirely empty");
for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) {
for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) {
for (int y = 0; y < 16; y++) {
BlockPos p = new BlockPos(x, y, z);
var state = chunk.getBlockState(p);
helper.assertTrue(state.isAir() || state.getFluidState().is(FluidTags.WATER)
&& (LayeredHydrologyRuntime.enabled(generator)
? LayeredHydrologyDiagnostics.isWithinSpringInspectionFootprint(LayeredHydrologyRuntime.layeredPlan(helper.getLevel()), x, z)
: NaturalHydrologyDiagnostics.isWithinSpringInspectionFootprint(NaturalHydrologyRuntime.naturalPlan(helper.getLevel()), x, z)),
"The lower margin has no artificial floor; water must lie in the finite spring inspection footprint, with provenance checked by the full fluid diagnostic: " + p);
}
}
}
}
helper.succeed();
}
@@ -1,6 +1,12 @@
package fr.koka.sanctuary;
import fr.koka.sanctuary.worldgen.MainIslandDensity;
import fr.koka.sanctuary.worldgen.NaturalShoreSugarCaneFeature;
import fr.koka.sanctuary.worldgen.TemperateIslandBiomeSource;
import fr.koka.sanctuary.worldgen.LayeredIslandBiomeSource;
import fr.koka.sanctuary.worldgen.LayeredShoreSugarCaneFeature;
import fr.koka.sanctuary.worldgen.LowerTerraceVegetationFeature;
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
import net.fabricmc.api.ModInitializer;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -19,6 +25,12 @@ 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);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("natural_shore_sugar_cane"), NaturalShoreSugarCaneFeature.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("temperate_island"), TemperateIslandBiomeSource.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("layered_shore_sugar_cane"), LayeredShoreSugarCaneFeature.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("lower_terrace_vegetation"), LowerTerraceVegetationFeature.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("layered_island"), LayeredIslandBiomeSource.CODEC);
LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available.");
}
}
@@ -0,0 +1,29 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.worldgen.LayeredSpringOutlets;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.LevelChunk;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
@Mixin(LevelChunk.class)
public abstract class LayeredSpringOutletMixin {
@Unique
private LayeredSpringOutlets.Pending sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY;
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
private void sanctuary$captureSpringOutlets(ServerLevel level, CallbackInfo callback) {
sanctuary$pendingSpringOutlets = LayeredSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
}
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
private void sanctuary$finishSpringOutlets(ServerLevel level, CallbackInfo callback) {
var pending = sanctuary$pendingSpringOutlets;
sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY;
LayeredSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
}
}
@@ -0,0 +1,47 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime;
import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime;
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 (LayeredHydrologyRuntime.enabled(generator)) {
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
LayeredHydrologyRuntime.apply(generator, randomState, generated);
return generated;
}));
} else if (NaturalHydrologyRuntime.enabled(generator)) {
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
NaturalHydrologyRuntime.apply(generator, randomState, generated);
return generated;
}));
} else 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));
}
}
@@ -0,0 +1,20 @@
package fr.koka.sanctuary.worldgen;
/** Broad, seed-warped vertical bands; none changes terrain density or guarantees a resource. */
public final class LayeredBiomePalette {
public enum Kind { MEADOW, FLOWER_MEADOW, LOWER_GROVE, MOSSY_TERRACES, ROCKY_TERRACES }
private LayeredBiomePalette() {}
public static Kind select(int blockY, float variation, float moisture) {
double elevation = blockY - Math.clamp(variation, -1.0F, 1.0F) * 28
- Math.clamp(moisture, -1.0F, 1.0F) * 12;
if (elevation < 154) return moisture < -0.30F ? Kind.ROCKY_TERRACES : Kind.MOSSY_TERRACES;
if (elevation < 214) {
if (variation > 0.34F) return Kind.ROCKY_TERRACES;
return moisture > 0.02F ? Kind.LOWER_GROVE : Kind.MOSSY_TERRACES;
}
if (variation > 0.22F) return Kind.ROCKY_TERRACES;
return moisture > -0.12F ? Kind.FLOWER_MEADOW : Kind.MEADOW;
}
}
@@ -0,0 +1,633 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayDeque;
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;
/** Alpha.6 surface basins, rocky shores and layered wall springs.
* This version is separate from NaturalHydrology so previously created worlds keep their original terrain.
* Results retain no chunks, sampler, random state or Minecraft objects. */
public final class LayeredHydrology {
public static final int RADIUS = 192;
public static final int MAX_CARVE = 4;
public static final int MAX_DEPTH = 3;
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 LayeredHydrology() {}
@FunctionalInterface
public interface Sampler { float sample(int x, int y, int z); }
public enum Kind { POND, LAKE }
public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS }
public record Point(int x, int z) {}
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
public record Position(int x, int y, int z) {}
public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
public record Spring(long id, Position source, Position outlet, List<Position> flowPath, FlowBounds flowBounds) {
public Spring { flowPath = List.copyOf(flowPath); }
}
/** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below.
* Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry
* terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
ShoreMaterial material, long featureId, int sedimentDepth) {
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 List<Spring> springs;
private final Map<Long, List<Spring>> springChunks;
private final Set<Position> springFlow;
private final int sampledColumns;
private final long densitySamples;
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow, 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);
this.springs = List.copyOf(springs);
this.springFlow = Set.copyOf(springFlow);
Map<Long, List<Spring>> groupedSprings = new HashMap<>();
for (Spring spring : springs) groupedSprings.computeIfAbsent(
key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring);
groupedSprings.replaceAll((ignored, values) -> List.copyOf(values));
springChunks = Map.copyOf(groupedSprings);
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 List<Spring> springs() { return springs; }
public List<Spring> springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); }
/** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */
public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); }
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 final List<Spring> springs = new ArrayList<>();
private final Set<Position> springFlow = new HashSet<>();
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 ? 190 + (int) (random(seed, center.x(), center.z()) * 130)
: 55 + (int) (random(seed + 1, center.x(), center.z()) * 55);
// Prefer an existing low spot; a shallow cut is the fallback on level terrain.
for (int level : new int[]{column.top() + 1, column.top(), column.top() - 1}) {
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;
}
}
addSedimentSheets();
addLowerWallSprings();
addSprings();
return new Plan(cells, features, springs, springFlow, 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) - 4;
}
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()) > 28 || 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) * 4.5 + field(x + 71, z - 93, 21, 107) * 3.5);
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()) > 32
|| !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 = wetMaterial(p.x(), p.z(), edge);
int sedimentDepth = sedimentDepth(p.x(), p.z());
if (column.solidBottom() > bed - sedimentDepth - 1) return null;
result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()),
material, id, sedimentDepth));
}
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 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 record ShoreNode(int x, int z, int level, long feature, double cost) {}
/** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby
* pocket can share the same sheet; the result is not a one-block material ring. */
private void addSedimentSheets() {
Comparator<ShoreNode> order = Comparator.comparingDouble(ShoreNode::cost)
.thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature);
PriorityQueue<ShoreNode> queue = new PriorityQueue<>(order);
Map<Long, Double> best = new HashMap<>();
for (Cell cell : cells.values()) if (cell.hasWater()) {
queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0));
best.put(key(cell.x(), cell.z()), 0.0);
}
while (!queue.isEmpty()) {
ShoreNode node = queue.remove();
if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue;
Column column = column(node.x(), node.z());
Cell existing = cells.get(key(node.x(), node.z()));
if (existing == null) {
double extent = 9 + field(node.x(), node.z(), 25, 131) * 10;
if (node.cost() > extent) continue;
int delta = column.top() - node.level();
if (delta < 0 || delta > 5) continue;
// Low shore: waterline, then one-block steps. Cut at most two existing blocks;
// far deposits keep the original landform and feather back into grass/stone.
int terrace = node.level() + (int) Math.floor(node.cost() / 4.5);
int bed = Math.max(node.level(), Math.max(column.top() - 2, Math.min(column.top(), terrace)));
int depth = sedimentDepth(node.x(), node.z());
if (column.solidBottom() > bed - depth - 1) continue;
boolean safe = true;
for (int[] d : CARDINALS) {
Cell next = cells.get(key(node.x() + d[0], node.z() + d[1]));
if (next != null && next.hasWater() && bed < next.waterY()) safe = false;
}
if (!safe) continue;
ShoreMaterial material = dryMaterial(node.x(), node.z(), node.cost() / extent);
cells.put(key(node.x(), node.z()), new Cell(node.x(), node.z(), -1, bed,
column.top(), material, node.feature(), depth));
}
for (int[] d : NEIGHBORS) {
int x = node.x() + d[0], z = node.z() + d[1];
if (!inside(x, z, RADIUS - 2)) continue;
Cell water = cells.get(key(x, z));
if (water != null && water.hasWater()) continue;
Column next = column(x, z);
if (next.top() < node.level() || next.top() > node.level() + 5) continue;
double cost = node.cost() + Math.hypot(d[0], d[1])
* (0.7 + Math.abs(next.top() - column.top()) * 0.32 + field(x, z, 12, 211) * 0.6);
if (cost > 19 || cost >= best.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
best.put(key(x, z), cost);
queue.add(new ShoreNode(x, z, node.level(), node.feature(), cost));
}
}
}
private int sedimentDepth(int x, int z) { return 3 + (int) (field(x, z, 15, 319) * 2.999); }
private ShoreMaterial wetMaterial(int x, int z, boolean edge) {
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
if (geology < 0.46) return ShoreMaterial.STONE;
if (geology < 0.65) return ShoreMaterial.GRAVEL;
if (!edge && geology > 0.78) return ShoreMaterial.CLAY;
return ShoreMaterial.SAND;
}
private ShoreMaterial dryMaterial(int x, int z, double distance) {
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
double grass = field(x + 103, z - 137, 19, 521);
if (grass > 0.62 || distance > 0.60 + grass * 0.65) return ShoreMaterial.GRASS;
if (geology < 0.46) return ShoreMaterial.STONE;
if (geology < 0.65) return ShoreMaterial.GRAVEL;
return ShoreMaterial.SAND;
}
private record Cliff(int x, int z, int dx, int dz, double score) {}
private record FlowNode(Position position, int reach) {}
private record Flow(Set<Position> volume, List<Position> path, FlowBounds bounds) {}
/** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces
* one rock block under an existing roof. Its outlet is already air over an eight-block drop;
* no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */
private void addSprings() {
List<Cliff> cliffs = new ArrayList<>();
for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) {
if (!inside(x, z, 264)) continue;
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
if (top < 88) continue;
for (int[] d : CARDINALS) {
int nx = x + d[0] * GRID, nz = z + d[1] * GRID;
int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
if (top - lower < 16) continue;
cliffs.add(new Cliff(x, z, d[0], d[1],
Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3));
}
}
cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed()
.thenComparingInt(Cliff::x).thenComparingInt(Cliff::z)
.thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz));
int attempts = 0;
for (Cliff cliff : cliffs) {
if (springs.size() >= 3 || attempts++ >= 160) break;
boolean placed = false;
for (int tangent : new int[]{0, -2, 2, -4, 4}) {
for (int along = 0; along < GRID; along++) {
int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent;
int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent;
if (!inside(x, z, 266)) continue;
int ox = x + cliff.dx(), oz = z + cliff.dz();
Column rock = column(x, z), outletColumn = column(ox, oz);
if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue;
// Five blocks below the highest natural surface is below the soil cap.
int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8));
for (int y = rock.top() - 5; y >= floor; y -= 3) {
Position source = new Position(x, y, z), outlet = new Position(ox, y, oz);
if (nearModifiedGround(source, 32) || !separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz())) continue;
Flow flow = traceSpring(source, outlet);
if (flow == null || flow.bounds().minY() > y - 12) continue;
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
springFlow.addAll(flow.volume());
placed = true;
break;
}
if (placed) break;
}
if (placed) break;
}
}
}
private boolean nearModifiedGround(Position source, int distance) {
for (Cell cell : cells.values()) {
int dy = Math.max(Math.max(cell.bedY() - cell.sedimentDepth() - source.y(), 0),
source.y() - cell.carveTop());
double horizontal = Math.hypot(source.x() - cell.x(), source.z() - cell.z());
if (Math.hypot(horizontal, dy) < distance) return true;
}
return false;
}
private boolean separatedSpring(Position source) {
for (Spring spring : springs) {
double horizontal = Math.hypot(source.x() - spring.source().x(), source.z() - spring.source().z());
int dy = Math.abs(source.y() - spring.source().y());
if (Math.hypot(horizontal, dy) < 64 || dy < 12) return false;
}
return true;
}
private record Wall(int x, int y, int z, int dx, int dz, double score) {}
/** Probe a few exposed faces below the highest surface as well. This finds overhangs and
* lower strata that a highest-column heightmap cannot represent, without inventing a ledge. */
private void addLowerWallSprings() {
List<Wall> candidates = new ArrayList<>();
for (int x = -240; x <= 240; x += 16) for (int z = -240; z <= 240; z += 16) {
if (!inside(x, z, 240)) continue;
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
for (int y = top - 32; y >= 64; y -= 16) {
if (!solid(x, y, z)) continue;
for (int[] direction : CARDINALS) {
if (solid(x + direction[0] * 4, y, z + direction[1] * 4)) continue;
candidates.add(new Wall(x, y, z, direction[0], direction[1],
random(seed + 1217 + y, x + direction[0], z + direction[1]) * 3
+ Math.min(100, top - y) * 0.015));
}
}
}
candidates.sort(Comparator.comparingDouble(Wall::score).reversed()
.thenComparingInt(Wall::x).thenComparingInt(Wall::z).thenComparingInt(Wall::y)
.thenComparingInt(Wall::dx).thenComparingInt(Wall::dz));
int attempts = 0;
for (Wall candidate : candidates) {
if (springs.size() >= 2 || attempts++ >= 96) break;
boolean placed = false;
for (int tangent : new int[]{0, -2, 2}) {
for (int along = 0; along < 4; along++) {
int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent;
int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent;
for (int dy : new int[]{0, 3, -3, 6, -6}) {
int y = candidate.y() + dy;
if (y < 56 || column(x, z).top() - y < 24) continue;
Position source = new Position(x, y, z);
if (nearModifiedGround(source, 32) || !separatedSpring(source)
|| !springNiche(source, candidate.dx(), candidate.dz())) continue;
Position outlet = new Position(x + candidate.dx(), y, z + candidate.dz());
Flow flow = traceSpring(source, outlet);
if (flow == null || flow.bounds().minY() > y - 12) continue;
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
springFlow.addAll(flow.volume());
placed = true;
break;
}
if (placed) break;
}
if (placed) break;
}
}
}
private boolean springNiche(Position source, int dx, int dz) {
for (int y = source.y() - 3; y <= source.y() + 1; y++) {
if (!solid(source.x(), y, source.z())) return false;
}
for (int[] d : CARDINALS) {
if (d[0] == dx && d[1] == dz) continue;
if (!solid(source.x() + d[0], source.y(), source.z() + d[1])) return false;
}
for (int y = source.y(); y >= source.y() - 8; y--) {
if (solid(source.x() + dx, y, source.z() + dz)) return false;
}
return true;
}
/** Predict drainage through undecorated rock to select springs and inspection bounds.
* Descend through air first, then explore seven horizontal steps on a shelf. Later trees
* can divert the actual fluid: this prediction is not a bound on decorated-world physics.
* Integration tests trace actual water back to its source within the inspected chunk halo. */
private Flow traceSpring(Position source, Position outlet) {
Map<Position, Integer> reach = new HashMap<>();
Map<Position, Position> parent = new HashMap<>();
ArrayDeque<FlowNode> queue = new ArrayDeque<>();
reach.put(source, 7);
queue.add(new FlowNode(source, 7));
Position lowest = source;
while (!queue.isEmpty()) {
FlowNode node = queue.removeFirst();
Position at = node.position();
if (node.reach() < reach.getOrDefault(at, -1)) continue;
if (at.y() < lowest.y()) lowest = at;
if (reach.size() > 24000) return null;
if (at.y() <= 0) continue;
Position below = new Position(at.x(), at.y() - 1, at.z());
if (!flowSolid(below, source)) {
enqueueFlow(below, 7, at, queue, reach, parent);
continue;
}
if (node.reach() <= 0) continue;
for (int[] d : CARDINALS) {
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
if (flowSolid(next, source)) continue;
enqueueFlow(next, node.reach() - 1, at, queue, reach, parent);
}
}
if (!reach.containsKey(outlet)) return null;
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY;
int minZ = source.z(), maxZ = minZ;
for (Position position : reach.keySet()) {
minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x());
minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y());
minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z());
}
List<Position> path = new ArrayList<>();
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
Collections.reverse(path);
return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
}
private boolean flowSolid(Position at, Position source) {
if (at.equals(source)) return false;
Cell cell = cells.get(key(at.x(), at.z()));
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
return solid(at.x(), at.y(), at.z());
}
private static void enqueueFlow(Position next, int available, Position from, ArrayDeque<FlowNode> queue,
Map<Position, Integer> reach, Map<Position, Position> parent) {
if (available <= reach.getOrDefault(next, -1)) return;
if (!reach.containsKey(next)) parent.put(next, from);
reach.put(next, available);
queue.addLast(new FlowNode(next, available));
}
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) { return field(x, z, 8, 31); }
private double field(int x, int z, int scale, long salt) {
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx;
double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, 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);
}
}
@@ -0,0 +1,148 @@
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;
/** Alpha.6 only: immutable plans cached by world state, applied to fresh terrain before decoration. */
public final class LayeredHydrologyRuntime {
private static final Map<RandomState, LayeredHydrology.Plan> PLANS =
Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<RandomState, LayeredLavaDeposit.Plan> LAVA_PLANS =
Collections.synchronizedMap(new WeakHashMap<>());
private LayeredHydrologyRuntime() {}
public static boolean enabled(NoiseBasedChunkGenerator generator) {
return generator.generatorSettings().is(SanctuarySpawn.LAYERED_SETTINGS);
}
public static LayeredHydrology.Plan layeredPlan(ServerLevel level) {
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
level.getChunkSource().randomState());
}
public static LayeredHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
if (!enabled(generator)) throw new IllegalArgumentException("Layered 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 = LayeredHydrology.create(state.seed(),
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
SanctuaryMod.LOGGER.info("Sanctuary layered hydrology: seed {}, {} features, {} columns, {} springs, {} ms",
state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(),
(System.nanoTime() - start) / 1_000_000);
return plan;
});
}
public static LayeredLavaDeposit.Plan lavaPlan(ServerLevel level) {
return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
level.getChunkSource().randomState());
}
public static LayeredLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) {
// Acquire the water plan before the lava cache lock; the water planner never acquires it.
var water = plan(generator, randomState);
return LAVA_PLANS.computeIfAbsent(randomState, state -> {
long start = System.nanoTime();
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
var lava = LayeredLavaDeposit.create(state.seed(),
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water);
SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms",
state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000);
return lava;
});
}
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 plan = plan(generator, randomState);
var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z());
var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z());
var outlets = plan.springs().stream().map(LayeredHydrology.Spring::outlet)
.filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList();
var lava = lavaPlan(generator, randomState);
var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z());
var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z());
if (cells.isEmpty() && springs.isEmpty() && outlets.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
// Validate all replacements and two intact support layers before modifying this chunk.
for (var cell : cells) {
for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed());
}
}
for (var spring : springs) {
var source = spring.source();
requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed());
}
for (var support : lavaSupports) {
requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed());
if (chunk.getBlockState(pos).ignitedByLava()) {
throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos);
}
}
for (var cell : lavaCells) {
requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed());
}
for (var cell : cells) {
for (int depth = 0; depth < cell.sedimentDepth(); depth++) {
pos.set(cell.x(), cell.bedY() - depth, cell.z());
BlockState material = switch (cell.material()) {
case SAND -> Blocks.SAND.defaultBlockState();
case GRAVEL -> Blocks.GRAVEL.defaultBlockState();
case CLAY -> Blocks.CLAY.defaultBlockState();
case STONE -> Blocks.STONE.defaultBlockState();
case GRASS -> (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState();
};
chunk.setBlockState(pos, material, 0);
}
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
pos.set(cell.x(), y, cell.z());
chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY()
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
}
}
for (var spring : springs) {
var source = spring.source();
pos.set(source.x(), source.y(), source.z());
chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0);
// Only the source is generated. Vanilla fluid ticks create the descending waterfall.
chunk.markPosForPostProcessing(pos);
}
for (var cell : lavaCells) {
pos.set(cell.x(), cell.y(), cell.z());
chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0);
if (cell.lava()) chunk.markPosForPostProcessing(pos);
}
// This existing vanilla queue is consumed after neighbouring chunks finish decoration.
// Mark the outlet in its own chunk, including when its source belongs to a neighbour.
for (var outlet : outlets) {
pos.set(outlet.x(), outlet.y(), outlet.z());
chunk.markPosForPostProcessing(pos);
}
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
Heightmap.Types.OCEAN_FLOOR_WG));
}
private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) {
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
throw new IllegalStateException("Layered hydrology differs from terrain at " + pos + " for seed " + seed);
}
}
}
@@ -0,0 +1,73 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
/** Alpha.6 palette: flowered upper meadows and distinct humid lower terraces. */
public final class LayeredIslandBiomeSource extends BiomeSource {
public static final ResourceKey<Biome> MEADOW = key("layered_meadow");
public static final ResourceKey<Biome> FLOWER_MEADOW = key("layered_flower_meadow");
public static final ResourceKey<Biome> LOWER_GROVE = key("layered_lower_grove");
public static final ResourceKey<Biome> MOSSY_TERRACES = key("layered_mossy_terraces");
public static final ResourceKey<Biome> ROCKY_TERRACES = key("layered_rocky_terraces");
public static final MapCodec<LayeredIslandBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
Biome.CODEC.fieldOf("meadow").forGetter(source -> source.meadow),
Biome.CODEC.fieldOf("flower_meadow").forGetter(source -> source.flowerMeadow),
Biome.CODEC.fieldOf("lower_grove").forGetter(source -> source.lowerGrove),
Biome.CODEC.fieldOf("mossy_terraces").forGetter(source -> source.mossyTerraces),
Biome.CODEC.fieldOf("rocky_terraces").forGetter(source -> source.rockyTerraces)
).apply(instance, LayeredIslandBiomeSource::new));
private final Holder<Biome> meadow;
private final Holder<Biome> flowerMeadow;
private final Holder<Biome> lowerGrove;
private final Holder<Biome> mossyTerraces;
private final Holder<Biome> rockyTerraces;
public LayeredIslandBiomeSource(Holder<Biome> meadow, Holder<Biome> flowerMeadow,
Holder<Biome> lowerGrove, Holder<Biome> mossyTerraces, Holder<Biome> rockyTerraces) {
this.meadow = meadow;
this.flowerMeadow = flowerMeadow;
this.lowerGrove = lowerGrove;
this.mossyTerraces = mossyTerraces;
this.rockyTerraces = rockyTerraces;
}
@Override
protected MapCodec<LayeredIslandBiomeSource> codec() { return CODEC; }
@Override
protected Stream<Holder<Biome>> collectPossibleBiomes() {
return Stream.of(meadow, flowerMeadow, lowerGrove, mossyTerraces, rockyTerraces);
}
@Override
public BiomeResolver createResolver(Climate.Sampler sampler) {
return (quartX, quartY, quartZ) -> {
int x = quartX * 4, y = quartY * 4, z = quartZ * 4;
float variation = sampler.temperature().sampleValue(x, 0, z);
float moisture = sampler.humidity().sampleValue(x, 0, z);
return switch (LayeredBiomePalette.select(y, variation, moisture)) {
case MEADOW -> meadow;
case FLOWER_MEADOW -> flowerMeadow;
case LOWER_GROVE -> lowerGrove;
case MOSSY_TERRACES -> mossyTerraces;
case ROCKY_TERRACES -> rockyTerraces;
};
};
}
private static ResourceKey<Biome> key(String path) {
return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path));
}
}
@@ -0,0 +1,223 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** One small, covered lava pocket in existing rock, reached from an existing outdoor ledge.
* No solid blocks are added, and unsuitable seeds are allowed to produce no deposit. */
public final class LayeredLavaDeposit {
public static final int RADIUS = 192;
public static final int WATER_CLEARANCE = 32;
public static final int LAVA_VOLUME = 18;
private static final int ENTRY_DISTANCE = 8;
private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private static final Comparator<Position> POSITION_ORDER = Comparator.comparingInt(Position::x)
.thenComparingInt(Position::z).thenComparingInt(Position::y);
private static final Comparator<Cell> CELL_ORDER = Comparator.comparingInt(Cell::x)
.thenComparingInt(Cell::z).thenComparingInt(Cell::y);
private LayeredLavaDeposit() {}
public record Position(int x, int y, int z) {}
/** lava=false removes existing rock to open the two-block-high chamber or approach. */
public record Cell(int x, int y, int z, boolean lava) {}
public static final class Plan {
private final List<Cell> cells;
private final List<Cell> lavaCells;
private final List<Position> supports;
private final Optional<Position> access;
private final Map<Long, List<Cell>> chunks;
private final Map<Long, List<Position>> supportChunks;
private Plan(List<Cell> cells, Set<Position> supports, Position access) {
this.cells = cells.stream().sorted(CELL_ORDER).toList();
lavaCells = this.cells.stream().filter(Cell::lava).toList();
this.supports = supports.stream().sorted(POSITION_ORDER).toList();
this.access = Optional.ofNullable(access);
Map<Long, List<Cell>> byChunk = new HashMap<>();
for (Cell cell : this.cells) byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4),
ignored -> new ArrayList<>()).add(cell);
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
chunks = Map.copyOf(byChunk);
Map<Long, List<Position>> bySupportChunk = new HashMap<>();
for (Position support : this.supports) bySupportChunk.computeIfAbsent(key(support.x() >> 4, support.z() >> 4),
ignored -> new ArrayList<>()).add(support);
bySupportChunk.replaceAll((ignored, values) -> List.copyOf(values));
supportChunks = Map.copyOf(bySupportChunk);
}
public List<Cell> cells() { return cells; }
public List<Cell> lavaCells() { return lavaCells; }
public List<Position> supports() { return supports; }
/** Player feet on the existing ledge; this column was naturally open to the sky. */
public Optional<Position> access() { return access; }
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
public List<Position> supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); }
}
public static Plan create(long seed, LayeredHydrology.Sampler sampler, LayeredHydrology.Plan hydrology) {
return new Search(seed, sampler, hydrology).find();
}
private record Candidate(int x, int z, int dx, int dz, double score) {}
private static final class Search {
private final long seed;
private final LayeredHydrology.Sampler sampler;
private final LayeredHydrology.Plan hydrology;
private final Map<Long, Integer> heights = new HashMap<>();
Search(long seed, LayeredHydrology.Sampler sampler, LayeredHydrology.Plan hydrology) {
this.seed = seed;
this.sampler = sampler;
this.hydrology = hydrology;
}
Plan find() {
List<Candidate> candidates = new ArrayList<>();
for (int x = -184; x <= 184; x += 8) for (int z = -184; z <= 184; z += 8) {
if (!inside(x, z, RADIUS - 8)) continue;
int ledge = top(x, z);
if (ledge < 64 || ledge > 300) continue;
for (int[] direction : DIRECTIONS) {
int cx = x - direction[0] * ENTRY_DISTANCE, cz = z - direction[1] * ENTRY_DISTANCE;
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) continue;
int rise = top(cx, cz) - ledge;
if (rise < 12) continue;
candidates.add(new Candidate(x, z, direction[0], direction[1],
random(seed, cx, cz) + Math.min(rise, 40) * 0.015));
}
}
candidates.sort(Comparator.comparingDouble(Candidate::score).reversed()
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)
.thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz));
int attempts = 0;
for (Candidate candidate : candidates) {
if (attempts++ >= 192) break;
// Refine the coarse edge, including a ledge lying between two eight-block samples.
for (int along = 0; along < 8; along++) for (int tangent : new int[]{0, -3, 3}) {
int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent;
int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent;
Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz());
if (plan != null) return plan;
}
}
return new Plan(List.of(), Set.of(), null);
}
private Plan tryNiche(int entryX, int entryZ, int dx, int dz) {
int cx = entryX - dx * ENTRY_DISTANCE, cz = entryZ - dz * ENTRY_DISTANCE;
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) return null;
int level = top(entryX, entryZ);
if (level < 64 || level > 300 || top(cx, cz) < level + 12) return null;
// The player can discover and enter from an actual outdoor ledge, not a sealed cave.
for (int y = level + 1; y < 384; y++) if (solid(entryX, y, entryZ)) return null;
for (int y = level - 2; y <= level; y++) if (!solid(entryX, y, entryZ)) return null;
List<Cell> cells = new ArrayList<>();
Set<Position> changed = new HashSet<>();
Set<Position> supports = new HashSet<>();
for (int x = cx - 1; x <= cx + 1; x++) for (int z = cz - 1; z <= cz + 1; z++) {
for (int y = level - 1; y <= level + 2; y++) {
if (!solid(x, y, z)) return null;
add(cells, changed, x, y, z, y <= level);
}
}
// A dry lip separates the ledge from the pool. Only the air above it is opened.
for (int distance = 2; distance < ENTRY_DISTANCE; distance++) {
int x = cx + dx * distance, z = cz + dz * distance;
for (int y = level + 1; y <= level + 2; y++) {
if (solid(x, y, z)) add(cells, changed, x, y, z, false);
}
}
// Every carved column must lie under eight continuous natural rock blocks. This
// excludes soil-cap excavation at the entrance as well as exposed surface lava.
Map<Long, Integer> highest = new HashMap<>();
for (Cell cell : cells) highest.merge(key(cell.x(), cell.z()), cell.y(), Math::max);
for (var column : highest.entrySet()) {
int x = (int) (column.getKey() >> 32), z = (int) (long) column.getKey();
for (int y = column.getValue() + 1; y <= column.getValue() + 8; y++) {
if (!requireRock(supports, x, y, z)) return null;
}
}
// Three intact blocks under the pool, two-block-thick side walls, and a solid chamber
// surround prevent escape through an adjacent cave. No part of this shell is created.
for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 3; z <= cz + 3; z++) {
for (int y = level - 4; y <= level + 2; y++) {
if (changed.contains(new Position(x, y, z))) continue;
if (!requireRock(supports, x, y, z)) return null;
}
}
// The natural roof also covers a conservative envelope around local lava fire spread.
// Its thickness keeps later surface vegetation well above the lava chamber.
for (int x = cx - 4; x <= cx + 4; x++) for (int z = cz - 4; z <= cz + 4; z++) {
for (int y = level + 3; y <= level + 10; y++) {
if (!requireRock(supports, x, y, z)) return null;
}
}
for (int distance = 2; distance <= ENTRY_DISTANCE; distance++) {
int x = cx + dx * distance, z = cz + dz * distance;
for (int y = level - 2; y <= level; y++) {
if (!requireRock(supports, x, y, z)) return null;
}
}
supports.removeAll(changed);
return new Plan(cells, supports, new Position(entryX, level + 1, entryZ));
}
private boolean requireRock(Set<Position> supports, int x, int y, int z) {
if (!solid(x, y, z)) return false;
supports.add(new Position(x, y, z));
return true;
}
private boolean clearOfWater(int x, int z) {
// The extra ten blocks include both the entrance and the complete protected shell.
int clearance = WATER_CLEARANCE + 10;
for (var cell : hydrology.cells()) {
long dx = x - cell.x(), dz = z - cell.z();
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
}
for (var spring : hydrology.springs()) {
var bounds = spring.flowBounds();
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
}
return true;
}
private int top(int x, int z) {
return heights.computeIfAbsent(key(x, z), ignored -> {
for (int y = 383; y >= 32; y -= 4) {
if (!solid(x, y, z)) continue;
for (int exact = Math.min(383, y + 3); exact > y; exact--) if (solid(x, exact, z)) return exact;
return y;
}
return -1;
});
}
private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; }
}
private static void add(List<Cell> cells, Set<Position> changed, int x, int y, int z, boolean lava) {
if (changed.add(new Position(x, y, z))) cells.add(new Cell(x, y, z, lava));
}
private static boolean inside(int x, int z, int radius) { return (long) x * x + (long) z * z < (long) radius * radius; }
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
private static double random(long seed, int x, int z) {
long value = seed ^ key(x, z) ^ 0x1A7A5EEDL;
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
}
}
@@ -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 LayeredShoreSugarCaneFeature implements Feature {
public static final MapCodec<LayeredShoreSugarCaneFeature> CODEC = MapCodec.unit(LayeredShoreSugarCaneFeature::new);
@Override
public MapCodec<LayeredShoreSugarCaneFeature> codec() {
return CODEC;
}
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !LayeredHydrologyRuntime.enabled(noise)) return false;
var plan = LayeredHydrologyRuntime.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,94 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.VegetationBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.material.Fluids;
/** One-time alpha.6 outlet finishing, using vanilla's persisted and consumed generation queue. */
public final class LayeredSpringOutlets {
public record Pending(List<LayeredHydrology.Position> sources, List<LayeredHydrology.Position> outlets) {
public static final Pending EMPTY = new Pending(List.of(), List.of());
public Pending { sources = List.copyOf(sources); outlets = List.copyOf(outlets); }
public boolean isEmpty() { return sources.isEmpty() && outlets.isEmpty(); }
}
private LayeredSpringOutlets() {}
/** Called before vanilla consumes the queue; a fully processed/reloaded chunk has no work. */
public static Pending capturePending(ServerLevel level, LevelChunk chunk) {
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|| !LayeredHydrologyRuntime.enabled(noise)) return Pending.EMPTY;
boolean queued = false;
for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) {
queued = true;
break;
}
if (!queued) return Pending.EMPTY;
var plan = LayeredHydrologyRuntime.plan(noise, level.getChunkSource().randomState());
List<LayeredHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
for (var spring : plan.springs()) {
if (markedHere(chunk, spring.source())) sources.add(spring.source());
if (markedHere(chunk, spring.outlet())) outlets.add(spring.outlet());
}
return new Pending(sources, outlets);
}
/** All neighbouring FEATURES have finished before a chunk reaches this stage. */
public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) {
if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|| !LayeredHydrologyRuntime.enabled(noise)) return;
var random = level.getChunkSource().randomState();
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
for (var outlet : pending.outlets()) {
if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet);
if (random.sampleBlockValueUncached(density, outlet.x(), outlet.y(), outlet.z()) > 0) {
throw new IllegalStateException("A spring outlet must already be natural air: " + outlet);
}
BlockPos pos = blockPos(outlet);
BlockState current = chunk.getBlockState(pos);
if (removableDecoration(current)) {
// Only this declared air cell is reopened. Neighbour notification wakes the
// adjacent source even if its own chunk was post-processed first.
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
for (var source : pending.sources()) {
if (!owns(chunk, source)) throw new IllegalArgumentException("A source belongs to another chunk: " + source);
BlockPos pos = blockPos(source);
BlockState current = chunk.getBlockState(pos);
if (current.is(Blocks.WATER) && current.getFluidState().isSource()) {
level.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level));
}
}
}
private static boolean markedHere(LevelChunk chunk, LayeredHydrology.Position position) {
if (!owns(chunk, position)) return false;
var queue = chunk.getPostProcessing()[chunk.getSectionIndex(position.y())];
return queue != null && queue.contains(ProtoChunk.packOffsetCoordinates(blockPos(position)));
}
private static boolean owns(LevelChunk chunk, LayeredHydrology.Position position) {
return (position.x() >> 4) == chunk.getPos().x() && (position.z() >> 4) == chunk.getPos().z();
}
private static BlockPos blockPos(LayeredHydrology.Position position) {
return new BlockPos(position.x(), position.y(), position.z());
}
private static boolean removableDecoration(BlockState state) {
if (state.hasBlockEntity()) return false;
return state.is(Blocks.GLOW_LICHEN) || state.is(Blocks.VINE) || state.is(Blocks.MOSS_CARPET)
|| state.getBlock() instanceof VegetationBlock || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS);
}
}
@@ -0,0 +1,21 @@
package fr.koka.sanctuary.worldgen;
/** Geometry checks shared by lower-ledge decoration and density diagnostics. */
public final class LowerTerraceEcology {
@FunctionalInterface
public interface Sampler { float sample(int x, int y, int z); }
private LowerTerraceEcology() {}
public static int highestNaturalSurface(Sampler sampler, int x, int z) {
for (int y = 383; y >= 24; y--) if (sampler.sample(x, y, z) > 0) return y;
return -1;
}
/** A lower natural floor: three solid blocks, headroom, and real terrain at least eight blocks above. */
public static boolean isLowerTerrace(Sampler sampler, int x, int bedY, int z, int highestNaturalSurface) {
if (bedY < 32 || bedY > 312 || highestNaturalSurface < bedY + 8) return false;
for (int y = bedY - 2; y <= bedY; y++) if (sampler.sample(x, y, z) <= 0) return false;
return sampler.sample(x, bedY + 1, z) <= 0 && sampler.sample(x, bedY + 2, z) <= 0;
}
}
@@ -0,0 +1,126 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import java.util.HashSet;
import java.util.Set;
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.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.feature.Feature;
/** Decorates actual lower natural floors after the ordinary highest-surface vegetation.
* Each chunk owns its columns; planned water, sediments, lava and their supports are excluded. */
public final class LowerTerraceVegetationFeature implements Feature {
public static final MapCodec<LowerTerraceVegetationFeature> CODEC = MapCodec.unit(LowerTerraceVegetationFeature::new);
@Override
public MapCodec<LowerTerraceVegetationFeature> codec() { return CODEC; }
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !LayeredHydrologyRuntime.enabled(noise)) return false;
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
if (Math.abs((long) minX) > 288 || Math.abs((long) minZ) > 288) return false;
var state = level.getLevel().getChunkSource().randomState();
var water = LayeredHydrologyRuntime.plan(noise, state);
var lava = LayeredHydrologyRuntime.lavaPlan(noise, state);
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
LowerTerraceEcology.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
var climate = state.createClimateSampler(SamplerContext.EMPTY_UNCACHED);
Set<Long> lavaColumns = new HashSet<>();
for (var cell : lava.cells()) protectColumn(lavaColumns, cell.x(), cell.z());
for (var support : lava.supports()) protectColumn(lavaColumns, support.x(), support.z());
boolean changed = false;
BlockPos.MutableBlockPos floor = new BlockPos.MutableBlockPos();
for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) {
if (blockedColumn(water, lavaColumns, x, z)) continue;
// A coherent moisture patch controls coverage, rather than an alternating block grid.
float growth = climate.humidity().sampleValue(x + 317, 0, z - 191);
if (growth < -0.30F) continue;
int highest = -1, terraces = 0;
for (int y = 319; y >= 32; y--) {
floor.set(x, y, z);
BlockState ground = level.getBlockState(floor);
if (!replaceableGround(ground)) continue;
if (highest < 0) {
if (sampler.sample(x, y, z) > 0) highest = y;
continue;
}
if (y > highest - 8) continue;
BlockPos plantPos = floor.above();
if (!level.getBlockState(plantPos).isAir() || !level.getBlockState(plantPos.above()).isAir()) continue;
if (!LowerTerraceEcology.isLowerTerrace(sampler, x, y, z, highest)) continue;
if (!ground.isCollisionShapeFullBlock(level, floor)
|| !level.getBlockState(floor.below()).isCollisionShapeFullBlock(level, floor.below())
|| !level.getBlockState(floor.below(2)).isCollisionShapeFullBlock(level, floor.below(2))) continue;
var biome = level.getBiome(plantPos);
if (biome.is(LayeredIslandBiomeSource.ROCKY_TERRACES)) continue;
boolean mossy = biome.is(LayeredIslandBiomeSource.MOSSY_TERRACES);
// Moss survives shaded ledges without needing artificial lighting. Existing grass
// is retained outside the moss biome; no supporting terrain is added to air.
if (mossy || !ground.is(Blocks.GRASS_BLOCK)) {
setBlock(level, floor, Blocks.MOSS_BLOCK.defaultBlockState());
changed = true;
}
if (random.nextInt(5) < 2) {
BlockState plant = plant(mossy, random.nextInt(10));
if (plant.canSurvive(level, plantPos)) {
setBlock(level, plantPos, plant);
changed = true;
}
}
if (++terraces >= 4) break;
}
}
return changed;
}
private static BlockState plant(boolean mossy, int choice) {
if (mossy) return switch (choice) {
case 0 -> Blocks.FLOWERING_AZALEA.defaultBlockState();
case 1 -> Blocks.AZALEA.defaultBlockState();
case 2, 3 -> Blocks.FERN.defaultBlockState();
default -> Blocks.MOSS_CARPET.defaultBlockState();
};
return switch (choice) {
case 0 -> Blocks.OXEYE_DAISY.defaultBlockState();
case 1 -> Blocks.CORNFLOWER.defaultBlockState();
case 2 -> Blocks.AZURE_BLUET.defaultBlockState();
case 3 -> Blocks.DANDELION.defaultBlockState();
case 4 -> Blocks.BUSH.defaultBlockState();
default -> Blocks.SHORT_GRASS.defaultBlockState();
};
}
private static boolean replaceableGround(BlockState state) {
return state.is(Blocks.STONE) || state.is(Blocks.GRANITE) || state.is(Blocks.DIORITE)
|| state.is(Blocks.ANDESITE) || state.is(Blocks.TUFF) || state.is(Blocks.DIRT)
|| state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.ROOTED_DIRT) || state.is(Blocks.COARSE_DIRT)
|| state.is(Blocks.MOSS_BLOCK);
}
private static boolean blockedColumn(LayeredHydrology.Plan water, Set<Long> lavaColumns, int x, int z) {
if (lavaColumns.contains(key(x, z))) return true;
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
if (water.cellAt(x + dx, z + dz) != null) return true;
}
for (var spring : water.springs()) if (inside(spring.flowBounds(), x, z)) return true;
return false;
}
private static boolean inside(LayeredHydrology.FlowBounds bounds, int x, int z) {
return x >= bounds.minX() - 2 && x <= bounds.maxX() + 2
&& z >= bounds.minZ() - 2 && z <= bounds.maxZ() + 2;
}
private static void protectColumn(Set<Long> columns, int x, int z) {
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) columns.add(key(x + dx, z + dz));
}
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
}
@@ -0,0 +1,698 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayDeque;
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;
/** Alpha.5 surface water, broad natural sediment sheets and occasional exposed rock springs.
* This version is separate from SurfaceHydrology so previously created worlds keep alpha.4 terrain.
* Results retain no chunks, sampler, random state or Minecraft objects. */
public final class NaturalHydrology {
public static final int RADIUS = 192;
public static final int MAX_CARVE = 4;
public static final int MAX_DEPTH = 3;
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 NaturalHydrology() {}
@FunctionalInterface
public interface Sampler { float sample(int x, int y, int z); }
public enum Kind { POND, LAKE, STREAM }
public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS }
public record Point(int x, int z) {}
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
public record Position(int x, int y, int z) {}
public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
public record Spring(long id, Position source, Position outlet, List<Position> flowPath, FlowBounds flowBounds) {
public Spring { flowPath = List.copyOf(flowPath); }
}
/** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below.
* Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry
* terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
ShoreMaterial material, long featureId, int sedimentDepth) {
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 List<Spring> springs;
private final Map<Long, List<Spring>> springChunks;
private final Set<Position> springFlow;
private final int sampledColumns;
private final long densitySamples;
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow, 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);
this.springs = List.copyOf(springs);
this.springFlow = Set.copyOf(springFlow);
Map<Long, List<Spring>> groupedSprings = new HashMap<>();
for (Spring spring : springs) groupedSprings.computeIfAbsent(
key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring);
groupedSprings.replaceAll((ignored, values) -> List.copyOf(values));
springChunks = Map.copyOf(groupedSprings);
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 List<Spring> springs() { return springs; }
public List<Spring> springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); }
/** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */
public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); }
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 final List<Spring> springs = new ArrayList<>();
private final Set<Position> springFlow = new HashSet<>();
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 ? 190 + (int) (random(seed, center.x(), center.z()) * 130)
: 55 + (int) (random(seed + 1, center.x(), center.z()) * 55);
// Prefer an existing low spot; a shallow cut is the fallback on level terrain.
for (int level : new int[]{column.top() + 1, column.top(), column.top() - 1}) {
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);
addSedimentSheets();
addSprings();
return new Plan(cells, features, springs, springFlow, 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) - 4;
}
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()) > 28 || 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) * 4.5 + field(x + 71, z - 93, 21, 107) * 3.5);
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()) > 32
|| !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 = wetMaterial(p.x(), p.z(), edge);
int sedimentDepth = sedimentDepth(p.x(), p.z());
if (column.solidBottom() > bed - sedimentDepth - 1) return null;
result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()),
material, id, sedimentDepth));
}
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(), column.top() - 1}) {
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 record ShoreNode(int x, int z, int level, long feature, double cost) {}
/** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby
* pocket can share the same sheet; the result is not a one-block material ring. */
private void addSedimentSheets() {
Comparator<ShoreNode> order = Comparator.comparingDouble(ShoreNode::cost)
.thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature);
PriorityQueue<ShoreNode> queue = new PriorityQueue<>(order);
Map<Long, Double> best = new HashMap<>();
for (Cell cell : cells.values()) if (cell.hasWater()) {
queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0));
best.put(key(cell.x(), cell.z()), 0.0);
}
while (!queue.isEmpty()) {
ShoreNode node = queue.remove();
if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue;
Column column = column(node.x(), node.z());
Cell existing = cells.get(key(node.x(), node.z()));
if (existing == null) {
double extent = 9 + field(node.x(), node.z(), 25, 131) * 10;
if (node.cost() > extent) continue;
int delta = column.top() - node.level();
if (delta < 0 || delta > 5) continue;
// Low shore: waterline, then one-block steps. Cut at most two existing blocks;
// far deposits keep the original landform and feather back into grass/stone.
int terrace = node.level() + (int) Math.floor(node.cost() / 4.5);
int bed = Math.max(node.level(), Math.max(column.top() - 2, Math.min(column.top(), terrace)));
int depth = sedimentDepth(node.x(), node.z());
if (column.solidBottom() > bed - depth - 1) continue;
boolean safe = true;
for (int[] d : CARDINALS) {
Cell next = cells.get(key(node.x() + d[0], node.z() + d[1]));
if (next != null && next.hasWater() && bed < next.waterY()) safe = false;
}
if (!safe) continue;
ShoreMaterial material = dryMaterial(node.x(), node.z(), node.cost() / extent);
cells.put(key(node.x(), node.z()), new Cell(node.x(), node.z(), -1, bed,
column.top(), material, node.feature(), depth));
}
for (int[] d : NEIGHBORS) {
int x = node.x() + d[0], z = node.z() + d[1];
if (!inside(x, z, RADIUS - 2)) continue;
Cell water = cells.get(key(x, z));
if (water != null && water.hasWater()) continue;
Column next = column(x, z);
if (next.top() < node.level() || next.top() > node.level() + 5) continue;
double cost = node.cost() + Math.hypot(d[0], d[1])
* (0.7 + Math.abs(next.top() - column.top()) * 0.32 + field(x, z, 12, 211) * 0.6);
if (cost > 19 || cost >= best.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
best.put(key(x, z), cost);
queue.add(new ShoreNode(x, z, node.level(), node.feature(), cost));
}
}
}
private int sedimentDepth(int x, int z) { return 3 + (int) (field(x, z, 15, 319) * 2.999); }
private ShoreMaterial wetMaterial(int x, int z, boolean edge) {
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
if (geology < 0.29) return ShoreMaterial.STONE;
if (geology < 0.50) return ShoreMaterial.GRAVEL;
if (!edge && geology > 0.78) return ShoreMaterial.CLAY;
return ShoreMaterial.SAND;
}
private ShoreMaterial dryMaterial(int x, int z, double distance) {
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
double grass = field(x + 103, z - 137, 19, 521);
if (grass > 0.68 || distance > 0.60 + grass * 0.65) return ShoreMaterial.GRASS;
if (geology < 0.29) return ShoreMaterial.STONE;
if (geology < 0.50) return ShoreMaterial.GRAVEL;
return ShoreMaterial.SAND;
}
private record Cliff(int x, int z, int dx, int dz, double score) {}
private record FlowNode(Position position, int reach) {}
private record Flow(Set<Position> volume, List<Position> path, FlowBounds bounds) {}
/** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces
* one rock block under an existing roof. Its outlet is already air over an eight-block drop;
* no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */
private void addSprings() {
List<Cliff> cliffs = new ArrayList<>();
for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) {
if (!inside(x, z, 264)) continue;
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
if (top < 88) continue;
for (int[] d : CARDINALS) {
int nx = x + d[0] * GRID, nz = z + d[1] * GRID;
int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
if (top - lower < 16) continue;
cliffs.add(new Cliff(x, z, d[0], d[1],
Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3));
}
}
cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed()
.thenComparingInt(Cliff::x).thenComparingInt(Cliff::z)
.thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz));
int attempts = 0;
for (Cliff cliff : cliffs) {
if (springs.size() >= 3 || attempts++ >= 160) break;
boolean placed = false;
for (int tangent : new int[]{0, -2, 2, -4, 4}) {
for (int along = 0; along < GRID; along++) {
int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent;
int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent;
if (!inside(x, z, 266) || nearModifiedGround(x, z, 32)) continue;
int ox = x + cliff.dx(), oz = z + cliff.dz();
Column rock = column(x, z), outletColumn = column(ox, oz);
if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue;
// Five blocks below the highest natural surface is below the soil cap.
int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8));
for (int y = rock.top() - 5; y >= floor; y -= 3) {
Position source = new Position(x, y, z), outlet = new Position(ox, y, oz);
if (!separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz())) continue;
Flow flow = traceSpring(source, outlet);
if (flow == null || flow.bounds().minY() > y - 12) continue;
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
springFlow.addAll(flow.volume());
placed = true;
break;
}
if (placed) break;
}
if (placed) break;
}
}
}
private boolean nearModifiedGround(int x, int z, int distance) {
for (Cell cell : cells.values()) if (Math.hypot(x - cell.x(), z - cell.z()) < distance) return true;
return false;
}
private boolean separatedSpring(Position source) {
for (Spring spring : springs) {
if (Math.hypot(source.x() - spring.source().x(), source.z() - spring.source().z()) < 64
|| Math.abs(source.y() - spring.source().y()) < 12) return false;
}
return true;
}
private boolean springNiche(Position source, int dx, int dz) {
for (int y = source.y() - 3; y <= source.y() + 1; y++) {
if (!solid(source.x(), y, source.z())) return false;
}
for (int[] d : CARDINALS) {
if (d[0] == dx && d[1] == dz) continue;
if (!solid(source.x() + d[0], source.y(), source.z() + d[1])) return false;
}
for (int y = source.y(); y >= source.y() - 8; y--) {
if (solid(source.x() + dx, y, source.z() + dz)) return false;
}
return true;
}
/** Predict drainage through undecorated rock to select springs and inspection bounds.
* Descend through air first, then explore seven horizontal steps on a shelf. Later trees
* can divert the actual fluid: this prediction is not a bound on decorated-world physics.
* Integration tests trace actual water back to its source within the inspected chunk halo. */
private Flow traceSpring(Position source, Position outlet) {
Map<Position, Integer> reach = new HashMap<>();
Map<Position, Position> parent = new HashMap<>();
ArrayDeque<FlowNode> queue = new ArrayDeque<>();
reach.put(source, 7);
queue.add(new FlowNode(source, 7));
Position lowest = source;
while (!queue.isEmpty()) {
FlowNode node = queue.removeFirst();
Position at = node.position();
if (node.reach() < reach.getOrDefault(at, -1)) continue;
if (at.y() < lowest.y()) lowest = at;
if (reach.size() > 24000) return null;
if (at.y() <= 0) continue;
Position below = new Position(at.x(), at.y() - 1, at.z());
if (!flowSolid(below, source)) {
enqueueFlow(below, 7, at, queue, reach, parent);
continue;
}
if (node.reach() <= 0) continue;
for (int[] d : CARDINALS) {
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
if (flowSolid(next, source)) continue;
enqueueFlow(next, node.reach() - 1, at, queue, reach, parent);
}
}
if (!reach.containsKey(outlet)) return null;
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY;
int minZ = source.z(), maxZ = minZ;
for (Position position : reach.keySet()) {
minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x());
minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y());
minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z());
}
List<Position> path = new ArrayList<>();
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
Collections.reverse(path);
return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
}
private boolean flowSolid(Position at, Position source) {
if (at.equals(source)) return false;
Cell cell = cells.get(key(at.x(), at.z()));
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
return solid(at.x(), at.y(), at.z());
}
private static void enqueueFlow(Position next, int available, Position from, ArrayDeque<FlowNode> queue,
Map<Position, Integer> reach, Map<Position, Position> parent) {
if (available <= reach.getOrDefault(next, -1)) return;
if (!reach.containsKey(next)) parent.put(next, from);
reach.put(next, available);
queue.addLast(new FlowNode(next, available));
}
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) { return field(x, z, 8, 31); }
private double field(int x, int z, int scale, long salt) {
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx;
double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, 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);
}
}
@@ -0,0 +1,140 @@
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;
/** Alpha.5 only: immutable plans cached by world state, applied to fresh terrain before decoration. */
public final class NaturalHydrologyRuntime {
private static final Map<RandomState, NaturalHydrology.Plan> PLANS =
Collections.synchronizedMap(new WeakHashMap<>());
private static final Map<RandomState, StarterLavaDeposit.Plan> LAVA_PLANS =
Collections.synchronizedMap(new WeakHashMap<>());
private NaturalHydrologyRuntime() {}
public static boolean enabled(NoiseBasedChunkGenerator generator) {
return generator.generatorSettings().is(SanctuarySpawn.NATURAL_SETTINGS);
}
public static NaturalHydrology.Plan naturalPlan(ServerLevel level) {
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
level.getChunkSource().randomState());
}
public static NaturalHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
if (!enabled(generator)) throw new IllegalArgumentException("Natural 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 = NaturalHydrology.create(state.seed(),
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
SanctuaryMod.LOGGER.info("Sanctuary natural hydrology: seed {}, {} features, {} columns, {} springs, {} ms",
state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(),
(System.nanoTime() - start) / 1_000_000);
return plan;
});
}
public static StarterLavaDeposit.Plan lavaPlan(ServerLevel level) {
return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
level.getChunkSource().randomState());
}
public static StarterLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) {
// Acquire the water plan before the lava cache lock; the water planner never acquires it.
var water = plan(generator, randomState);
return LAVA_PLANS.computeIfAbsent(randomState, state -> {
long start = System.nanoTime();
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
var lava = StarterLavaDeposit.create(state.seed(),
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water);
SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms",
state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000);
return lava;
});
}
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 plan = plan(generator, randomState);
var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z());
var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z());
var lava = lavaPlan(generator, randomState);
var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z());
var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z());
if (cells.isEmpty() && springs.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return;
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
// Validate all replacements and two intact support layers before modifying this chunk.
for (var cell : cells) {
for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed());
}
}
for (var spring : springs) {
var source = spring.source();
requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed());
}
for (var support : lavaSupports) {
requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed());
if (chunk.getBlockState(pos).ignitedByLava()) {
throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos);
}
}
for (var cell : lavaCells) {
requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed());
}
for (var cell : cells) {
for (int depth = 0; depth < cell.sedimentDepth(); depth++) {
pos.set(cell.x(), cell.bedY() - depth, cell.z());
BlockState material = switch (cell.material()) {
case SAND -> Blocks.SAND.defaultBlockState();
case GRAVEL -> Blocks.GRAVEL.defaultBlockState();
case CLAY -> Blocks.CLAY.defaultBlockState();
case STONE -> Blocks.STONE.defaultBlockState();
case GRASS -> (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState();
};
chunk.setBlockState(pos, material, 0);
}
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
pos.set(cell.x(), y, cell.z());
chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY()
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
}
}
for (var spring : springs) {
var source = spring.source();
pos.set(source.x(), source.y(), source.z());
chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0);
// Only the source is generated. Vanilla fluid ticks create the descending waterfall.
chunk.markPosForPostProcessing(pos);
}
for (var cell : lavaCells) {
pos.set(cell.x(), cell.y(), cell.z());
chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0);
if (cell.lava()) chunk.markPosForPostProcessing(pos);
}
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
Heightmap.Types.OCEAN_FLOOR_WG));
}
private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) {
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
throw new IllegalStateException("Natural hydrology differs from terrain at " + pos + " for seed " + seed);
}
}
}
@@ -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 NaturalShoreSugarCaneFeature implements Feature {
public static final MapCodec<NaturalShoreSugarCaneFeature> CODEC = MapCodec.unit(NaturalShoreSugarCaneFeature::new);
@Override
public MapCodec<NaturalShoreSugarCaneFeature> codec() {
return CODEC;
}
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !NaturalHydrologyRuntime.enabled(noise)) return false;
var plan = NaturalHydrologyRuntime.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;
}
}
@@ -19,13 +19,24 @@ 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"));
public static final ResourceKey<NoiseGeneratorSettings> NATURAL_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_natural"));
public static final ResourceKey<NoiseGeneratorSettings> LAYERED_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_layered"));
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)
|| generator.generatorSettings().is(NATURAL_SETTINGS)
|| generator.generatorSettings().is(LAYERED_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,223 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** One small, covered lava pocket in existing rock, reached from an existing outdoor ledge.
* No solid blocks are added, and unsuitable seeds are allowed to produce no deposit. */
public final class StarterLavaDeposit {
public static final int RADIUS = 192;
public static final int WATER_CLEARANCE = 32;
public static final int LAVA_VOLUME = 18;
private static final int ENTRY_DISTANCE = 8;
private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private static final Comparator<Position> POSITION_ORDER = Comparator.comparingInt(Position::x)
.thenComparingInt(Position::z).thenComparingInt(Position::y);
private static final Comparator<Cell> CELL_ORDER = Comparator.comparingInt(Cell::x)
.thenComparingInt(Cell::z).thenComparingInt(Cell::y);
private StarterLavaDeposit() {}
public record Position(int x, int y, int z) {}
/** lava=false removes existing rock to open the two-block-high chamber or approach. */
public record Cell(int x, int y, int z, boolean lava) {}
public static final class Plan {
private final List<Cell> cells;
private final List<Cell> lavaCells;
private final List<Position> supports;
private final Optional<Position> access;
private final Map<Long, List<Cell>> chunks;
private final Map<Long, List<Position>> supportChunks;
private Plan(List<Cell> cells, Set<Position> supports, Position access) {
this.cells = cells.stream().sorted(CELL_ORDER).toList();
lavaCells = this.cells.stream().filter(Cell::lava).toList();
this.supports = supports.stream().sorted(POSITION_ORDER).toList();
this.access = Optional.ofNullable(access);
Map<Long, List<Cell>> byChunk = new HashMap<>();
for (Cell cell : this.cells) byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4),
ignored -> new ArrayList<>()).add(cell);
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
chunks = Map.copyOf(byChunk);
Map<Long, List<Position>> bySupportChunk = new HashMap<>();
for (Position support : this.supports) bySupportChunk.computeIfAbsent(key(support.x() >> 4, support.z() >> 4),
ignored -> new ArrayList<>()).add(support);
bySupportChunk.replaceAll((ignored, values) -> List.copyOf(values));
supportChunks = Map.copyOf(bySupportChunk);
}
public List<Cell> cells() { return cells; }
public List<Cell> lavaCells() { return lavaCells; }
public List<Position> supports() { return supports; }
/** Player feet on the existing ledge; this column was naturally open to the sky. */
public Optional<Position> access() { return access; }
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
public List<Position> supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); }
}
public static Plan create(long seed, NaturalHydrology.Sampler sampler, NaturalHydrology.Plan hydrology) {
return new Search(seed, sampler, hydrology).find();
}
private record Candidate(int x, int z, int dx, int dz, double score) {}
private static final class Search {
private final long seed;
private final NaturalHydrology.Sampler sampler;
private final NaturalHydrology.Plan hydrology;
private final Map<Long, Integer> heights = new HashMap<>();
Search(long seed, NaturalHydrology.Sampler sampler, NaturalHydrology.Plan hydrology) {
this.seed = seed;
this.sampler = sampler;
this.hydrology = hydrology;
}
Plan find() {
List<Candidate> candidates = new ArrayList<>();
for (int x = -184; x <= 184; x += 8) for (int z = -184; z <= 184; z += 8) {
if (!inside(x, z, RADIUS - 8)) continue;
int ledge = top(x, z);
if (ledge < 64 || ledge > 300) continue;
for (int[] direction : DIRECTIONS) {
int cx = x - direction[0] * ENTRY_DISTANCE, cz = z - direction[1] * ENTRY_DISTANCE;
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) continue;
int rise = top(cx, cz) - ledge;
if (rise < 12) continue;
candidates.add(new Candidate(x, z, direction[0], direction[1],
random(seed, cx, cz) + Math.min(rise, 40) * 0.015));
}
}
candidates.sort(Comparator.comparingDouble(Candidate::score).reversed()
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)
.thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz));
int attempts = 0;
for (Candidate candidate : candidates) {
if (attempts++ >= 192) break;
// Refine the coarse edge, including a ledge lying between two eight-block samples.
for (int along = 0; along < 8; along++) for (int tangent : new int[]{0, -3, 3}) {
int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent;
int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent;
Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz());
if (plan != null) return plan;
}
}
return new Plan(List.of(), Set.of(), null);
}
private Plan tryNiche(int entryX, int entryZ, int dx, int dz) {
int cx = entryX - dx * ENTRY_DISTANCE, cz = entryZ - dz * ENTRY_DISTANCE;
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) return null;
int level = top(entryX, entryZ);
if (level < 64 || level > 300 || top(cx, cz) < level + 12) return null;
// The player can discover and enter from an actual outdoor ledge, not a sealed cave.
for (int y = level + 1; y < 384; y++) if (solid(entryX, y, entryZ)) return null;
for (int y = level - 2; y <= level; y++) if (!solid(entryX, y, entryZ)) return null;
List<Cell> cells = new ArrayList<>();
Set<Position> changed = new HashSet<>();
Set<Position> supports = new HashSet<>();
for (int x = cx - 1; x <= cx + 1; x++) for (int z = cz - 1; z <= cz + 1; z++) {
for (int y = level - 1; y <= level + 2; y++) {
if (!solid(x, y, z)) return null;
add(cells, changed, x, y, z, y <= level);
}
}
// A dry lip separates the ledge from the pool. Only the air above it is opened.
for (int distance = 2; distance < ENTRY_DISTANCE; distance++) {
int x = cx + dx * distance, z = cz + dz * distance;
for (int y = level + 1; y <= level + 2; y++) {
if (solid(x, y, z)) add(cells, changed, x, y, z, false);
}
}
// Every carved column must lie under eight continuous natural rock blocks. This
// excludes soil-cap excavation at the entrance as well as exposed surface lava.
Map<Long, Integer> highest = new HashMap<>();
for (Cell cell : cells) highest.merge(key(cell.x(), cell.z()), cell.y(), Math::max);
for (var column : highest.entrySet()) {
int x = (int) (column.getKey() >> 32), z = (int) (long) column.getKey();
for (int y = column.getValue() + 1; y <= column.getValue() + 8; y++) {
if (!requireRock(supports, x, y, z)) return null;
}
}
// Three intact blocks under the pool, two-block-thick side walls, and a solid chamber
// surround prevent escape through an adjacent cave. No part of this shell is created.
for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 3; z <= cz + 3; z++) {
for (int y = level - 4; y <= level + 2; y++) {
if (changed.contains(new Position(x, y, z))) continue;
if (!requireRock(supports, x, y, z)) return null;
}
}
// The natural roof also covers a conservative envelope around local lava fire spread.
// Its thickness keeps later surface vegetation well above the lava chamber.
for (int x = cx - 4; x <= cx + 4; x++) for (int z = cz - 4; z <= cz + 4; z++) {
for (int y = level + 3; y <= level + 10; y++) {
if (!requireRock(supports, x, y, z)) return null;
}
}
for (int distance = 2; distance <= ENTRY_DISTANCE; distance++) {
int x = cx + dx * distance, z = cz + dz * distance;
for (int y = level - 2; y <= level; y++) {
if (!requireRock(supports, x, y, z)) return null;
}
}
supports.removeAll(changed);
return new Plan(cells, supports, new Position(entryX, level + 1, entryZ));
}
private boolean requireRock(Set<Position> supports, int x, int y, int z) {
if (!solid(x, y, z)) return false;
supports.add(new Position(x, y, z));
return true;
}
private boolean clearOfWater(int x, int z) {
// The extra ten blocks include both the entrance and the complete protected shell.
int clearance = WATER_CLEARANCE + 10;
for (var cell : hydrology.cells()) {
long dx = x - cell.x(), dz = z - cell.z();
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
}
for (var spring : hydrology.springs()) {
var bounds = spring.flowBounds();
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
}
return true;
}
private int top(int x, int z) {
return heights.computeIfAbsent(key(x, z), ignored -> {
for (int y = 383; y >= 32; y -= 4) {
if (!solid(x, y, z)) continue;
for (int exact = Math.min(383, y + 3); exact > y; exact--) if (solid(x, exact, z)) return exact;
return y;
}
return -1;
});
}
private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; }
}
private static void add(List<Cell> cells, Set<Position> changed, int x, int y, int z, boolean lava) {
if (changed.add(new Position(x, y, z))) cells.add(new Cell(x, y, z, lava));
}
private static boolean inside(int x, int z, int radius) { return (long) x * x + (long) z * z < (long) radius * radius; }
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
private static double random(long seed, int x, int z) {
long value = seed ^ key(x, z) ^ 0x1A7A5EEDL;
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
}
}
@@ -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);
}
}
@@ -0,0 +1,64 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
/** Horizontal temperate patches sampled from this world's seeded climate router. */
public final class TemperateIslandBiomeSource extends BiomeSource {
public static final MapCodec<TemperateIslandBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
Biome.CODEC.fieldOf("forest").forGetter(source -> source.forest),
Biome.CODEC.fieldOf("plains").forGetter(source -> source.plains),
Biome.CODEC.fieldOf("birch_forest").forGetter(source -> source.birchForest),
Biome.CODEC.fieldOf("flower_forest").forGetter(source -> source.flowerForest),
Biome.CODEC.fieldOf("dappled_forest").forGetter(source -> source.dappledForest)
).apply(instance, TemperateIslandBiomeSource::new));
private final Holder<Biome> forest;
private final Holder<Biome> plains;
private final Holder<Biome> birchForest;
private final Holder<Biome> flowerForest;
private final Holder<Biome> dappledForest;
public TemperateIslandBiomeSource(Holder<Biome> forest, Holder<Biome> plains,
Holder<Biome> birchForest, Holder<Biome> flowerForest, Holder<Biome> dappledForest) {
this.forest = forest;
this.plains = plains;
this.birchForest = birchForest;
this.flowerForest = flowerForest;
this.dappledForest = dappledForest;
}
@Override
protected MapCodec<TemperateIslandBiomeSource> codec() {
return CODEC;
}
@Override
protected Stream<Holder<Biome>> collectPossibleBiomes() {
return Stream.of(forest, plains, birchForest, flowerForest, dappledForest);
}
@Override
public BiomeResolver createResolver(Climate.Sampler sampler) {
// The resolver receives quart coordinates. The two bound density samplers use block
// coordinates, and already carry the world's seed through RandomState. Sampling Y=0
// keeps a shore and the rock beneath it in the same biome, including within overhangs.
return (quartX, quartY, quartZ) -> {
int x = quartX * 4;
int z = quartZ * 4;
float variation = sampler.temperature().sampleValue(x, 0, z);
float moisture = sampler.humidity().sampleValue(x, 0, z);
if (variation > 0.18F) return dappledForest;
if (variation < -0.28F) return plains;
if (moisture > 0.25F) return birchForest;
if (moisture < -0.30F) return flowerForest;
return forest;
};
}
}
@@ -1,4 +1,15 @@
{
"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",
"biome.sanctuary.temperate_forest": "Sanctuary Temperate Forest",
"biome.sanctuary.temperate_plains": "Sanctuary Plains",
"biome.sanctuary.temperate_birch_forest": "Sanctuary Birch Forest",
"biome.sanctuary.temperate_flower_forest": "Sanctuary Flower Forest",
"biome.sanctuary.temperate_dappled_forest": "Sanctuary Dappled Forest",
"biome.sanctuary.layered_meadow": "Highland Meadow",
"biome.sanctuary.layered_flower_meadow": "Flowered Highland Meadow",
"biome.sanctuary.layered_lower_grove": "Ledge Woodland",
"biome.sanctuary.layered_mossy_terraces": "Mossy Ledges",
"biome.sanctuary.layered_rocky_terraces": "Rocky Terraces"
}
@@ -1,4 +1,15 @@
{
"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",
"biome.sanctuary.temperate_forest": "Forêt tempérée de Sanctuary",
"biome.sanctuary.temperate_plains": "Prairies de Sanctuary",
"biome.sanctuary.temperate_birch_forest": "Boulaie de Sanctuary",
"biome.sanctuary.temperate_flower_forest": "Forêt fleurie de Sanctuary",
"biome.sanctuary.temperate_dappled_forest": "Forêt diaprée de Sanctuary",
"biome.sanctuary.layered_meadow": "Prairie des hauteurs",
"biome.sanctuary.layered_flower_meadow": "Prairie fleurie des hauteurs",
"biome.sanctuary.layered_lower_grove": "Sous-bois des corniches",
"biome.sanctuary.layered_mossy_terraces": "Corniches moussues",
"biome.sanctuary.layered_rocky_terraces": "Terrasses rocheuses"
}
@@ -0,0 +1,164 @@
{
"attributes": {
"minecraft:audio/background_music": {
"default": {
"max_delay": 24000,
"min_delay": 12000,
"sound": "minecraft:music.overworld.meadow"
}
},
"minecraft:gameplay/natural_mob_spawns": {
"argument": {
"spawn_costs": {},
"spawns_by_category": {
"ambient": [
{
"type": "minecraft:bat",
"count": 8,
"weight": 10
}
],
"creature": [
{
"type": "minecraft:donkey",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 2,
"min_inclusive": 1
},
"weight": 1
},
{
"type": "minecraft:rabbit",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 6,
"min_inclusive": 2
},
"weight": 2
},
{
"type": "minecraft:sheep",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 4,
"min_inclusive": 2
},
"weight": 2
}
],
"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",
"grass_color": "#88b96b",
"foliage_color": "#79aa61",
"dry_foliage_color": "#b9a766"
},
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:patch_tall_grass_2",
"minecraft:patch_grass_meadow",
"minecraft:flower_meadow",
"minecraft:trees_meadow",
"minecraft:wildflowers_meadow",
"minecraft:flower_flower_forest",
"sanctuary:layered_shore_sugar_cane",
"sanctuary:lower_terrace_vegetation"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,162 @@
{
"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
}
],
"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",
"grass_color": "#88b96b",
"foliage_color": "#79aa61",
"dry_foliage_color": "#b9a766"
},
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:forest_flowers",
"minecraft:wildflowers_birch_forest",
"minecraft:trees_birch",
"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:layered_shore_sugar_cane",
"sanctuary:lower_terrace_vegetation"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,175 @@
{
"attributes": {
"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:horse",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 6,
"min_inclusive": 2
},
"weight": 5
},
{
"type": "minecraft:donkey",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 3,
"min_inclusive": 1
},
"weight": 1
}
],
"monster": [
{
"type": "minecraft:spider",
"count": 4,
"weight": 100
},
{
"type": "minecraft:zombie",
"count": 4,
"weight": 90
},
{
"type": "minecraft:zombie_villager",
"count": 1,
"weight": 5
},
{
"type": "minecraft:zombie_horse",
"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",
"grass_color": "#88b96b",
"foliage_color": "#79aa61",
"dry_foliage_color": "#b9a766"
},
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:patch_tall_grass_2",
"minecraft:patch_grass_meadow",
"minecraft:flower_meadow",
"minecraft:trees_meadow",
"minecraft:wildflowers_meadow",
"minecraft:patch_pumpkin",
"minecraft:patch_sugar_cane",
"minecraft:patch_firefly_bush_near_water",
"sanctuary:layered_shore_sugar_cane",
"sanctuary:lower_terrace_vegetation"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,147 @@
{
"attributes": {
"minecraft:audio/background_music": {
"default": {
"max_delay": 24000,
"min_delay": 12000,
"sound": "minecraft:music.overworld.lush_caves"
}
},
"minecraft:gameplay/natural_mob_spawns": {
"argument": {
"spawn_costs": {},
"spawns_by_category": {
"ambient": [
{
"type": "minecraft:bat",
"count": 8,
"weight": 10
}
],
"axolotls": [
{
"type": "minecraft:axolotl",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 6,
"min_inclusive": 4
},
"weight": 10
}
],
"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
}
],
"water_ambient": [
{
"type": "minecraft:tropical_fish",
"count": 8,
"weight": 25
}
]
}
},
"modifier": "overlay"
},
"minecraft:visual/sky_color": "#79a6ff"
},
"carvers": [],
"downfall": 0.8,
"effects": {
"water_color": "#3f76e4",
"grass_color": "#88b96b",
"foliage_color": "#79aa61",
"dry_foliage_color": "#b9a766"
},
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"sanctuary:layered_shore_sugar_cane",
"sanctuary:lower_terrace_vegetation"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,158 @@
{
"attributes": {
"minecraft:audio/background_music": {
"default": {
"max_delay": 24000,
"min_delay": 12000,
"sound": "minecraft:music.overworld.meadow"
}
},
"minecraft:gameplay/natural_mob_spawns": {
"argument": {
"spawn_costs": {},
"spawns_by_category": {
"ambient": [
{
"type": "minecraft:bat",
"count": 8,
"weight": 10
}
],
"creature": [
{
"type": "minecraft:donkey",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 2,
"min_inclusive": 1
},
"weight": 1
},
{
"type": "minecraft:rabbit",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 6,
"min_inclusive": 2
},
"weight": 2
},
{
"type": "minecraft:sheep",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 4,
"min_inclusive": 2
},
"weight": 2
}
],
"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",
"grass_color": "#88b96b",
"foliage_color": "#79aa61",
"dry_foliage_color": "#b9a766"
},
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"sanctuary:layered_shore_sugar_cane",
"sanctuary:lower_terrace_vegetation"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -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,158 @@
{
"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
}
],
"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": "#7aa5ff"
},
"carvers": [],
"downfall": 0.65,
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:forest_flowers",
"minecraft:wildflowers_birch_forest",
"minecraft:trees_birch",
"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:natural_shore_sugar_cane"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.6
}
@@ -0,0 +1,174 @@
{
"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:rabbit",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 4,
"min_inclusive": 2
},
"weight": 4
},
{
"type": "minecraft:fox",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 4,
"min_inclusive": 2
},
"weight": 4
}
],
"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/fog_color": "#ccd8e2",
"minecraft:visual/sky_color": "#7ca3ff",
"minecraft:visual/water_fog_color": "#375154"
},
"carvers": [],
"downfall": 0.65,
"effects": {
"dry_foliage_color": "#8c3a04",
"foliage_color": "#e68e30",
"grass_color": "#df6827",
"water_color": "#375154"
},
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:trees_dappled_forest",
"minecraft:brown_mushroom_dappled_forest",
"minecraft:patch_red_shrub",
"minecraft:patch_grass_forest",
"sanctuary:natural_shore_sugar_cane"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.6
}
@@ -0,0 +1,165 @@
{
"attributes": {
"minecraft:audio/background_music": {
"default": {
"max_delay": 24000,
"min_delay": 12000,
"sound": "minecraft:music.overworld.flower_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:rabbit",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 3,
"min_inclusive": 2
},
"weight": 4
}
],
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:flower_forest_flowers",
"minecraft:trees_flower_forest",
"minecraft:flower_flower_forest",
"minecraft:patch_grass_badlands",
"minecraft:brown_mushroom_normal",
"minecraft:red_mushroom_normal",
"minecraft:patch_pumpkin",
"minecraft:patch_sugar_cane",
"minecraft:patch_firefly_bush_near_water",
"sanctuary:natural_shore_sugar_cane"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,162 @@
{
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"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:natural_shore_sugar_cane"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.7
}
@@ -0,0 +1,173 @@
{
"attributes": {
"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:horse",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 6,
"min_inclusive": 2
},
"weight": 5
},
{
"type": "minecraft:donkey",
"count": {
"type": "minecraft:uniform",
"max_inclusive": 3,
"min_inclusive": 1
},
"weight": 1
}
],
"monster": [
{
"type": "minecraft:spider",
"count": 4,
"weight": 100
},
{
"type": "minecraft:zombie",
"count": 4,
"weight": 90
},
{
"type": "minecraft:zombie_villager",
"count": 1,
"weight": 5
},
{
"type": "minecraft:zombie_horse",
"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": "#78a7ff"
},
"carvers": [],
"downfall": 0.65,
"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",
"sanctuary:island_ore_coal",
"sanctuary:island_ore_iron",
"sanctuary:island_ore_copper",
"sanctuary:island_ore_gold",
"sanctuary:island_ore_redstone",
"sanctuary:island_ore_lapis",
"sanctuary:island_ore_diamond"
],
[],
[],
[
"minecraft:glow_lichen",
"minecraft:patch_tall_grass_2",
"minecraft:patch_bush",
"minecraft:trees_plains",
"minecraft:flower_plains",
"minecraft:patch_grass_plain",
"minecraft:brown_mushroom_normal",
"minecraft:red_mushroom_normal",
"minecraft:patch_pumpkin",
"minecraft:patch_sugar_cane",
"minecraft:patch_firefly_bush_near_water",
"sanctuary:natural_shore_sugar_cane"
],
[
"minecraft:freeze_top_layer"
]
],
"has_precipitation": true,
"temperature": 0.8
}
@@ -0,0 +1,6 @@
{
"type": "minecraft:noise",
"noise": "sanctuary:temperate_moisture",
"xz_scale": 1.0,
"y_scale": 0.0
}
@@ -0,0 +1,6 @@
{
"type": "minecraft:noise",
"noise": "sanctuary:temperate_variation",
"xz_scale": 1.0,
"y_scale": 0.0
}
@@ -0,0 +1,83 @@
{
"type": "minecraft:ore",
"discard_chance_on_air_exposure": 0.25,
"size": 6,
"targets": [
{
"state": "minecraft:coal_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 2031,
"min_inclusive": 0,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"
}
]
}
]
}
},
{
"state": "minecraft:deepslate_coal_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 8,
"min_inclusive": -2032,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:deepslate_ore_replaceables"
}
]
}
]
}
}
]
}
@@ -0,0 +1,83 @@
{
"type": "minecraft:ore",
"discard_chance_on_air_exposure": 0.25,
"size": 4,
"targets": [
{
"state": "minecraft:copper_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 2031,
"min_inclusive": 0,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"
}
]
}
]
}
},
{
"state": "minecraft:deepslate_copper_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 8,
"min_inclusive": -2032,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:deepslate_ore_replaceables"
}
]
}
]
}
}
]
}
@@ -0,0 +1,83 @@
{
"type": "minecraft:ore",
"discard_chance_on_air_exposure": 0.7,
"size": 3,
"targets": [
{
"state": "minecraft:diamond_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 2031,
"min_inclusive": 0,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"
}
]
}
]
}
},
{
"state": "minecraft:deepslate_diamond_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 8,
"min_inclusive": -2032,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:deepslate_ore_replaceables"
}
]
}
]
}
}
]
}
@@ -0,0 +1,83 @@
{
"type": "minecraft:ore",
"discard_chance_on_air_exposure": 0.4,
"size": 4,
"targets": [
{
"state": "minecraft:gold_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 2031,
"min_inclusive": 0,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"
}
]
}
]
}
},
{
"state": "minecraft:deepslate_gold_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 8,
"min_inclusive": -2032,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:deepslate_ore_replaceables"
}
]
}
]
}
}
]
}
@@ -0,0 +1,83 @@
{
"type": "minecraft:ore",
"discard_chance_on_air_exposure": 0.25,
"size": 4,
"targets": [
{
"state": "minecraft:iron_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 2031,
"min_inclusive": 0,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"
}
]
}
]
}
},
{
"state": "minecraft:deepslate_iron_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 8,
"min_inclusive": -2032,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:deepslate_ore_replaceables"
}
]
}
]
}
}
]
}
@@ -0,0 +1,83 @@
{
"type": "minecraft:ore",
"discard_chance_on_air_exposure": 0.4,
"size": 3,
"targets": [
{
"state": "minecraft:lapis_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 2031,
"min_inclusive": 0,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"
}
]
}
]
}
},
{
"state": "minecraft:deepslate_lapis_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 8,
"min_inclusive": -2032,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:deepslate_ore_replaceables"
}
]
}
]
}
}
]
}
@@ -0,0 +1,83 @@
{
"type": "minecraft:ore",
"discard_chance_on_air_exposure": 0.4,
"size": 4,
"targets": [
{
"state": "minecraft:redstone_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 2031,
"min_inclusive": 0,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:stone_ore_replaceables"
}
]
}
]
}
},
{
"state": "minecraft:deepslate_redstone_ore",
"target": {
"predicate_type": "minecraft:any_of",
"rules": [
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
},
{
"max_inclusive": 8,
"min_inclusive": -2032,
"predicate_type": "minecraft:height_match"
}
]
},
{
"predicate_type": "minecraft:all_of",
"rules": [
{
"predicate_type": "minecraft:not",
"rule": {
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:height_specific_ore_replaceables"
}
},
{
"predicate_type": "minecraft:tag_match",
"tag": "minecraft:deepslate_ore_replaceables"
}
]
}
]
}
}
]
}
@@ -0,0 +1,17 @@
{
"type": "minecraft:sequence",
"sequence": [
{
"type": "minecraft:condition",
"if_true": {
"type": "minecraft:biome",
"biome_is": "sanctuary:layered_rocky_terraces"
},
"then_run": {
"type": "minecraft:block",
"result_state": "minecraft:stone"
}
},
"sanctuary:starter_island"
]
}
@@ -0,0 +1,9 @@
{
"base_octave": -5,
"octave_count": 3,
"amplitude_modifiers": [
1.0,
0.5,
0.25
]
}
@@ -0,0 +1,9 @@
{
"base_octave": -6,
"octave_count": 3,
"amplitude_modifiers": [
1.0,
0.5,
0.25
]
}
@@ -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,23 @@
{
"default_block": "minecraft:stone",
"default_fluid": "minecraft:air",
"disable_mob_generation": false,
"legacy_random_source": true,
"material_rule": "sanctuary:layered_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": "sanctuary:temperate_variation",
"vegetation": "sanctuary:temperate_moisture"
},
"sea_level": -64,
"spawn_target": []
}
@@ -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": "sanctuary:temperate_variation",
"vegetation": "sanctuary:temperate_moisture"
},
"sea_level": -64,
"spawn_target": []
}
@@ -0,0 +1,27 @@
{
"feature": "sanctuary:island_ore_coal",
"placement": [
{
"type": "minecraft:count",
"count": 2
},
{
"type": "minecraft:in_square"
},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:trapezoid",
"min_inclusive": {
"absolute": 96
},
"max_inclusive": {
"absolute": 288
}
}
},
{
"type": "minecraft:biome"
}
]
}
@@ -0,0 +1,27 @@
{
"feature": "sanctuary:island_ore_copper",
"placement": [
{
"type": "minecraft:count",
"count": 1
},
{
"type": "minecraft:in_square"
},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:trapezoid",
"min_inclusive": {
"absolute": 96
},
"max_inclusive": {
"absolute": 248
}
}
},
{
"type": "minecraft:biome"
}
]
}
@@ -0,0 +1,27 @@
{
"feature": "sanctuary:island_ore_diamond",
"placement": [
{
"type": "minecraft:rarity_filter",
"chance": 4
},
{
"type": "minecraft:in_square"
},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:trapezoid",
"min_inclusive": {
"absolute": 104
},
"max_inclusive": {
"absolute": 208
}
}
},
{
"type": "minecraft:biome"
}
]
}
@@ -0,0 +1,27 @@
{
"feature": "sanctuary:island_ore_gold",
"placement": [
{
"type": "minecraft:count",
"count": 1
},
{
"type": "minecraft:in_square"
},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:trapezoid",
"min_inclusive": {
"absolute": 104
},
"max_inclusive": {
"absolute": 224
}
}
},
{
"type": "minecraft:biome"
}
]
}
@@ -0,0 +1,27 @@
{
"feature": "sanctuary:island_ore_iron",
"placement": [
{
"type": "minecraft:count",
"count": 2
},
{
"type": "minecraft:in_square"
},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:trapezoid",
"min_inclusive": {
"absolute": 88
},
"max_inclusive": {
"absolute": 248
}
}
},
{
"type": "minecraft:biome"
}
]
}
@@ -0,0 +1,27 @@
{
"feature": "sanctuary:island_ore_lapis",
"placement": [
{
"type": "minecraft:rarity_filter",
"chance": 2
},
{
"type": "minecraft:in_square"
},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:trapezoid",
"min_inclusive": {
"absolute": 112
},
"max_inclusive": {
"absolute": 232
}
}
},
{
"type": "minecraft:biome"
}
]
}
@@ -0,0 +1,27 @@
{
"feature": "sanctuary:island_ore_redstone",
"placement": [
{
"type": "minecraft:count",
"count": 1
},
{
"type": "minecraft:in_square"
},
{
"type": "minecraft:height_range",
"height": {
"type": "minecraft:trapezoid",
"min_inclusive": {
"absolute": 96
},
"max_inclusive": {
"absolute": 224
}
}
},
{
"type": "minecraft:biome"
}
]
}
@@ -0,0 +1,4 @@
{
"feature": { "type": "sanctuary:layered_shore_sugar_cane" },
"placement": []
}
@@ -0,0 +1,6 @@
{
"feature": {
"type": "sanctuary:lower_terrace_vegetation"
},
"placement": []
}
@@ -0,0 +1,4 @@
{
"feature": { "type": "sanctuary:natural_shore_sugar_cane" },
"placement": []
}
@@ -0,0 +1,6 @@
{
"feature": {
"type": "sanctuary:shore_sugar_cane"
},
"placement": []
}
@@ -5,10 +5,14 @@
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:fixed",
"biome": "sanctuary:starter_forest"
"type": "sanctuary:layered_island",
"meadow": "sanctuary:layered_meadow",
"flower_meadow": "sanctuary:layered_flower_meadow",
"lower_grove": "sanctuary:layered_lower_grove",
"mossy_terraces": "sanctuary:layered_mossy_terraces",
"rocky_terraces": "sanctuary:layered_rocky_terraces"
},
"settings": "sanctuary:sanctuary_384"
"settings": "sanctuary:sanctuary_layered"
}
},
"minecraft:the_end": {
@@ -3,7 +3,9 @@
"package": "fr.koka.sanctuary.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"InitialSpawnMixin"
"InitialSpawnMixin",
"SurfaceHydrologyMixin",
"LayeredSpringOutletMixin"
],
"client": [
"client.SanctuarySkyMixin",
@@ -0,0 +1,56 @@
package fr.koka.sanctuary.worldgen;
import java.util.EnumSet;
/** Lower-layer decoration must find an existing substantial ledge, never manufacture its floor. */
public final class LayeredEcologySmoke {
public static void main(String[] args) {
LowerTerraceEcology.Sampler layered = (x, y, z) ->
(y >= 240 && y <= 248) || (y >= 170 && y <= 190) ? 1 : -1;
int highest = LowerTerraceEcology.highestNaturalSurface(layered, 0, 0);
require(highest == 248, "The highest natural layer must be identified independently of plants");
require(LowerTerraceEcology.isLowerTerrace(layered, 0, 190, 0, highest),
"A thick lower floor with headroom and an upper layer must qualify");
require(!LowerTerraceEcology.isLowerTerrace(layered, 0, highest, 0, highest),
"Ordinary highest-surface vegetation must not be decorated again as a lower terrace");
require(!LowerTerraceEcology.isLowerTerrace(layered, 0, 189, 0, highest),
"A buried block is not an exposed floor");
LowerTerraceEcology.Sampler thin = (x, y, z) -> y < 189 ? -1 : layered.sample(x, y, z);
require(!LowerTerraceEcology.isLowerTerrace(thin, 0, 190, 0, highest),
"A floor thinner than three natural blocks must remain untouched");
LowerTerraceEcology.Sampler perforated = (x, y, z) -> y == 189 ? 0 : layered.sample(x, y, z);
require(!LowerTerraceEcology.isLowerTerrace(perforated, 0, 190, 0, highest),
"Zero-density holes in a floor must not become supports");
LowerTerraceEcology.Sampler lowRoof = (x, y, z) -> y == 192 ? 1 : layered.sample(x, y, z);
require(!LowerTerraceEcology.isLowerTerrace(lowRoof, 0, 190, 0, highest),
"A terrace without two blocks of natural headroom must remain untouched");
LowerTerraceEcology.Sampler single = (x, y, z) -> y >= 170 && y <= 190 ? 1 : -1;
require(!LowerTerraceEcology.isLowerTerrace(single, 0, 190, 0,
LowerTerraceEcology.highestNaturalSurface(single, 0, 0)), "An isolated highest surface is not a lower layer");
require(LowerTerraceEcology.highestNaturalSurface((x, y, z) -> -1, 0, 0) == -1,
"The void must contain no invented surface");
require(!LowerTerraceEcology.isLowerTerrace((x, y, z) -> -1, 0, 100, 0, -1),
"The void must receive no lower-terrace decoration");
var lower = LayeredBiomePalette.select(96, 0, 0.2F);
var middle = LayeredBiomePalette.select(184, 0, 0.2F);
var upper = LayeredBiomePalette.select(260, 0, 0.2F);
require(lower != middle && lower != upper && middle != upper,
"The same horizontal climate must expose distinct biomes on separate layers");
require(upper == LayeredBiomePalette.Kind.FLOWER_MEADOW,
"A moist temperate upper layer must retain a flowered meadow");
require(LayeredBiomePalette.select(220, -0.2F, 0.2F)
!= LayeredBiomePalette.select(220, 0.2F, 0.2F), "Layer boundaries must follow horizontal climate variation");
EnumSet<LayeredBiomePalette.Kind> palette = EnumSet.noneOf(LayeredBiomePalette.Kind.class);
for (int y = 32; y <= 312; y += 4) for (int v = -10; v <= 10; v++) for (int m = -10; m <= 10; m++) {
palette.add(LayeredBiomePalette.select(y, v / 10.0F, m / 10.0F));
}
require(palette.equals(EnumSet.allOf(LayeredBiomePalette.Kind.class)),
"Every declared starter biome must be reachable within the island's vertical bounds");
System.out.println("LayeredEcologySmoke: supported lower floors, headroom, void exclusion, distinct vertical biomes and warped boundaries passed.");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,150 @@
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 LayeredHydrologySmoke {
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
public static void main(String[] args) {
LayeredHydrology.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 = LayeredHydrology.create(0, terrain);
var replay = LayeredHydrology.create(0, terrain);
var changed = LayeredHydrology.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())
&& first.springs().equals(replay.springs()),
"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);
LayeredHydrology.Sampler pierced = (x, y, z) ->
Math.floorMod(x, 37) < 2 && Math.floorMod(z, 41) < 2 ? -0.45F : terrain.sample(x, y, z);
var aroundShafts = LayeredHydrology.create(0, pierced);
require(!aroundShafts.features().isEmpty(), "Safe areas around natural shafts should remain usable");
verify(aroundShafts, pierced);
require(LayeredHydrology.create(0, (x, y, z) -> -1).cells().isEmpty(), "An empty world must not acquire a guaranteed basin");
var roof = LayeredHydrology.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 = LayeredHydrology.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 = LayeredHydrology.create(0, (x, y, z) ->
Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F);
verify(flat, (x, y, z) -> Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F);
require(!flat.springs().isEmpty(), "A natural cliff over the void must exercise rock spring placement");
long wet = flat.cells().stream().filter(LayeredHydrology.Cell::hasWater).count();
require(flat.cells().size() > wet * 2, "Sediment sheets must extend beyond a narrow one-block ring");
require(flat.cells().stream().anyMatch(cell -> cell.material() == LayeredHydrology.ShoreMaterial.STONE),
"Some natural shores should be rocky");
require(flat.cells().stream().anyMatch(cell -> cell.material() == LayeredHydrology.ShoreMaterial.GRASS),
"Some natural shores should retain grassy terrain");
require(flat.cells().stream().anyMatch(cell -> cell.sedimentDepth() == 5), "Some deposits should be five blocks thick");
require(flat.features().stream().allMatch(feature -> feature.path().size() == 1),
"Surface features should be basins rather than forced short channels");
LayeredHydrology.Sampler longSlope = (x, y, z) -> {
int top = 230 - Math.floorDiv(x + 192, 16);
return Math.hypot(x, z) < 235 && y >= 80 && y <= top ? 0.45F : -0.45F;
};
var layered = LayeredHydrology.create(0, longSlope);
verify(layered, longSlope);
require(layered.springs().stream().anyMatch(spring -> spring.source().y()
<= 230 - Math.floorDiv(spring.source().x() + 192, 16) - 24),
"The layered terrain must exercise a spring well below the highest surface");
System.out.println("LayeredHydrologySmoke: closed surface basins, layered rock springs and safe deposits passed.");
}
private static void verify(LayeredHydrology.Plan plan, LayeredHydrology.Sampler terrain) {
Map<String, LayeredHydrology.Cell> byChunks = new HashMap<>();
// Reverse chunk traversal must reconstruct the same cells without generating neighbours.
for (int cx = 18; cx >= -18; cx--) for (int cz = 18; cz >= -18; cz--) {
var chunk = plan.cellsInChunk(cx, cz);
LayeredHydrology.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() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
require(terrain.sample(cell.x(), y, cell.z()) > 0, "The entire sediment layer and two supporting blocks must already exist");
}
require(cell.sedimentDepth() >= 3 && cell.sedimentDepth() <= 5, "Sediment thickness must vary between 3 and 5");
if (!cell.hasWater()) {
require(cell.carveTop() - cell.bedY() >= 0 && cell.carveTop() - cell.bedY() <= 2,
"Dry terraces must remove at most two blocks");
continue;
}
require(cell.waterY() - cell.bedY() >= 1 && cell.waterY() - cell.bedY() <= LayeredHydrology.MAX_DEPTH, "Invalid shallow-water depth");
require(cell.carveTop() - cell.bedY() <= LayeredHydrology.MAX_CARVE, "Excavation exceeds four 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: " + cell + " toward " + x + "," + y + "," + z + " neighbor=" + neighbor);
}
}
}
for (var spring : plan.springs()) {
var source = spring.source();
var outlet = spring.outlet();
require(terrain.sample(source.x(), source.y(), source.z()) > 0, "Spring must replace existing rock");
require(terrain.sample(source.x(), source.y() + 1, source.z()) > 0, "Rock spring must have a natural roof");
require(Math.abs(source.x() - outlet.x()) + Math.abs(source.z() - outlet.z()) == 1
&& source.y() == outlet.y(), "Spring outlet must be adjacent to its source");
for (int[] direction : CARDINALS) {
int x = source.x() + direction[0], z = source.z() + direction[1];
if (x == outlet.x() && z == outlet.z()) continue;
require(terrain.sample(x, source.y(), z) > 0, "A spring niche must have three natural lateral walls");
}
for (int y = source.y() - 3; y < source.y(); y++) {
require(terrain.sample(source.x(), y, source.z()) > 0, "Source needs an intact natural rock floor");
}
for (int y = source.y(); y >= source.y() - 8; y--) {
require(terrain.sample(outlet.x(), y, outlet.z()) <= 0, "Waterfall needs an exposed natural drop");
}
for (var cell : plan.cells()) {
int dy = Math.max(Math.max(cell.bedY() - cell.sedimentDepth() - source.y(), 0), source.y() - cell.carveTop());
require(Math.hypot(Math.hypot(source.x() - cell.x(), source.z() - cell.z()), dy) >= 32,
"Rock springs must stay away from modified terrain in three dimensions");
}
require(spring.flowBounds().minY() <= source.y() - 12, "Spring requires a real descending route");
require(spring.flowPath().getFirst().equals(source), "Flow path starts at the single carved source");
LayeredHydrology.Position previous = null;
for (var position : spring.flowPath()) {
require(plan.allowsSpringFlow(position.x(), position.y(), position.z()), "Path must lie inside the flow envelope");
require(position.equals(source) || terrain.sample(position.x(), position.y(), position.z()) <= 0,
"Waterfall path must use existing air; no cliff excavation or rock filling");
if (previous != null) require(position.y() <= previous.y()
&& Math.abs(position.x() - previous.x()) + Math.abs(position.y() - previous.y())
+ Math.abs(position.z() - previous.z()) == 1, "Flow path must be contiguous and never ascend");
previous = position;
}
require(plan.springsInChunk(source.x() >> 4, source.z() >> 4).contains(spring), "Spring belongs to its source chunk");
require(!plan.allowsSpringFlow(10000, source.y(), 10000), "Bare-terrain prediction must exclude unrelated void water");
}
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,140 @@
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 NaturalHydrologySmoke {
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
public static void main(String[] args) {
NaturalHydrology.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 = NaturalHydrology.create(0, terrain);
var replay = NaturalHydrology.create(0, terrain);
var changed = NaturalHydrology.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())
&& first.springs().equals(replay.springs()),
"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);
NaturalHydrology.Sampler pierced = (x, y, z) ->
Math.floorMod(x, 37) < 2 && Math.floorMod(z, 41) < 2 ? -0.45F : terrain.sample(x, y, z);
var aroundShafts = NaturalHydrology.create(0, pierced);
require(!aroundShafts.features().isEmpty(), "Safe areas around natural shafts should remain usable");
verify(aroundShafts, pierced);
require(NaturalHydrology.create(0, (x, y, z) -> -1).cells().isEmpty(), "An empty world must not acquire a guaranteed basin");
var roof = NaturalHydrology.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 = NaturalHydrology.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 = NaturalHydrology.create(0, (x, y, z) ->
Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F);
verify(flat, (x, y, z) -> Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F);
require(!flat.springs().isEmpty(), "A natural cliff over the void must exercise rock spring placement");
long wet = flat.cells().stream().filter(NaturalHydrology.Cell::hasWater).count();
require(flat.cells().size() > wet * 2, "Sediment sheets must extend beyond a narrow one-block ring");
require(flat.cells().stream().anyMatch(cell -> cell.material() == NaturalHydrology.ShoreMaterial.STONE),
"Some natural shores should be rocky");
require(flat.cells().stream().anyMatch(cell -> cell.material() == NaturalHydrology.ShoreMaterial.GRASS),
"Some natural shores should retain grassy terrain");
require(flat.cells().stream().anyMatch(cell -> cell.sedimentDepth() == 5), "Some deposits should be five blocks thick");
require(flat.features().stream().anyMatch(feature -> feature.kind() == NaturalHydrology.Kind.STREAM),
"The controlled safe plateau must exercise the stream planner");
System.out.println("NaturalHydrologySmoke: shallow closed water, broad layered shores, deterministic rock springs and bounded flows passed.");
}
private static void verify(NaturalHydrology.Plan plan, NaturalHydrology.Sampler terrain) {
Map<String, NaturalHydrology.Cell> byChunks = new HashMap<>();
// Reverse chunk traversal must reconstruct the same cells without generating neighbours.
for (int cx = 18; cx >= -18; cx--) for (int cz = 18; cz >= -18; cz--) {
var chunk = plan.cellsInChunk(cx, cz);
NaturalHydrology.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() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
require(terrain.sample(cell.x(), y, cell.z()) > 0, "The entire sediment layer and two supporting blocks must already exist");
}
require(cell.sedimentDepth() >= 3 && cell.sedimentDepth() <= 5, "Sediment thickness must vary between 3 and 5");
if (!cell.hasWater()) {
require(cell.carveTop() - cell.bedY() >= 0 && cell.carveTop() - cell.bedY() <= 2,
"Dry terraces must remove at most two blocks");
continue;
}
require(cell.waterY() - cell.bedY() >= 1 && cell.waterY() - cell.bedY() <= NaturalHydrology.MAX_DEPTH, "Invalid shallow-water depth");
require(cell.carveTop() - cell.bedY() <= NaturalHydrology.MAX_CARVE, "Excavation exceeds four 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");
}
}
}
for (var spring : plan.springs()) {
var source = spring.source();
var outlet = spring.outlet();
require(terrain.sample(source.x(), source.y(), source.z()) > 0, "Spring must replace existing rock");
require(terrain.sample(source.x(), source.y() + 1, source.z()) > 0, "Rock spring must have a natural roof");
require(Math.abs(source.x() - outlet.x()) + Math.abs(source.z() - outlet.z()) == 1
&& source.y() == outlet.y(), "Spring outlet must be adjacent to its source");
for (int[] direction : CARDINALS) {
int x = source.x() + direction[0], z = source.z() + direction[1];
if (x == outlet.x() && z == outlet.z()) continue;
require(terrain.sample(x, source.y(), z) > 0, "A spring niche must have three natural lateral walls");
}
for (int y = source.y() - 3; y < source.y(); y++) {
require(terrain.sample(source.x(), y, source.z()) > 0, "Source needs an intact natural rock floor");
}
for (int y = source.y(); y >= source.y() - 8; y--) {
require(terrain.sample(outlet.x(), y, outlet.z()) <= 0, "Waterfall needs an exposed natural drop");
}
for (var cell : plan.cells()) {
require(Math.hypot(source.x() - cell.x(), source.z() - cell.z()) >= 32,
"Rock springs must stay away from modified closed-water terrain");
}
require(spring.flowBounds().minY() <= source.y() - 12, "Spring requires a real descending route");
require(spring.flowPath().getFirst().equals(source), "Flow path starts at the single carved source");
NaturalHydrology.Position previous = null;
for (var position : spring.flowPath()) {
require(plan.allowsSpringFlow(position.x(), position.y(), position.z()), "Path must lie inside the flow envelope");
require(position.equals(source) || terrain.sample(position.x(), position.y(), position.z()) <= 0,
"Waterfall path must use existing air; no cliff excavation or rock filling");
if (previous != null) require(position.y() <= previous.y()
&& Math.abs(position.x() - previous.x()) + Math.abs(position.y() - previous.y())
+ Math.abs(position.z() - previous.z()) == 1, "Flow path must be contiguous and never ascend");
previous = position;
}
require(plan.springsInChunk(source.x() >> 4, source.z() >> 4).contains(spring), "Spring belongs to its source chunk");
require(!plan.allowsSpringFlow(10000, source.y(), 10000), "Bare-terrain prediction must exclude unrelated void water");
}
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,130 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Small lava supply: existing rock, dry access, natural containment, and no mandatory fallback. */
public final class StarterLavaDepositSmoke {
private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
public static void main(String[] args) {
var noWater = NaturalHydrology.create(0, (x, y, z) -> -1);
NaturalHydrology.Sampler cliff = (x, y, z) -> Math.hypot(x, z) < 220
&& y >= 100 && y <= (x <= 0 ? 240 : 200) ? 1 : -1;
var first = StarterLavaDeposit.create(0, cliff, noWater);
var replay = StarterLavaDeposit.create(0, cliff, noWater);
var otherSeed = StarterLavaDeposit.create(42, cliff, noWater);
require(first.lavaCells().size() == 18, "A thick natural cliff should provide one small deposit");
require(first.cells().equals(replay.cells()) && first.supports().equals(replay.supports())
&& first.access().equals(replay.access()), "The same seed must replay the entire plan");
require(!first.cells().equals(otherSeed.cells()), "The seed must influence which suitable ledge is chosen");
verify(first, cliff, noWater);
verify(otherSeed, cliff, noWater);
try {
first.cells().clear();
throw new AssertionError("Plan cells must be immutable");
} catch (UnsupportedOperationException expected) {
// Passed.
}
require(StarterLavaDeposit.create(0, (x, y, z) -> -1, noWater).cells().isEmpty(), "No lava in the void");
require(StarterLavaDeposit.create(0, (x, y, z) -> 1, noWater).cells().isEmpty(), "No sealed underground pocket");
require(StarterLavaDeposit.create(0, (x, y, z) -> y < 199 ? -1 : cliff.sample(x, y, z), noWater)
.cells().isEmpty(), "Thin natural floors must not receive a manufactured foundation");
require(StarterLavaDeposit.create(0, (x, y, z) -> y == 198 && Math.floorMod(x, 2) == 0
? -1 : cliff.sample(x, y, z), noWater).cells().isEmpty(), "Perforated floors must reject the deposit");
NaturalHydrology.Sampler thinRoof = (x, y, z) -> Math.hypot(x, z) < 220
&& y >= 100 && y <= (x <= 0 ? 207 : 200) ? 1 : -1;
require(StarterLavaDeposit.create(0, thinRoof, noWater).cells().isEmpty(), "No exposed or thinly covered lava");
// Exercise real water exclusions as well; no lava is an acceptable result on a crowded cliff.
var water = NaturalHydrology.create(0, cliff);
verify(StarterLavaDeposit.create(0, cliff, water), cliff, water);
System.out.println("StarterLavaDepositSmoke: deterministic 18-source pocket, natural floor/walls/roof, walkable access, water separation and no fallback passed.");
}
private static void verify(StarterLavaDeposit.Plan plan, NaturalHydrology.Sampler terrain, NaturalHydrology.Plan water) {
Map<StarterLavaDeposit.Position, StarterLavaDeposit.Cell> changes = new HashMap<>();
Set<StarterLavaDeposit.Position> supports = new HashSet<>();
for (int cx = 12; cx >= -12; cx--) for (int cz = 12; cz >= -12; cz--) {
for (var cell : plan.cellsInChunk(cx, cz)) {
require(cell.x() >> 4 == cx && cell.z() >> 4 == cz, "A modification crossed its owning chunk");
var pos = new StarterLavaDeposit.Position(cell.x(), cell.y(), cell.z());
require(changes.put(pos, cell) == null, "Repeated modification in chunk slices");
}
for (var support : plan.supportsInChunk(cx, cz)) {
require(support.x() >> 4 == cx && support.z() >> 4 == cz, "A support crossed its owning chunk");
require(supports.add(support), "Repeated support in chunk slices");
}
}
require(changes.size() == plan.cells().size() && supports.size() == plan.supports().size(), "Chunk order lost data");
for (var support : supports) {
require(!changes.containsKey(support), "A natural support is also being excavated");
require(terrain.sample(support.x(), support.y(), support.z()) > 0, "Manufactured rock support");
}
require(plan.lavaCells().size() == 0 || plan.lavaCells().size() == StarterLavaDeposit.LAVA_VOLUME,
"The planner must place at most one 18-source pocket");
for (var cell : plan.cells()) {
require(cell.y() >= 0 && cell.y() < 384 && Math.hypot(cell.x(), cell.z()) < StarterLavaDeposit.RADIUS,
"Lava excavation exceeded island bounds");
require(terrain.sample(cell.x(), cell.y(), cell.z()) > 0, "A modification replaced natural air");
for (var shore : water.cells()) require(Math.hypot(cell.x() - shore.x(), cell.z() - shore.z()) >= 32,
"Lava or its access intersects the water exclusion distance");
for (var spring : water.springs()) {
var b = spring.flowBounds();
double dx = Math.max(Math.max(b.minX() - cell.x(), 0), cell.x() - b.maxX());
double dz = Math.max(Math.max(b.minZ() - cell.z(), 0), cell.z() - b.maxZ());
require(Math.hypot(dx, dz) >= 32, "A waterfall permission volume can reach the deposit");
}
if (!cell.lava()) continue;
var below = new StarterLavaDeposit.Position(cell.x(), cell.y() - 1, cell.z());
require(retainedSolidOrLava(below, changes, terrain), "Lava has an open floor");
for (int[] direction : DIRECTIONS) require(retainedSolidOrLava(new StarterLavaDeposit.Position(
cell.x() + direction[0], cell.y(), cell.z() + direction[1]), changes, terrain), "Lava has a side outlet");
}
if (plan.lavaCells().isEmpty()) {
require(plan.access().isEmpty() && plan.cells().isEmpty() && plan.supports().isEmpty(), "An empty result left partial excavation");
return;
}
var entry = plan.access().orElseThrow();
for (int y = entry.y(); y < 384; y++) require(terrain.sample(entry.x(), y, entry.z()) <= 0,
"Access does not start from naturally open air");
// A player can walk from the outdoor ledge to the dry lip next to the lava, without digging
// or crossing a fluid. Merely opening an isolated cavity would not satisfy this check.
Set<StarterLavaDeposit.Position> seen = new HashSet<>();
ArrayDeque<StarterLavaDeposit.Position> queue = new ArrayDeque<>();
seen.add(entry); queue.add(entry);
boolean reached = false;
while (!queue.isEmpty() && seen.size() <= 1600) {
var at = queue.removeFirst();
for (int[] direction : DIRECTIONS) {
var floor = new StarterLavaDeposit.Position(at.x() + direction[0], at.y() - 1, at.z() + direction[1]);
var fluid = changes.get(floor);
reached |= fluid != null && fluid.lava();
var next = new StarterLavaDeposit.Position(at.x() + direction[0], at.y(), at.z() + direction[1]);
if (Math.abs(next.x() - entry.x()) > 18 || Math.abs(next.z() - entry.z()) > 18 || seen.contains(next)) continue;
if (!air(next, changes, terrain) || !air(new StarterLavaDeposit.Position(next.x(), next.y() + 1, next.z()), changes, terrain)) continue;
if (changes.containsKey(floor) || terrain.sample(floor.x(), floor.y(), floor.z()) <= 0) continue;
seen.add(next); queue.addLast(next);
}
}
require(reached, "The pocket has no dry, two-block-high access from the natural ledge");
}
private static boolean air(StarterLavaDeposit.Position at, Map<StarterLavaDeposit.Position, StarterLavaDeposit.Cell> changes,
NaturalHydrology.Sampler terrain) {
var change = changes.get(at);
return change == null ? terrain.sample(at.x(), at.y(), at.z()) <= 0 : !change.lava();
}
private static boolean retainedSolidOrLava(StarterLavaDeposit.Position at,
Map<StarterLavaDeposit.Position, StarterLavaDeposit.Cell> changes, NaturalHydrology.Sampler terrain) {
var change = changes.get(at);
return change == null ? terrain.sample(at.x(), at.y(), at.z()) > 0 : change.lava();
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -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.6.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.6"
pack-format = "packwiz:1.1.0"
[index]