diff --git a/CHANGELOG.md b/CHANGELOG.md index e946627..100dfd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.1.0-alpha.9 — 2026-09-09 + +- Cavités luxuriantes éclairées par leur végétation, secteurs à spéléothèmes + et géodes d’améthyste recherchés sous l’île, sur des supports naturels. +- Province de soufre limitée dans les profondeurs ; geysers utilisant + les blocs et conditions de fonctionnement de Minecraft 26.3-pre-2. +- Forêts et rivière de surface conservées ; recherche de lacs plus profonds + et volumineux et de groupes facultatifs de bassins en terrasses reliés par + des cascades. Les « rizières » désignent le relief aquatique, sans culture + de riz ni mécanique d’ascenseur ajoutée au joueur. +- Nouvelle clé `sanctuary:sanctuary_cavern`, réservée aux nouveaux mondes. + Les générations jusqu’à l’alpha.8 restent séparées ; aucune sauvegarde + existante n’est convertie ou régénérée. + +Validation : au moins dix tests requis réussis sur chacune des graines `0`, +`42` et `8675309`, avec 1 800 ticks de fluides ; `check build assemblePack` +réussi. Les preuves cumulées comprennent deux bassins en terrasses reliés, +un geyser dormant puis éruptif, des baies lumineuses, des spéléothèmes et de +l’améthyste bourgeonnante. Les mesures et leurs périmètres figurent dans +[Validation](docs/testing.md). Publication et synchronisations Prism restent +suivies séparément. Aucun site ni stock n’est garanti sur chaque graine. + ## 0.1.0-alpha.8 — 2026-09-09 - Forêts de surface conservées, avec de nouvelles failles courbes qui retirent diff --git a/README.md b/README.md index 8456272..fe467de 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ conserve les intentions ; le [backlog](docs/backlog.md) prépare les premiers tickets. Les systèmes d'expansion, d'économie, de progression et les dimensions décrits dans la vision ne sont pas encore implémentés. -## Premier incrément +## Socle livré jusqu’à l’alpha.8 - Mod `sanctuary` indépendant et pack Sanctuary construit avec packwiz. - Preset **Sanctuary** sélectionnable à la création d'un monde, avec une île @@ -57,14 +57,34 @@ le terrain : le spawn cherche un emplacement naturel. Le catalogue TerraMix, les continents déverrouillables, les océans, les ruines et les autres fonctionnalités restent à construire par tickets. -L’alpha.8 utilise la nouvelle génération `sanctuary:sanctuary_rift` ; créer un -**nouveau monde** pour l’essayer. Les anciennes générations, jusqu’à l’alpha.7, -gardent leurs paramètres ; aucune sauvegarde n’est régénérée. Dappled Forest -reste réservé aux futurs continents et présent dans les anciennes sauvegardes -alpha.5. **La validation moteur de l’alpha.8 passe sur les trois graines de -référence**, avec 1 800 ticks de fluides ; aucune rivière, coulée ou quantité -de ressources n’est garantie pour toutes les graines. Les observations et -leurs limites figurent dans [Validation](docs/testing.md). +La génération alpha.8, `sanctuary:sanctuary_rift`, passe les contrôles moteur +sur les trois graines de référence, avec 1 800 ticks de fluides. Ces résultats +ne valident pas par avance l’incrément suivant. Les observations et leurs +limites figurent dans [Validation](docs/testing.md). + +## Alpha.9 : cavités vivantes et terrasses d’eau + +L’incrément ajoute des cavités luxuriantes et lumineuses sous l’île, des +secteurs à spéléothèmes, des géodes d’améthyste et des poches de soufre moins +envahissantes, avec de vrais geysers de soufre lorsque leurs conditions sont +réunies. Les forêts de surface sont conservées ; la recherche de lacs plus +profonds et volumineux accompagne des groupes facultatifs de bassins naturels +étagés. Leurs cascades relient les paliers et permettent de circuler par la nage. +L’image des « rizières » décrit ce paysage d’eau en terrasses : aucune culture +de riz n’est ajoutée. + +**La validation moteur alpha.9 passe sur les trois graines de référence.** +Les preuves de terrasses, geysers et décor souterrain sont cumulées sur ces +trois mondes, sans imposer chaque élément à chacun. La distribution est +vérifiée séparément, selon le guide packwiz. Le +nouveau réglage `sanctuary:sanctuary_cavern` nécessite de créer un **nouveau +monde Sanctuary**. Les anciennes générations jusqu’à l’alpha.8 gardent leurs +paramètres ; aucune sauvegarde n’est régénérée. Les cavités restent dans +l’Overworld, sans créer la dimension des cavernes prévue par la vision. +Dappled Forest reste réservé aux futurs continents et présent dans les +anciennes sauvegardes alpha.5. Aucun lac, groupe de terrasses, geyser ou stock +de ressources n’est garanti sur chaque graine. Voir le ticket local +[WG-10](docs/backlog.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 @@ -88,7 +108,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.8 | +| Sanctuary / pack | 0.1.0-alpha.9 | 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 @@ -115,7 +135,7 @@ décrits dans [Validation](docs/testing.md). Résultats : -- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.8.jar` : mod à installer avec +- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.9.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). diff --git a/docs/backlog.md b/docs/backlog.md index 2b70bc9..45778b6 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -13,6 +13,7 @@ visuels, multijoueurs et de redémarrage indiqués dans ce document restent à faire avant de clôturer tous leurs critères. WG-04 à WG-07 restent ouverts, avec les incréments de l’île initiale décrits ci-dessous. WG-08 est validé ; WG-09 est livré en alpha.8, avec mesures moteur et mise à jour Prism vérifiées. +WG-10 dispose de mesures moteur alpha.9 ; la distribution reste suivie séparément. Résultat visé : un client et un serveur Fabric compatibles peuvent ouvrir un monde Sanctuary, plusieurs joueurs y arrivent sur une même île sûre et les chunks extérieurs restent vides. La génération est reproductible et ses limites sont documentées. @@ -254,6 +255,59 @@ est synchronisée deux fois, avec sauvegardes et réglages conservés. Le ticket est livré ; l’appréciation visuelle et l’équilibrage en partie restent à éprouver. Ce ticket local n’est pas une issue distante publiée. +### WG-10 — Cavités luxuriantes et terrasses d’eau — alpha.9 + +**Branche :** `codex/lush-caverns-and-water-terraces`. + +**But :** rendre les cavités sous Sanctuary plus vivantes et lumineuses, +diversifier leurs ressources et créer des eaux plus volumineuses, sans +remplacer les forêts et la rivière de surface appréciées en alpha.8. + +**Critères d’acceptation :** + +- Des cavités luxuriantes portent une végétation réellement lumineuse et + enracinée dans les surfaces et plafonds existants. Les observations doivent + distinguer un biome déclaré, ses blocs décoratifs et leur lumière réelle. +- Des secteurs à spéléothèmes et des géodes d’améthyste sont recherchés dans + la roche compatible ; leurs supports, leurs volumes et leur accessibilité + sont contrôlés dans les vrais chunks, sans géodes suspendues dans le vide. +- Le soufre occupe moins de roche qu’en alpha.8, sur un périmètre de comparaison + explicite. Les geysers éventuels utilisent le soufre actif et les conditions + exactes de Minecraft 26.3-pre-2 ; leur fonctionnement doit être démontré, + sans confondre un bloc jaune avec un geyser actif. +- Les forêts, la rivière et les bassins de surface restent présents lorsque + le relief le permet. Les lacs plus profonds et volumineux sont mesurés en + blocs d’eau réels, avec fond et parois retenus après simulation. +- Des groupes facultatifs de deux bassins naturels en terrasses + peuvent relier plusieurs niveaux par des cascades déclarées. Chaque palier + doit avoir ses supports et son propre volume retenu ; une cascade isolée + ou des plans d’eau sans liaison ne valident pas un groupe de terrasses. + Le second bassin peut avoir une sortie terminale vers les roches ou le vide, + sans créer un troisième palier. +- Ces cascades offrent un passage vertical selon les règles de nage de + Minecraft. « Rizières » désigne seulement leur disposition paysagère : + aucune culture de riz ni téléportation n’entre dans ce ticket. +- Les sorties d’eau sont contrôlées après décoration et après 1 800 ticks, + y compris aux frontières de chunks. Leur finition initiale doit préserver + les aménagements du joueur aux rechargements suivants. +- Le nouveau réglage `sanctuary:sanctuary_cavern` s’applique aux nouveaux + mondes. Les identifiants et traitements alpha.1 à alpha.8 restent séparés ; + aucune sauvegarde personnelle n’est modifiée par les essais. +- Les graines `0`, `42` et `8675309` disposent de mesures identifiées par + version. Les sites absents sont signalés ; aucun quota de ressources ni + bassin de secours ne remplit un relief incompatible. +- Après validation : `check build assemblePack`, artefacts immuables vérifiés, + deux synchronisations isolées puis deux dans la même instance Prism, + sauvegardes et réglages conservés. + +**Validation moteur au 9 septembre 2026 :** les trois graines de référence +passent les contrôles finaux. Les preuves cumulées démontrent un groupe de +deux paliers reliés, un geyser actif et les décors souterrains demandés ; les +absences locales et périmètres de mesure figurent dans [Validation](testing.md). +Le build est réussi. La publication et la synchronisation de la même instance +Prism restent vérifiées lors de la distribution. Ce ticket local n’est pas +une issue distante publiée ; l’essai visuel et l’équilibrage restent ouverts. + ## 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. diff --git a/docs/packwiz.md b/docs/packwiz.md index faf2064..0ecfd8a 100644 --- a/docs/packwiz.md +++ b/docs/packwiz.md @@ -15,6 +15,11 @@ de l'ancien pack 26.2. ## Livrer une mise à jour +L’alpha.9 est en préparation dans les sources. Les exemples de commandes +ci-dessous ciblent cette prochaine version ; ils ne signifient pas qu’elle +est publiée. Le canal reste sur la dernière livraison vérifiée jusqu’à la +validation du build, des essais moteur et des nouveaux artefacts. + Dernière livraison vérifiée le 9 septembre 2026 : [Sanctuary 0.1.0-alpha.8](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.8), depuis le commit source `80e78e0`. Le JAR, le `.mrpack` et le ZIP d’amorçage @@ -36,7 +41,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.8.mrpack +python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.9.mrpack ``` Les options sont facultatives. Ce script **publie** sur le Git configuré dans @@ -55,7 +60,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.8/sanctuary-0.1.0-alpha.8.jar + python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.9/sanctuary-0.1.0-alpha.9.jar ``` 4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce @@ -154,6 +159,13 @@ nouveau monde Sanctuary pour les découvrir. La même instance Prism reçoit le nouveau JAR par le canal stable ; ses sauvegardes et réglages sont conservés. Les mondes alpha.7 et antérieurs gardent leurs paramètres de génération. +L’alpha.9 prépare `sanctuary:sanctuary_cavern` pour les cavités luxuriantes, +les géodes, les geysers et les terrasses d’eau. Un **nouveau monde Sanctuary** +sera nécessaire pour essayer cet incrément après sa validation. Les mondes +alpha.8 conservent `sanctuary:sanctuary_rift` ; leur contenu n’est pas retouché +par une mise à jour de la même instance Prism. Les anciennes sauvegardes et +les réglages restent à conserver lors des deux synchronisations de livraison. + 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). diff --git a/docs/testing.md b/docs/testing.md index a436327..2258c05 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -65,7 +65,8 @@ Le test capture donc le spawn juste avant ce déplacement, après l'initialisati normale du monde, et examine les coordonnées absolues de l'île. Les scénarios suivants couvrent le socle et les générations historiques ; -les contrôles et résultats propres à l’alpha.8 figurent plus bas : +les contrôles validés pour l’alpha.9 et les résultats historiques +propres à l’alpha.8 figurent plus bas : 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 @@ -189,6 +190,171 @@ 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 et validation alpha.9 — 9 septembre 2026 + +Les mondes neufs des graines `0`, `42` et `8675309` utilisent les paramètres +de production `sanctuary:sanctuary_cavern`. Chaque journal termine au moins +dix tests requis et sa tâche Gradle sans échec, avec 1 800 ticks de fluides. +Les sources naturelles d’eau et de lave, les débordements et les geysers +démarrent uniquement avec leurs ticks de production. Le test programme +seulement un témoin artificiel indépendant pour vérifier que les ticks +fonctionnent ; il ne réveille pas les sources naturelles. +Le build complet `check build assemblePack` réussit en 6m 24s, +avec 15 tâches de tests purs de comportement. +Les rapports sont postérieurs au gel du code final ; les résultats de +l’alpha.8 et les essais alpha.9 intermédiaires ne servent pas de preuve ici. + +La présence d’un groupe de terrasses connecté, d’un geyser réellement actif, +de baies lumineuses, de spéléothèmes et d’améthyste bourgeonnante est vérifiée +sur l’ensemble des trois graines. Chaque élément n’est pas imposé à chaque +graine ; leurs absences locales restent visibles dans les tableaux. + +| Graine | Chunks FULL | Failles / témoins | Chênes / bouleaux supérieurs | Chênes noirs inférieurs | Gros champignons | Petits sous plafond | Minerais dehors : charbon / fer / cuivre | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `0` | 251 | 3 / 3 | 321 / 251 | 18 | 14 | 211 | 506 / 178 / 147 | +| `42` | 246 | 2 / 2 | 362 / 177 | 11 | 8 | 69 | 617 / 269 / 193 | +| `8675309` | 242 | 3 / 3 | 248 / 125 | 14 | 11 | 131 | 761 / 317 / 328 | + +Les arbres sont des groupes de pieds de troncs sur sol naturel, avec feuilles +observées ; les gros champignons ont une tige et un chapeau connectés. + +| Graine | Blocs à baies lumineuses | Blocs émissifs | Spéléothèmes pointus | Améthyste bourgeonnante | Blocs d’améthyste | Geysers dormants + éruptifs | Sources d’eau de geyser | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `0` | 363 | 391 | 501 | 214 | 818 | 1 | 1 | +| `42` | 280 | 308 | 592 | 210 | 945 | 4 | 4 | +| `8675309` | 446 | 476 | 380 | 141 | 504 | 5 | 5 | + +Le décor souterrain est compté sur les blocs initiaux réellement générés. +Ses témoins sont revérifiés après les fluides : support, conservation de +l’améthyste bourgeonnante et lumière du moteur pour les blocs émissifs. +Ces nombres comptent des blocs, pas des cavités ni des géodes distinctes. +L’améthyste bourgeonnante est reliée à une cavité naturelle par un parcours +d’air vérifié avant et après les ticks ; les JSON donnent la position de +chaque raccord et les exemples de blocs souterrains, limités à 64 témoins. +Les geysers observés possèdent la vraie entité de bloc `potent_sulfur`, +du magma dessous et une source d’eau retenue au-dessus. Un même geyser +doit passer par les états dormant et éruptif pendant les ticks, sans +activation artificielle par le test. + +| Graine | Niveaux Y des bassins | Colonnes d’eau par bassin | Descente depuis la bouche | Nouveaux blocs écoulés accessibles | Deux paliers connectés | +| --- | --- | --- | --- | --- | --- | +| `0` | 148 / 140 | 53 / 76 | 148 | 1 230 | Oui, après ticks | +| `0` | 177 / 163 | 169 / 47 | 177 | 3 370 | Oui, après ticks | +| `42` | 129 / 114 | 116 / 53 | 129 | 181 | Oui, après ticks | +| `42` | 112 / 107 | 105 / 161 | 112 | 354 | Oui, après ticks | +| `8675309` | 154 / 149 | 128 / 149 | 7 | 7 | Oui, après ticks | +| `8675309` | 153 / 112 | 87 / 58 | 153 | 871 | Oui, après ticks | + +Chaque groupe réunit deux bassins retenus à des niveaux différents. Le +diagnostic suit l’eau réelle depuis chaque bouche, horizontalement ou en +descendant, sans mouvement vers le haut, jusqu’au bassin cible ; +un exutoire terminal sans bassin cible n’est pas compté comme cette liaison. +La descente est mesurée sur ce parcours propre à la bouche. Les nouveaux +blocs accessibles en sont les eaux écoulées qui n’étaient ni retenues dans +les bassins ni présentes avant la simulation. Les exutoires terminaux +facultatifs doivent descendre d’au moins douze blocs réels. + +Les terrasses sont limitées à trois blocs de profondeur et cinq blocs +d’incision ; les lacs, à huit blocs de profondeur et douze blocs d’incision. +Pour les terrasses, le diagnostic exige aussi quatre blocs d’air naturel +au-dessus de chaque colonne excavée, en plus des fonds et supports conservés. + +| Graine | Longueur rivière | Déplacement | Niveau Y | Colonnes d’eau | Colonnes des bassins terminaux | Sources liées | +| --- | --- | --- | --- | --- | --- | --- | +| `0` | 409,8 | 199,7 | 243 | 2 863 | 240 / 241 | 1 | +| `42` | 247,8 | 171,0 | 247 | 1 712 | 254 / 247 | 1 | +| `8675309` | 340,7 | 251,3 | 239 | 2 247 | 265 / 244 | 1 | + +La longueur additionne les déplacements du tracé, diagonales comprises. Les +bassins terminaux et l’arrivée de leur cascade sont contrôlés après les ticks. + +| Graine | Colonnes du lac | Blocs d’eau réels | Profondeur retenue maximale | +| --- | --- | --- | --- | +| `0` | 728 | 3 746 | 8 | +| `42` | 722 | 3 027 | 8 | +| `8675309` | 992 | 4 978 | 8 | + +Chaque ligne décrit un site classé `LAKE`, après simulation. Les étangs, +bassins de rivière et terrasses sont consignés dans leurs propres catégories. + +| Graine | Eau retenue, geysers inclus | Dont sources de geyser | Eau hors retenues | Eau totale | Lave totale | Sources de lave | Coulées | Accès poche de lave | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `0` | 9 845 | 1 | 4 971 | 14 816 | 144 | 20 | 2 | `(96, 100, -45)` | +| `42` | 7 267 | 4 | 903 | 8 170 | 144 | 20 | 2 | `(67, 133, -108)` | +| `8675309` | 10 268 | 5 | 1 230 | 11 498 | 144 | 20 | 2 | `(-93, 156, -21)` | + +Ces volumes sont mesurés après 1 800 ticks actifs ; ils ne mesurent pas un +débit et ne signifient pas que les coulées ont atteint leur état final. +L’eau retenue correspond au volume attendu après décoration et comprend +les sources propres aux geysers. Les composantes extérieures sont rattachées +aux sources rocheuses ou aux débordements déclarés, avec leurs raccords +réels. La lave des coulées est distinguée de la poche couverte. + +| Graine | Sols de mousse | Sols de podzol | Sols de mycélium | +| --- | --- | --- | --- | +| `0` | 13 175 | 10 936 | 5 074 | +| `42` | 4 897 | 3 712 | 2 200 | +| `8675309` | 10 058 | 6 714 | 3 293 | + +Les stocks suivants portent sur les seuls chunks inspectés, avant les +ticks de fluides. Les variantes ordinaires et d’ardoise de chaque minerai +sont additionnées. Ce relevé ne couvre pas toute l’île et ne déclenche +jamais de remplissage pour atteindre un quota. + +| Minerai | Graine 0 | Graine 42 | Graine 8675309 | +| --- | --- | --- | --- | +| Charbon | 7 858 | 6 788 | 4 992 | +| Fer | 3 224 | 2 700 | 2 172 | +| Cuivre | 2 439 | 2 132 | 1 621 | +| Or | 930 | 687 | 507 | +| Redstone | 924 | 631 | 523 | +| Lapis | 278 | 279 | 137 | +| Diamant | 162 | 86 | 88 | +| Émeraude | 64 | 41 | 42 | + +| Matière | Graine 0 | Graine 42 | Graine 8675309 | +| --- | --- | --- | --- | +| Tuf | 657 813 | 491 736 | 239 434 | +| Cobblestone | 285 441 | 172 282 | 162 791 | +| Cobblestone moussue | 593 694 | 420 042 | 229 846 | +| Boue compactée | 168 698 | 167 276 | 158 922 | +| Ardoise des abîmes | 1 443 679 | 901 821 | 858 181 | +| Roche noire | 539 269 | 275 994 | 324 101 | +| Basalte | 605 828 | 390 889 | 307 652 | +| Soufre | 87 811 | 57 838 | 82 209 | +| Cinabre | 11 797 | 13 597 | 20 095 | + +La sélection des chunks suit les sites d’eau et d’écologie ; elle n’est pas +un échantillon représentatif de toute l’île. Ces stocks ne démontrent pas +l’accessibilité de chaque bloc et ne prouvent pas, seuls, une réduction du +soufre par rapport à une autre version sur un périmètre différent. +Le tableau des minerais extérieurs sélectionne charbon, fer et cuivre ; les +JSON gardent les autres familles effectivement observées avec une face d’air. + +| Graine | Réentrée source d’eau | Réentrée source de lave | Réentrée débordement | +| --- | --- | --- | --- | +| `0` | 1 vérifiée(s) | 1 vérifiée(s) | 1 vérifiée(s) | +| `42` | 1 vérifiée(s) | 1 vérifiée(s) | 1 vérifiée(s) | +| `8675309` | 1 vérifiée(s) | 1 vérifiée(s) | 1 vérifiée(s) | + +Après consommation des marqueurs, les rappels du post-traitement préservent +les modifications témoins apportées aux bouches ; les blocs sont restaurés +ensuite. Les 176 fichiers historiques suivis hors raccordement +conservent leur SHA-256. Aucun monde personnel n’est ouvert par ces tests. + +Preuves locales ignorées : `build/alpha9-check-build-final.log` (build et graine 0), +`build/alpha9-seed42-final.log`, `build/alpha9-seed8675309-final.log` et les rapports +`cavern-seed--after-1800-ticks.json` et `-postprocessing-reentry.json` +sous `mods/sanctuary/build/run/gameTest/diagnostics/`. +`build/report-alpha9-validation.py` extrait les mesures et vérifie les preuves +cumulées, le gel des sources et les journaux. Le hash final du JAR est lu +dans `build/alpha9-jar-sha256.txt`, sans valeur codée dans l’extracteur. + +Ces résultats ne garantissent aucune géode, terrasse, rivière, geyser ou +ressource sur chaque graine. L’appréciation visuelle et l’équilibrage en +partie restent ouverts. La publication et les deux synchronisations de +la même instance Prism sont vérifiées séparément lors de la distribution. + ## Contrôles et validation alpha.8 — 9 septembre 2026 Les trois essais utilisent des mondes neufs et le preset de production diff --git a/docs/vision.md b/docs/vision.md index bcca70f..82998b6 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -61,9 +61,13 @@ petits bassins, sans imposer de courts ruisseaux. Le terrain est traité près d 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 +Une rivière peut avoir des échappements et des chutes déclarés. Les eaux +intérieures doivent rester localisées et retenues, sans aquifère qui remplit +globalement les cavités. Des bassins naturels étagés, évoquant des rizières, +peuvent se relier par des cascades et offrir des passages verticaux par la nage. +Cette image ne prévoit aucune culture de riz dans le mod Sanctuary. +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. L’ambiance de départ doit rester douce et tempérée, avec de véritables forêts @@ -77,8 +81,9 @@ masse artificielle au centre de l’île. La géologie change en descendant : tuf, cobblestone, pierres moussues et boue compactée dans les intérieurs humides, puis ardoise des abîmes, roche noire et -basalte. Des poches de soufre doivent être réellement explorables. Quelques -sources profondes peuvent former des coulées de lave ; elles ne doivent pas +basalte. Des poches de soufre doivent être réellement explorables et rester +ponctuelles, avec de vrais geysers actifs lorsque les conditions s’y prêtent. +Quelques sources profondes peuvent former des coulées de lave ; elles ne doivent pas transformer les profondeurs en grands bassins remplis. L’émeraude rejoint les minerais à rechercher, avec l’or, le diamant, la redstone et le lapis. @@ -91,6 +96,15 @@ cerisier rose ou une autre de ces essences rares, lorsque le terrain convient. Les biomes très contrastés de Minecraft 26.3, dont Dappled Forest, restent réservés aux futurs continents. +Les cavités sous l’île doivent aussi accueillir des ambiances luxuriantes +avec de la végétation lumineuse, des secteurs à stalactites et stalagmites et +des géodes d’améthyste. Leur décor doit utiliser les sols et plafonds du relief, +sans reconstruire l’île ni déposer de structures flottantes sans support. +L’alpha.9 introduit ces ambiances et recherche des lacs plus profonds et +volumineux ; les résultats et leurs limites sont documentés dans les contrôles moteur. Ces cavités appartiennent à +l’Overworld de Sanctuary et ne remplacent pas le futur projet de dimension +des cavernes. + 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 diff --git a/docs/worldgen.md b/docs/worldgen.md index e3a5ac6..987c7ef 100644 --- a/docs/worldgen.md +++ b/docs/worldgen.md @@ -5,10 +5,11 @@ entourée de vide sans répétition d'îles à grande distance. C'est une premi tranche de génération pour Minecraft **26.3-pre-2 / Fabric**, pas encore le système d'expansion collective. -L’alpha.8 utilise les paramètres `sanctuary:sanctuary_rift`. Ses contrôles -dans les vrais chunks passent sur les trois graines de référence, avec -1 800 ticks de fluides. Les observations et limites de cette version sont -consignées séparément des générations précédentes dans [Validation](testing.md). +L’alpha.9 utilise les paramètres `sanctuary:sanctuary_cavern`, pour de nouveaux +mondes uniquement. Sa validation moteur passe sur les trois graines de référence. L’alpha.8 conserve +`sanctuary:sanctuary_rift` et ses résultats sur trois graines avec 1 800 ticks +de fluides ; ils ne valident pas la génération v9. Les observations de chaque +version restent séparées dans [Validation](testing.md). ## Créer un monde @@ -27,7 +28,10 @@ La seed est libre. Modifier `level-type` ne convertit pas un monde existant : utiliser un nouveau `level-name` pour tester une autre génération. Les fichiers de test et les sauvegardes personnelles doivent rester distincts. -## Ce qui est généré +## Socle de génération conservé + +Les points suivants décrivent le socle livré jusqu’à l’alpha.8. Les évolutions +alpha.9 et leur état de validation sont précisés dans la section v9 ci-dessous. - Le générateur reste celui de Minecraft, `minecraft:noise`. - La forme reprend le champ de bruit flottant actif en 26.2 : `old_blended_noise` @@ -59,8 +63,10 @@ de test et les sauvegardes personnelles doivent rester distincts. 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. L’eau de Sanctuary suit son propre plan de surface et de niches +- Jusqu’à l’alpha.8, les carvers, géodes, donjons, grands lacs de lave et sources + vanilla sont retirés. L’alpha.9 ajoute une intégration bornée de géodes et + de geysers, sans réactiver l’ensemble de la décoration souterraine vanilla. + L’eau 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 n’est nécessaire. @@ -71,6 +77,103 @@ chaque seed. L’accès à chaque progression vanilla et les ressources renouvel 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. +## Cavités et terrasses d’eau — génération v9 + +Le nouveau réglage est `sanctuary:sanctuary_cavern`, avec le biome source +`sanctuary:cavern_island` et la règle de matière `sanctuary:cavern_island`. +La densité réutilise `sanctuary:final_density_rift` sans changer cet identifiant +historique. Ses nouvelles données et traitements +doivent rester distincts de `sanctuary:sanctuary_rift` et de toutes les +générations précédentes. Le preset sélectionne la nouvelle génération à la +création ; une mise à jour du JAR ne convertit pas une sauvegarde ni ne +régénère ses chunks. Les cavités restent dans l’Overworld sur 384 blocs de +hauteur ; la dimension des cavernes décrite dans la vision n’est pas ajoutée. + +### Ambiances et ressources + +Le ticket conserve les forêts de surface et ajoute des cavités luxuriantes +éclairées par leur végétation, des secteurs à spéléothèmes et des géodes +d’améthyste sur des emplacements compatibles. Les sols, plafonds, coquilles +et ouvertures doivent être observés après la décoration réelle : une valeur +de biome ne prouve ni le décor ni son éclairage. L’objectif est d’utiliser +les volumes naturels de l’île, sans remplir ses trous pour accueillir une +structure. + +Les dix biomes ont leurs propres identifiants : `cavern_oak_forest`, +`cavern_birch_forest`, `cavern_clearing`, `cavern_dry_woodland`, +`cavern_rocky_heath`, `cavern_dark_grove`, `cavern_bamboo_grove`, +`cavern_sulfur_depths`, `cavern_lush_caves` et `cavern_dripstone_caves`, dans +le namespace `sanctuary`. Les deux nouvelles ambiances luxuriantes et à +spéléothèmes sont limitées à Y<184 par la palette ; leur décoration recherche +des sols entre Y=40 et Y=182, avec trois blocs naturels de support et un +plafond situé de 6 à 48 blocs au-dessus. + +Dans les cavités luxuriantes, la mousse remplace le sol compatible et de +petites plantes l’accompagnent. Les vraies lianes `cave_vines` et +`cave_vines_plant` pendent sur 3 à 8 blocs ; leurs baies émettent de la lumière +et restent récoltables. Ce décor n’ajoute pas d’eau. Les spéléothèmes utilisent +les blocs `pointed_dripstone` orientés, sur un appui de `dripstone_block` +remplaçant de la roche existante, avec une longueur de 1 à 4 blocs selon +l’espace disponible. + +La géode utilise une configuration vanilla compacte avec améthyste, +améthyste bourgeonnante, calcite et basalte lisse. Son enveloppe doit tenir +dans la roche préexistante et un passage de deux blocs de haut la relie à +la cavité, en préservant l’améthyste bourgeonnante et les cristaux attachés. +Un chunk sur huit est candidat ; au plus une pose réussie est retenue par +chunk candidat. Cela ne garantit pas de géode sur une graine. + +Le soufre devient admissible dans une bande climatique plus étroite sous +Y=176. La réduction de cette sélection peut être testée indépendamment du +terrain ; seule une comparaison des vrais blocs sur un périmètre commun +mesure sa diminution dans l’île. Un geyser éventuel creuse un seul bloc de +sol pour sa source d’eau, avec `potent_sulfur` dessous et `magma_block` encore +en dessous. Le fond, les quatre parois et la hauteur libre sont contrôlés. +Le cycle du geyser reste celui de Minecraft ; ce décor est distinct des +bassins hydrologiques et son eau doit être comptée séparément. + +Leur présence, leur activité, l’éclairage et la cohabitation avec les eaux +et la végétation sont contrôlés dans les mondes de test. Les preuves de ces +éléments sont cumulées sur les trois graines. Un site incompatible peut +être rejeté ; aucun geyser ni stock n’est garanti pour chaque graine. + +### Lacs et bassins étagés + +Le plan hydrologique conserve la recherche de rivière de +l’alpha.8 et vise de plus grands lacs de surface. Les cibles actuelles sont +de l’ordre de **700 à 1 100 colonnes** par grand lac, jusqu’à **7 à 8 blocs** +de profondeur intérieure et une incision limitée à **12 blocs**. Ce sont des +bornes de recherche ; les dimensions réelles sont relevées séparément, sans quota d’eau à +remplir. Les fonds, les parois et les dépôts exigent du terrain naturel. + +Les terrasses sont recherchées dans des cavités entre **Y=56 et Y=208**, +avec deux paliers par groupe et jusqu’à deux groupes facultatifs. +Un palier vise environ **40 à 180 colonnes**, une profondeur de **1 à 3 blocs** +et une incision d’au plus **5 blocs**. Le fond doit conserver trois couches +de sédiments et deux couches de support naturel ; le plafond et l’espace +disponible sont vérifiés. Les mesures moteur précisent les sites effectivement retenus. + +La recherche suit le choix de pente de l’eau vanilla : parmi les passages +libres, elle privilégie ceux qui rejoignent la chute la plus proche. Un +bassin inférieur doit intercepter ce trajet réel, sans détour creusé pour +le rejoindre. Les sorties entre paliers sont déclarées et les cascades sont formées par les +ticks de fluides Minecraft. Le premier débordement doit atteindre le second +bassin. Celui-ci peut avoir un exutoire terminal qui s’écoule sur les roches +ou vers le vide, sans troisième bassin ajouté. Le générateur ne doit ni peindre une colonne +d’eau jusqu’au vide ni colmater un trou pour retenir un bassin. Les volumes +retenus et les liaisons réelles sont vérifiés séparément après décoration +et simulation, y compris aux frontières de chunks. Les cascades peuvent +servir à monter ou descendre selon la nage vanilla ; aucune mécanique +d’ascenseur supplémentaire n’est prévue. « Rizières » décrit seulement +l’aspect du paysage en terrasses, sans ajouter une culture de riz. + +**Validation moteur au 9 septembre 2026 :** les trois graines de référence +passent les contrôles après 1 800 ticks, avec preuves cumulées des terrasses +connectées, geysers actifs, baies lumineuses, spéléothèmes et améthyste +bourgeonnante. Le build complet réussit. Les résultats et leurs limites sont +consignés dans [Validation](testing.md). Aucune présence universelle n’est +garantie ; la distribution est vérifiée séparément selon le guide packwiz. + ## Failles, géologie et ressources — génération v8 Les nouveaux mondes utilisent `sanctuary:sanctuary_rift`, la densité diff --git a/gradle.properties b/gradle.properties index 4282856..725ed57 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.8 -pack_version=0.1.0-alpha.8 +mod_version=0.1.0-alpha.9 +pack_version=0.1.0-alpha.9 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 9139888..9d1735c 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -179,3 +179,27 @@ tasks.register('riftLavaSmoke', JavaExec) { mainClass = 'fr.koka.sanctuary.worldgen.RiftLavaSmoke' } tasks.named('check') { dependsOn('riftLavaSmoke') } + +tasks.register('cavernPaletteSmoke', JavaExec) { + group = 'verification' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.CavernPaletteSmoke' +} +tasks.named('check') { dependsOn('cavernPaletteSmoke') } + +tasks.register('cavernHydrologySmoke', JavaExec) { + group = 'verification' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.CavernHydrologySmoke' +} +tasks.named('check') { dependsOn('cavernHydrologySmoke') } + +tasks.register('cavernLavaSmoke', JavaExec) { + group = 'verification' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.CavernLavaSmoke' +} +tasks.named('check') { dependsOn('cavernLavaSmoke') } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/CavernDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/CavernDiagnostics.java new file mode 100644 index 0000000..5bf0e0f --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/CavernDiagnostics.java @@ -0,0 +1,2446 @@ +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.CavernHydrology; +import fr.koka.sanctuary.worldgen.CavernTerraces; +import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; +import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime; +import fr.koka.sanctuary.worldgen.CavernLavaDeposit; +import fr.koka.sanctuary.worldgen.CavernLavaFlows; +import fr.koka.sanctuary.worldgen.CavernIslandBiomeSource; +import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime; +import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime; +import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime; +import fr.koka.sanctuary.worldgen.RiftIslandDensity; +import fr.koka.sanctuary.worldgen.RiftShape; +import fr.koka.sanctuary.worldgen.CavernMaterials; +import fr.koka.sanctuary.worldgen.CavernGrovesFeature; +import fr.koka.sanctuary.worldgen.CavernDecorationsFeature; +import fr.koka.sanctuary.worldgen.WoodlandGroveGeometry; +import net.minecraft.core.registries.BuiltInRegistries; +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.TreeSet; +import net.minecraft.core.BlockPos; +import net.fabricmc.loader.api.FabricLoader; +import java.time.Instant; +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.LightLayer; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.MultifaceBlock; +import net.minecraft.world.level.block.PotentSulfurBlock; +import net.minecraft.world.level.block.CaveVines; +import net.minecraft.world.level.block.entity.PotentSulfurBlockEntity; +import net.minecraft.world.level.block.state.properties.PotentSulfurState; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +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; +import java.util.stream.Stream; + +/** Checks the actual decorated terrain and fluid simulation in a disposable world, far from test fixtures. */ +public final class CavernDiagnostics { + private static final List HORIZONTAL = List.of(Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST); + + private CavernDiagnostics() {} + + public static void start(GameTestHelper helper) throws IOException { + Context context = new Context(helper); + try { + context.prepare(); + context.verifyNaturalShellAndFinishedWater(); + context.verifyAllObservedWaterIsPlanned(); + context.verifyRiftWitnesses(); + context.verifyRiverRoutes(); + context.verifyBiomesAndResources(); + try { + context.verifyRiftReferenceExamples(); + } catch (RuntimeException | Error failure) { + context.export("generated", false); + throw failure; + } + context.export("generated", true); + helper.assertTrue(context.caneBlocks > 0, + "Regression seed must provide at least one naturally valid shore sugar cane plant; inspect the exported site map"); + } catch (IOException | RuntimeException | Error failure) { + context.cleanup(); + throw failure; + } + // A loaded FULL chunk and a simulation ticket are not enough in 26.3: the + // fluid scheduler also waits for its ticking future AND entity storage load. + // Start the measured interval only once that exact production predicate passes. + helper.onEachTick(() -> { + try { + context.startWhenFluidChunksReady(); + context.observeGeysers(); + } catch (RuntimeException | Error failure) { + context.cleanup(); + throw failure; + } + }); + } + + public static void verifyLegacyIsolation(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + var active = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + helper.assertTrue(CavernHydrologyRuntime.enabled(active), "The new preset must enable alpha.9 hydrology"); + helper.assertFalse(HydrologyRuntime.enabled(active) || NaturalHydrologyRuntime.enabled(active) + || LayeredHydrologyRuntime.enabled(active) || WoodlandHydrologyRuntime.enabled(active) || RiftHydrologyRuntime.enabled(active), "Older hydrology applications must not run with alpha.9"); + helper.assertTrue(active.generatorSettings().is(SanctuarySpawn.CAVERN_SETTINGS), + "Alpha.9 must use its own settings key, preserving every previous world"); + var registry = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); + for (var key : List.of(SanctuarySpawn.SETTINGS, SanctuarySpawn.RAISED_SETTINGS, SanctuarySpawn.HYDROLOGY_SETTINGS, + SanctuarySpawn.NATURAL_SETTINGS, SanctuarySpawn.LAYERED_SETTINGS, SanctuarySpawn.WOODLAND_SETTINGS, SanctuarySpawn.RIFT_SETTINGS)) { + var legacy = new NoiseBasedChunkGenerator(active.getBiomeSource(), registry.getOrThrow(key)); + helper.assertFalse(CavernHydrologyRuntime.enabled(legacy), "Saved settings must not acquire alpha.9: " + key.identifier()); + helper.assertTrue(HydrologyRuntime.enabled(legacy) == key.equals(SanctuarySpawn.HYDROLOGY_SETTINGS), + "Only saved alpha.4 settings keep alpha.4 hydrology"); + helper.assertTrue(NaturalHydrologyRuntime.enabled(legacy) == key.equals(SanctuarySpawn.NATURAL_SETTINGS), + "Only saved alpha.5 settings keep alpha.5 hydrology"); + helper.assertTrue(LayeredHydrologyRuntime.enabled(legacy) == key.equals(SanctuarySpawn.LAYERED_SETTINGS), + "Only saved alpha.6 settings keep alpha.6 hydrology"); + helper.assertTrue(WoodlandHydrologyRuntime.enabled(legacy) == key.equals(SanctuarySpawn.WOODLAND_SETTINGS), + "Only saved alpha.7 settings keep alpha.7 hydrology"); + helper.assertTrue(RiftHydrologyRuntime.enabled(legacy) == key.equals(SanctuarySpawn.RIFT_SETTINGS), + "Only saved alpha.8 settings keep alpha.8 hydrology"); + helper.assertFalse(new CavernGrovesFeature().place(level, legacy, RandomSource.create(0), BlockPos.ZERO), + "Rift decoration must refuse every saved generation key"); + helper.assertFalse(new CavernDecorationsFeature().place(level, legacy, RandomSource.create(0), BlockPos.ZERO), + "Cave points of interest must refuse all previous saved generation keys"); + } + helper.assertTrue(active.getBiomeSource() instanceof CavernIslandBiomeSource, + "The active preset must use the versioned rift biome source"); + Set expected = Set.of("sanctuary:cavern_oak_forest", "sanctuary:cavern_birch_forest", + "sanctuary:cavern_clearing", "sanctuary:cavern_dry_woodland", "sanctuary:cavern_rocky_heath", + "sanctuary:cavern_dark_grove", "sanctuary:cavern_bamboo_grove", "sanctuary:cavern_sulfur_depths", + "sanctuary:cavern_lush_caves", "sanctuary:cavern_dripstone_caves"); + helper.assertTrue(active.getBiomeSource().possibleBiomes().stream().map(holder -> holder.getRegisteredName()) + .collect(java.util.stream.Collectors.toSet()).equals(expected), + "The cavern palette must have ten variants, with Dappled reserved for future continents"); + } + + /** Coarse finite coverage used by the independent lower-margin smoke check. Actual water + * provenance and absence of an escape through this coverage are checked by the full diagnostic. */ + public static boolean isWithinSpringInspectionFootprint(CavernHydrology.Plan plan, int x, int z) { + int cx = x >> 4, cz = z >> 4; + return waterBounds(plan).stream().anyMatch(bounds -> { + return cx >= (bounds.minX() >> 4) - 1 && cx <= (bounds.maxX() >> 4) + 1 + && cz >= (bounds.minZ() >> 4) - 1 && cz <= (bounds.maxZ() >> 4) + 1; + }); + } + + private static List waterBounds(CavernHydrology.Plan plan) { + return Stream.concat(plan.springs().stream().map(CavernHydrology.Spring::flowBounds), + plan.spills().stream().map(CavernHydrology.Spill::flowBounds)).toList(); + } + + public static boolean isWithinLavaInspectionFootprint(CavernLavaDeposit.Plan plan, int x, int z) { + int cx = x >> 4, cz = z >> 4; + return plan.flowBounds().stream().anyMatch(bounds -> cx >= (bounds.minX() >> 4) - 1 && cx <= (bounds.maxX() >> 4) + 1 + && cz >= (bounds.minZ() >> 4) - 1 && cz <= (bounds.maxZ() >> 4) + 1); + } + + private record FlowRoot(long id, String kind, long featureId, BlockPos position) {} + + private record FlowComponent(int id, List sourceIds, int actualWaterBlocks, + int newWaterBlocksSinceInitialSnapshot, CavernHydrology.FlowBounds actualBounds, + int outsideDensityPredictionVoxelCount, CavernHydrology.FlowBounds outsideDensityVoxelActualBounds, + int outsideDensityPredictionBoundingBoxCount, CavernHydrology.FlowBounds outsideDensityBoundingBoxActualBounds, + int horizontalNeighboursOutsideInspection, boolean reachesDimensionFloor) {} + + private record TreeWitness(String log, BlockPos base, List footprint, int verticalTrunkHeight, + BlockPos leafWitness, boolean lowerTerrace, int naturalRoofY, String biome) {} + private record MushroomWitness(String block, BlockPos base, int stalkHeight, int naturalRoofY) {} + private record RockPatch(String block, int exposedBlocks, BlockPos example, CavernHydrology.FlowBounds bounds) {} + private static final Set REMARKABLE_LOGS = Set.of("minecraft:cherry_log", "minecraft:spruce_log", + "minecraft:jungle_log", "minecraft:acacia_log", "minecraft:mangrove_log", "minecraft:pale_oak_log"); + + private static boolean isTerracePlant(BlockState state) { + return state.is(BlockTags.FLOWERS) || state.is(Blocks.SHORT_GRASS) || state.is(Blocks.MOSS_CARPET) + || state.is(Blocks.AZALEA) || state.is(Blocks.FLOWERING_AZALEA) || state.is(Blocks.FERN) || state.is(Blocks.BUSH) + || state.is(Blocks.BROWN_MUSHROOM) || state.is(Blocks.RED_MUSHROOM); + } + + private static boolean isTerrain(BlockState state) { + return state.is(Blocks.STONE) || state.is(BlockTags.DIRT) || state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.MOSS_BLOCK) + || state.is(Blocks.GRAVEL) || state.is(Blocks.SAND) || state.is(Blocks.CLAY) + || state.is(Blocks.PODZOL) || state.is(Blocks.MYCELIUM) + || state.is(BlockTags.BASE_STONE_OVERWORLD) || isRiftOre(state) + || state.is(Blocks.SULFUR) || state.is(Blocks.CINNABAR) || state.is(Blocks.COBBLESTONE) + || state.is(Blocks.MOSSY_COBBLESTONE) || state.is(Blocks.PACKED_MUD) || state.is(Blocks.BLACKSTONE) || state.is(Blocks.BASALT); + } + + /** Only the eight ore families actually configured in the rift biomes. */ + private static boolean isRiftOre(BlockState state) { + return state.is(Blocks.COAL_ORE) || state.is(Blocks.DEEPSLATE_COAL_ORE) + || state.is(Blocks.IRON_ORE) || state.is(Blocks.DEEPSLATE_IRON_ORE) + || state.is(Blocks.COPPER_ORE) || state.is(Blocks.DEEPSLATE_COPPER_ORE) + || state.is(Blocks.GOLD_ORE) || state.is(Blocks.DEEPSLATE_GOLD_ORE) + || state.is(Blocks.REDSTONE_ORE) || state.is(Blocks.DEEPSLATE_REDSTONE_ORE) + || state.is(Blocks.LAPIS_ORE) || state.is(Blocks.DEEPSLATE_LAPIS_ORE) + || state.is(Blocks.DIAMOND_ORE) || state.is(Blocks.DEEPSLATE_DIAMOND_ORE) + || state.is(Blocks.EMERALD_ORE) || state.is(Blocks.DEEPSLATE_EMERALD_ORE); + } + + private static final class Context { + final GameTestHelper helper; + final ServerLevel level; + final CavernHydrology.Plan plan; + final CavernLavaDeposit.Plan lavaPlan; + final Set expectedLava = new HashSet<>(); + final Set expectedPoolLava = new HashSet<>(); + final Set initialLavaFlow = new HashSet<>(); + final List> lavaFlowReports = new ArrayList<>(); + final RandomState random; + final DensityFunction natural; + final Set ownedCellAnchors = new HashSet<>(); + final Set declaredWater = new HashSet<>(); + final Set expectedWater = new HashSet<>(); + final Map naturalLogObstructions = new LinkedHashMap<>(); + final Set initialSpringWater = new HashSet<>(); + final Map biomeTargets = new TreeMap<>(); + final Map resourceCounts = new TreeMap<>(); + final Map surfaceBiomes = new TreeMap<>(); + final Map> naturalSurfaceCache = new HashMap<>(); + final List lowerTerraceTargets = new ArrayList<>(); + final Map lowerVegetation = new TreeMap<>(); + final Map lowerPlants = new LinkedHashMap<>(); + final List> thinNaturalLedgePlants = new ArrayList<>(); + final Set lowerSurfaceBiomes = new TreeSet<>(); + final Map surfaceMaterials = new TreeMap<>(); + final Map actualDryShoreTopMaterials = new TreeMap<>(); + final Map> actualDryShoreMaterialsByDepth = new TreeMap<>(); + final Map upperGround = new HashMap<>(); + final Map exposedRocks = new TreeMap<>(); + final Map exposedOreCounts = new TreeMap<>(), outdoorOreCounts = new TreeMap<>(); + final List> exposedOreExamples = new ArrayList<>(); + final List rockPatches = new ArrayList<>(); + final List trees = new ArrayList<>(); + final List mushrooms = new ArrayList<>(); + final Map hugeMushroomBlocks = new LinkedHashMap<>(); + final Map hugeMushroomCounts = new TreeMap<>(); + final List> hugeMushroomConnections = new ArrayList<>(); + final Map lowerSoilCounts = new TreeMap<>(); + final List> riftWitnessReports = new ArrayList<>(); + final List> riverReports = new ArrayList<>(); + final List> terraceReports = new ArrayList<>(); + final Map> geyserStates = new LinkedHashMap<>(); + final Set geyserWater = new HashSet<>(); + final Map caveDecorationWitnesses = new LinkedHashMap<>(); + final Map caveDecorationCounts = new TreeMap<>(); + final List> geodeConnections = new ArrayList<>(); + final RiftIslandDensity riftDensity; + final RiftShape.Layout riftLayout; + final Map upperTreeCounts = new TreeMap<>(), lowerTreeCounts = new TreeMap<>(); + final Map remarkableTreeReport = new LinkedHashMap<>(); + final Set ecologyExtraChunks = new TreeSet<>(); + final List centralTerrainTargets = new ArrayList<>(); + final List> incompleteTreeEvidence = new ArrayList<>(); + int interiorOakTrees; + int innerGroundColumns; + int innerCanopyColumns; + int exteriorGroundColumns; + int exteriorCanopyColumns; + int lowerPlantsOnMoss; + int upperFlowers; + int verticalBiomeContrasts; + final Map springDescent = new TreeMap<>(); + final List actualFlowComponents = new ArrayList<>(); + final Map componentBySource = new HashMap<>(); + final List flowRoots = new ArrayList<>(); + int actualWaterBlocks; + int springWaterBlocks; + int maxDryShoreDistance; + final Map chunks = new TreeMap<>(); + final Set newlyForced = new HashSet<>(); + final List witnesses = new ArrayList<>(); + final Set fluidChunks = new HashSet<>(); + boolean witnessPlaced; + boolean simulationStarted; + boolean closed; + long entityLoadWaitMillis; + long simulationStartGameTime; + boolean allWaterChunksTicking; + boolean springMouthsCheckedAfterPostProcessing; + int seams; + int caneBlocks; + + Context(GameTestHelper helper) { + this.helper = helper; + level = helper.getLevel(); + var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + helper.assertTrue(CavernHydrologyRuntime.enabled(generator), "Hydrology test requires the production wet preset"); + plan = CavernHydrologyRuntime.cavernPlan(level); + lavaPlan = CavernHydrologyRuntime.lavaPlan(level); + random = level.getChunkSource().randomState(); + natural = generator.generatorSettings().value().noiseRouter().finalDensity(); + var density = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow( + ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density_rift"))).value(); + helper.assertTrue(density instanceof RiftIslandDensity, "The new settings must register the versioned rift density"); + riftDensity = (RiftIslandDensity) density; + riftLayout = riftDensity.layout(random); + } + + 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()); + } + helper.assertTrue(plan.features().stream().allMatch(feature -> feature.kind() == CavernHydrology.Kind.POND + || feature.kind() == CavernHydrology.Kind.LAKE || feature.kind() == CavernHydrology.Kind.RIVER + || feature.kind() == CavernHydrology.Kind.TERRACE), + "Every wet feature must be a declared pond, lake or long river; no short-stream placeholder"); + for (var spring : plan.springs()) { + SanctuaryMod.LOGGER.info("Natural spring planned: seed {}, source {}, outlet {}, envelope {}", + level.getSeed(), spring.source(), spring.outlet(), spring.flowBounds()); + } + if (level.getSeed() == 0L) helper.assertTrue(!plan.springs().isEmpty(), + "Reference seed 0 must demonstrate a real rock spring, without imposing spring quotas on other seeds"); + Set requested = new HashSet<>(); + for (var cell : plan.cells()) { + helper.assertTrue(ownedCellAnchors.add(new BlockPos(cell.x(), cell.bedY(), cell.z())), + "A plan must own each three-dimensional basin column exactly once"); + if (cell.hasWater()) { + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + declaredWater.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)); + } + } + for (var spring : plan.springs()) { + var bounds = spring.flowBounds(); + for (int cx = (bounds.minX() >> 4) - 1; cx <= (bounds.maxX() >> 4) + 1; cx++) { + for (int cz = (bounds.minZ() >> 4) - 1; cz <= (bounds.maxZ() >> 4) + 1; cz++) { + requested.add(ChunkPos.pack(cx, cz)); + } + } + } + for (var cell : lavaPlan.lavaCells()) expectedLava.add(new BlockPos(cell.x(), cell.y(), cell.z())); + expectedPoolLava.addAll(expectedLava); + for (var source : lavaPlan.flowSources()) expectedPoolLava.remove(lavaPosition(source)); + for (var bounds : lavaPlan.flowBounds()) { + for (int cx = (bounds.minX() >> 4) - 1; cx <= (bounds.maxX() >> 4) + 1; cx++) + for (int cz = (bounds.minZ() >> 4) - 1; cz <= (bounds.maxZ() >> 4) + 1; cz++) requested.add(ChunkPos.pack(cx, cz)); + } + for (var spill : plan.spills()) { + var bounds = spill.flowBounds(); + for (int cx = (bounds.minX() >> 4) - 1; cx <= (bounds.maxX() >> 4) + 1; cx++) + for (int cz = (bounds.minZ() >> 4) - 1; cz <= (bounds.maxZ() >> 4) + 1; cz++) requested.add(ChunkPos.pack(cx, cz)); + } + Set lavaChunks = new HashSet<>(); + for (var cell : lavaPlan.cells()) lavaChunks.add(ChunkPos.pack(cell.x() >> 4, cell.z() >> 4)); + for (var support : lavaPlan.supports()) lavaChunks.add(ChunkPos.pack(support.x() >> 4, support.z() >> 4)); + for (long packed : lavaChunks) { + var p = ChunkPos.unpack(packed); + for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) requested.add(ChunkPos.pack(p.x() + dx, p.z() + dz)); + } + SanctuaryMod.LOGGER.info("Starter lava planned, seed {}: {} sources; access {}", level.getSeed(), expectedLava.size(), lavaPlan.access()); + findBiomeTargets(requested); + for (var rift : riftLayout.rifts()) { + int cx = (int) Math.rint(rift.main().x()) >> 4, cz = (int) Math.rint(rift.main().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(!declaredWater.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()); + List orderedChunks = requested.stream().sorted().toList(); + // Declare the whole finite footprint before loading it, so neighbouring + // chunk promotions cannot temporarily remove another site's support tickets. + for (long packed : orderedChunks) { + ChunkPos pos = ChunkPos.unpack(packed); + if (level.setChunkForced(pos.x(), pos.z(), true)) newlyForced.add(packed); + } + for (long packed : orderedChunks) { + ChunkPos pos = ChunkPos.unpack(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); + } + // GameTestServer runs accelerated ticks: 600 ticks can elapse in less + // than a second, so they do not bound the asynchronous entity I/O wait. + // Minecraft's own helper pumps completed entity loads via managedBlock. + // Radius zero waits only for each already inspected, forced FULL chunk. + long entityWaitStarted = System.nanoTime(); + for (long packed : orderedChunks) level.waitForEntities(ChunkPos.unpack(packed), 0); + entityLoadWaitMillis = (System.nanoTime() - entityWaitStarted) / 1_000_000; + helper.assertTrue(orderedChunks.stream().allMatch(level::areEntitiesLoaded), + "Official entity loading must finish for every inspected chunk before fluid readiness is measured"); + SanctuaryMod.LOGGER.info("Hydrology seed {}: official entity loading completed for {} existing chunks in {} ms", + level.getSeed(), orderedChunks.size(), entityLoadWaitMillis); + captureCavernDecorations(); + captureNaturalLogObstructions(); + for (var spring : plan.springs()) flowRoots.add(new FlowRoot(spring.id(), "rock_spring", spring.id(), position(spring.source()))); + for (var spill : plan.spills()) flowRoots.add(new FlowRoot(spill.id(), "terrace_spill", spill.fromFeatureId(), position(spill.source()))); + BlockPos first = expectedWater.stream().min(Comparator.comparingInt((BlockPos pos) -> pos.getX()) + .thenComparingInt(BlockPos::getZ).thenComparingInt(BlockPos::getY)).orElseThrow(); + Set witnessChunks = new TreeSet<>(); + witnessChunks.add(ChunkPos.pack(first)); + for (BlockPos water : expectedWater) fluidChunks.add(ChunkPos.pack(water)); + for (BlockPos lava : expectedLava) { + fluidChunks.add(ChunkPos.pack(lava)); + witnessChunks.add(ChunkPos.pack(lava)); + } + for (var spring : plan.springs()) { + witnessChunks.add(ChunkPos.pack(position(spring.source()))); + var bounds = spring.flowBounds(); + for (int cx = bounds.minX() >> 4; cx <= bounds.maxX() >> 4; cx++) { + for (int cz = bounds.minZ() >> 4; cz <= bounds.maxZ() >> 4; cz++) fluidChunks.add(ChunkPos.pack(cx, cz)); + } + } + // Decoration may divert water into an already inspected halo chunk. Wait for + // the whole existing finite footprint; this adds no chunk or ticket to the test. + fluidChunks.addAll(chunks.keySet()); + for (long packed : witnessChunks) { + var pos = ChunkPos.unpack(packed); + witnesses.add(new BlockPos(pos.getMinBlockX() + 8, level.getMaxY() - 20, pos.getMinBlockZ() + 8)); + } + logSpringStates("initial FULL chunks"); + 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); + } + + boolean hasNaturalRoof(int x, int fromY, int z) { + for (int y = fromY; y < level.getMaxY(); y++) if (sample(x, y, z) > 0) return true; + return false; + } + + Map positionMap(BlockPos p) { + return Map.of("x", p.getX(), "y", p.getY(), "z", p.getZ()); + } + + boolean isCaveDecoration(BlockState state) { + return state.is(Blocks.CAVE_VINES) || state.is(Blocks.CAVE_VINES_PLANT) + || state.is(Blocks.POINTED_DRIPSTONE) + || state.is(Blocks.AMETHYST_BLOCK) || state.is(Blocks.BUDDING_AMETHYST) + || state.is(Blocks.SMALL_AMETHYST_BUD) || state.is(Blocks.MEDIUM_AMETHYST_BUD) + || state.is(Blocks.LARGE_AMETHYST_BUD) || state.is(Blocks.AMETHYST_CLUSTER) + || state.is(Blocks.POTENT_SULFUR); + } + + /** Read real decorated blocks before any fluid simulation. Only the exact contained + * geyser sources receive a separate declaration; no other unknown water is accepted. */ + void captureCavernDecorations() { + for (LevelChunk chunk : chunks.values()) chunk.findBlocks(this::isCaveDecoration, (mutable, state) -> { + BlockPos p = mutable.immutable(); + helper.assertTrue(p.getY() < 230 && hasNaturalRoof(p.getX(), p.getY() + 1, p.getZ()), + "Cave-only decoration must remain below the upper surface and a natural roof: " + p + "=" + state); + helper.assertTrue(state.canSurvive(level, p), "A cave decoration must have valid actual support: " + p + "=" + state); + caveDecorationWitnesses.put(p, state); + caveDecorationCounts.merge(blockId(state), 1, Integer::sum); + if (state.getLightEmission() > 0) caveDecorationCounts.merge("light_emitting_blocks", 1, Integer::sum); + if (CaveVines.hasGlowBerries(state)) caveDecorationCounts.merge("glow_berry_blocks", 1, Integer::sum); + if (!state.is(Blocks.POTENT_SULFUR)) return; + BlockPos water = p.above(); + helper.assertTrue(level.getBlockEntity(p) instanceof PotentSulfurBlockEntity, + "A natural geyser must use the real vanilla block entity: " + p); + helper.assertTrue(level.getBlockState(p.below()).is(Blocks.MAGMA_BLOCK), + "The periodic sulfur geyser needs magma below, not a cosmetic plume: " + p); + helper.assertTrue(level.getFluidState(water).is(FluidTags.WATER) && level.getFluidState(water).isSource(), + "A sulfur geyser needs its contained water source: " + p); + helper.assertTrue(sample(water.getX(), water.getY(), water.getZ()) > 0 + && sample(p.getX(), p.getY(), p.getZ()) > 0 && sample(p.getX(), p.getY() - 1, p.getZ()) > 0, + "The geyser's water, sulfur and magma must replace existing rock: " + p); + for (Direction side : HORIZONTAL) { + BlockPos wall = water.relative(side); + helper.assertTrue(sample(wall.getX(), wall.getY(), wall.getZ()) > 0 + && level.getBlockState(wall).isCollisionShapeFullBlock(level, wall), + "A geyser water source must keep its natural side walls: " + wall); + } + helper.assertTrue(declaredWater.add(water), "A geyser must not overlap a hydrology source: " + p); + geyserWater.add(water); + geyserStates.put(p, new TreeSet<>()); + }); + SanctuaryMod.LOGGER.info("Cavern decorations seed {}: {}", level.getSeed(), caveDecorationCounts); + verifyGeodeAccess(); + } + + void observeGeysers() { + if (!simulationStarted || closed) return; + for (var entry : geyserStates.entrySet()) { + BlockState state = level.getBlockState(entry.getKey()); + helper.assertTrue(state.is(Blocks.POTENT_SULFUR), "The ticking geyser must retain its sulfur block"); + entry.getValue().add(state.getValue(PotentSulfurBlock.STATE).getSerializedName()); + } + } + + void verifyCavernDecorationsAfterTicks() { + for (var entry : caveDecorationWitnesses.entrySet()) { + BlockPos p = entry.getKey(); + BlockState state = level.getBlockState(p); + helper.assertTrue(isCaveDecoration(state) && state.canSurvive(level, p), + "Cave decoration must remain supported after real fluids and growth ticks: " + p + "=" + state); + if (state.getLightEmission() > 0) helper.assertTrue(level.getBrightness(LightLayer.BLOCK, p) > 0, + "The cave light source must illuminate the actual block-light engine: " + p); + if (entry.getValue().is(Blocks.BUDDING_AMETHYST)) helper.assertTrue(state.is(Blocks.BUDDING_AMETHYST), + "The renewable budding amethyst must survive generation and fluid ticks: " + p); + } + for (var entry : geyserStates.entrySet()) { + helper.assertTrue(entry.getValue().contains(PotentSulfurState.DORMANT.getSerializedName()) + && entry.getValue().contains(PotentSulfurState.ERUPTING.getSerializedName()), + "A sulfur geyser must really alternate between dormant and erupting without test activation: " + + entry.getKey() + " states=" + entry.getValue()); + helper.assertTrue(level.getFluidState(entry.getKey().above()).isSource(), + "The periodic geyser must retain its water after simulation"); + } + verifyGeodeAccess(); + } + + void verifyGeodeAccess() { + geodeConnections.clear(); + Map> groups = new TreeMap<>(); + for (var entry : caveDecorationWitnesses.entrySet()) if (entry.getValue().is(Blocks.BUDDING_AMETHYST)) + groups.computeIfAbsent(ChunkPos.pack(entry.getKey()), ignored -> new ArrayList<>()).add(entry.getKey()); + for (var entry : groups.entrySet()) { + LevelChunk chunk = chunks.get(entry.getKey()); + var buds = entry.getValue(); + int minY = buds.stream().mapToInt(BlockPos::getY).min().orElseThrow() - 5; + int maxY = Math.min(230, buds.stream().mapToInt(BlockPos::getY).max().orElseThrow() + 18); + Set visited = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + for (BlockPos bud : buds) for (Direction side : Direction.values()) { + BlockPos p = bud.relative(side); + if (ChunkPos.pack(p) == entry.getKey() && chunk.getBlockState(p).isAir() && visited.add(p)) queue.add(p); + } + BlockPos cavity = null; + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + if (sample(p.getX(), p.getY(), p.getZ()) <= 0 && hasNaturalRoof(p.getX(), p.getY() + 1, p.getZ())) { + cavity = p; break; + } + for (Direction side : Direction.values()) { + BlockPos next = p.relative(side); + if (next.getY() < minY || next.getY() > maxY || ChunkPos.pack(next) != entry.getKey()) continue; + if (visited.add(next) && chunk.getBlockState(next).isAir()) queue.addLast(next); + } + } + helper.assertTrue(cavity != null, "A generated geode with renewable amethyst must open into an existing cavity: " + chunk.getPos()); + geodeConnections.add(Map.of("owner_chunk", chunk.getPos(), "budding_blocks", buds.size(), + "cavity_connection", positionMap(cavity), "checked_after_fluid_ticks", allWaterChunksTicking)); + } + } + + void verifyTerraceFlows() { + terraceReports.clear(); + for (var group : plan.terraces()) { + var basins = plan.features().stream().filter(f -> group.basinIds().contains(f.id())).toList(); + helper.assertTrue(basins.size() >= 2 && basins.stream().map(CavernHydrology.Feature::waterY).distinct().count() >= 2, + "A terrace group must contain actual basins on distinct vertical levels: " + group); + for (var basin : basins) { + long actual = plan.cells().stream().filter(c -> c.featureId() == basin.id() && c.hasWater()) + .filter(c -> level.getFluidState(new BlockPos(c.x(), c.waterY(), c.z())).isSource()).count(); + helper.assertTrue(actual == basin.waterCells() && actual >= 40, + "Each terrace must retain its significant actual water surface: " + basin + " actual=" + actual); + } + } + for (var spill : plan.spills()) { + BlockPos source = position(spill.source()), outlet = position(spill.outlet()); + String spillLabel = spill.id() + " source=" + source + " outlet=" + outlet + " target=" + spill.toFeatureId(); + helper.assertTrue(expectedWater.contains(source) && level.getFluidState(source).isSource(), + "A terrace cascade must come from its real retained basin"); + helper.assertTrue(source.distManhattan(outlet) == 1 && level.getFluidState(outlet).is(FluidTags.WATER), + "A declared spill must feed its adjacent outlet after vanilla ticks: " + spillLabel); + FlowComponent component = componentBySource.get(spill.id()); + helper.assertTrue(component != null && component.actualBounds().minY() < source.getY() + && component.newWaterBlocksSinceInitialSnapshot() > 0, + "A terrace outlet must produce an actual descending cascade, not a prefilled column: " + spillLabel); + Set reachable = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); queue.add(outlet); reachable.add(outlet); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + for (Direction direction : Direction.values()) { + if (direction == Direction.UP) continue; + BlockPos next = p.relative(direction); + if (next.getY() < level.getMinY() || !chunks.containsKey(ChunkPos.pack(next)) || reachable.contains(next)) continue; + if (level.getFluidState(next).is(FluidTags.WATER)) { reachable.add(next); queue.addLast(next); } + } + } + int descent = source.getY() - reachable.stream().mapToInt(BlockPos::getY).min().orElse(source.getY()); + long newWater = reachable.stream().filter(p -> !expectedWater.contains(p) && !initialSpringWater.contains(p)).count(); + helper.assertTrue(descent > 0 && newWater > 0, + "Each outlet must produce its own descending path through actual new water: " + spillLabel); + boolean reachesTarget = spill.toFeatureId() == -1; + int targetY = -1; + if (spill.toFeatureId() != -1) { + var target = plan.features().stream().filter(f -> f.id() == spill.toFeatureId()).findFirst().orElseThrow(); + targetY = target.waterY(); + helper.assertTrue(targetY < source.getY(), "A receiving terrace must lie below its source"); + reachesTarget = reachable.stream().anyMatch(p -> { + var cell = plan.cellAt(p.getX(), p.getY(), p.getZ()); + return cell != null && cell.featureId() == target.id() && cell.hasWater() + && p.getY() > cell.bedY() && p.getY() <= cell.waterY(); + }); + helper.assertTrue(reachesTarget, "The real descending waterfall must connect both terrace basins: " + spillLabel); + } else helper.assertTrue(descent >= 12, "A terminal terrace waterfall must descend at least twelve actual blocks: " + spillLabel); + terraceReports.add(Map.of("spill_id", spill.id(), "source", spill.source(), "target_feature", spill.toFeatureId(), + "target_y", targetY, "reaches_target", reachesTarget, "actual_descent", descent, + "new_reachable_flow_blocks", newWater, "checked_after_fluid_ticks", true)); + } + } + + void captureNaturalLogObstructions() { + expectedWater.addAll(declaredWater); + for (BlockPos p : declaredWater.stream().sorted(Comparator.comparingInt((BlockPos pos) -> pos.getX()) + .thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ)).toList()) { + BlockState state = level.getBlockState(p); + if (state.getFluidState().is(FluidTags.WATER) && state.getFluidState().isSource()) continue; + var cell = plan.cellAt(p.getX(), p.getY(), p.getZ()); + boolean horizontalLog = state.is(BlockTags.LOGS) && state.hasProperty(BlockStateProperties.AXIS) + && state.getValue(BlockStateProperties.AXIS) != Direction.Axis.Y; + boolean submerged = declaredWater.contains(p.below()) + && level.getFluidState(p.below()).is(FluidTags.WATER) && level.getFluidState(p.below()).isSource(); + boolean shallowEdge = cell.bedY() == p.getY() - 1 + && level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below()); + boolean besideWater = HORIZONTAL.stream().map(p::relative).anyMatch(side -> declaredWater.contains(side) + && level.getFluidState(side).is(FluidTags.WATER) && level.getFluidState(side).isSource()); + helper.assertTrue(horizontalLog && p.getY() == cell.waterY() && (submerged || shallowEdge) && besideWater, + "Only a horizontal surface log beside retained source water, on the bed or with water underneath, may occupy basin water: " + + p + ", actual=" + state); + naturalLogObstructions.put(p.immutable(), state); + expectedWater.remove(p); + } + helper.assertTrue(declaredWater.size() == expectedWater.size() + naturalLogObstructions.size(), + "Every declared water block must be accounted for as retained water or a precise natural log obstruction"); + SanctuaryMod.LOGGER.info("Natural water after decoration, seed {}: {} declared, {} retained sources, {} horizontal surface logs: {}", + level.getSeed(), declaredWater.size(), expectedWater.size(), naturalLogObstructions.size(), naturalLogObstructions); + } + + void verifyNaturalShellAndFinishedWater() { + verifySedimentProfiles(); + verifySpringOrigins(); + verifyStarterLava(); + for (var cell : plan.cells()) { + if (!cell.hasWater()) continue; + helper.assertTrue(cell.waterY() > cell.bedY() && cell.waterY() - cell.bedY() <= (plan.isTerraceCell(cell) ? CavernTerraces.MAX_DEPTH : CavernHydrology.MAX_DEPTH), + "Each basin must keep its own water depth budget"); + helper.assertTrue(cell.carveTop() - cell.bedY() <= allowedWetCut(cell), + "Each basin must keep its own excavation budget"); + 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()); + BlockState obstruction = naturalLogObstructions.get(water); + if (obstruction == null) { + helper.assertTrue(level.getFluidState(water).is(FluidTags.WATER) && level.getFluidState(water).isSource(), + "Every unobstructed planned water cell must remain a retained source: " + water); + } else { + helper.assertTrue(level.getBlockState(water).equals(obstruction), + "A recorded natural surface log must retain exactly its original block state after fluid ticks: " + water); + if (expectedWater.contains(water.below())) { + helper.assertTrue(level.getFluidState(water.below()).is(FluidTags.WATER) + && level.getFluidState(water.below()).isSource(), + "A submerged natural log must retain source water underneath: " + water); + } else { + helper.assertTrue(cell.bedY() == water.getY() - 1 + && level.getBlockState(water.below()).isCollisionShapeFullBlock(level, water.below()), + "A log at the shallow edge must retain its supported natural bed: " + water); + } + helper.assertTrue(HORIZONTAL.stream().map(water::relative).anyMatch(side -> expectedWater.contains(side) + && level.getFluidState(side).is(FluidTags.WATER) && level.getFluidState(side).isSource()), + "Natural surface logs must remain beside actual retained water: " + water); + } + for (Direction direction : HORIZONTAL) { + BlockPos side = water.relative(direction); + // A recorded log occupies the interior of the original basin volume; + // it must not be mistaken for an exterior wall that was originally rock. + if (declaredWater.contains(side) || plan.isSpillOpening(side.getX(), side.getY(), side.getZ())) 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. + if (plan.isTerraceCell(cell)) { + for (int y = cell.carveTop() + 1; y <= cell.carveTop() + 4; y++) + helper.assertTrue(sample(cell.x(), y, cell.z()) <= 0, "A terrace needs four blocks of original headroom: " + cell); + helper.assertTrue(cell.waterY() >= CavernTerraces.MIN_Y && cell.waterY() <= CavernTerraces.MAX_Y + && hasNaturalRoof(cell.x(), cell.carveTop() + 5, cell.z()), + "A lower terrace must remain under a natural cave roof with headroom: " + cell); + continue; + } + 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()); + } + } + } + + void verifyAllObservedWaterIsPlanned() { + int[] observed = {0, 0}; + Set actualFlow = new HashSet<>(); + 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)) { + if (expectedWater.contains(pos)) observed[0]++; + else { + actualFlow.add(pos.immutable()); + if (!allWaterChunksTicking) initialSpringWater.add(pos.immutable()); + } + } + if (state.is(Blocks.SUGAR_CANE)) { + helper.assertTrue(state.canSurvive(level, pos), "Generated shore sugar cane must survive at " + pos); + observed[1]++; + } + }); + } + helper.assertTrue(observed[0] == expectedWater.size(), + "Every closed basin must retain all declared sources except its recorded natural surface logs"); + analyseActualFlow(actualFlow); + for (var spring : plan.springs()) helper.assertTrue(actualFlow.contains(position(spring.source())), + "Every declared spring must remain a real source outside the closed basins: " + spring.source()); + helper.assertTrue(actualFlowComponents.stream().mapToInt(FlowComponent::actualWaterBlocks).sum() == actualFlow.size(), + "The connected components must account for every observed water voxel outside retained basins"); + for (FlowComponent component : actualFlowComponents) { + helper.assertTrue(!component.sourceIds().isEmpty(), + "All water outside retained basin cells must be connected through real six-neighbour water to a declared rock spring: " + component); + helper.assertTrue(component.horizontalNeighboursOutsideInspection() == 0, + "Spring water reached a horizontal boundary of the inspected chunks; its containment is not proven: " + component); + } + actualWaterBlocks = observed[0] + actualFlow.size(); + springWaterBlocks = actualFlow.size(); + caneBlocks = observed[1]; + } + + /** Inspect actual connected water after decoration, without loading further chunks or + * assuming that the earlier bare-rock route can predict canopy-induced deviations. */ + void analyseActualFlow(Set actualFlow) { + actualFlowComponents.clear(); + componentBySource.clear(); + Set visited = new HashSet<>(); + Set graph = new HashSet<>(actualFlow); + List starts = new ArrayList<>(); + for (FlowRoot root : flowRoots) { + starts.add(root.position()); + if (level.getFluidState(root.position()).is(FluidTags.WATER)) graph.add(root.position()); + } + // Keep orphan components in the report too, so an assertion failure remains reviewable. + starts.addAll(actualFlow.stream().sorted(Comparator.comparingInt((BlockPos p) -> p.getX()) + .thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ)).toList()); + for (BlockPos start : starts) { + if (!graph.contains(start) || !visited.add(start)) continue; + Set componentWater = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(start); + int minX = start.getX(), maxX = minX, minY = start.getY(), maxY = minY, minZ = start.getZ(), maxZ = minZ; + int newWater = 0, externalWater = 0, missingNeighbours = 0; + List outsidePrediction = new ArrayList<>(); + List outsideBounds = new ArrayList<>(); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + componentWater.add(p); + minX = Math.min(minX, p.getX()); maxX = Math.max(maxX, p.getX()); + minY = Math.min(minY, p.getY()); maxY = Math.max(maxY, p.getY()); + minZ = Math.min(minZ, p.getZ()); maxZ = Math.max(maxZ, p.getZ()); + if (actualFlow.contains(p)) { + externalWater++; + if (!initialSpringWater.contains(p)) newWater++; + if (!plan.allowsSpringFlow(p.getX(), p.getY(), p.getZ())) outsidePrediction.add(p); + if (!isWithinDensityPredictionBounds(p)) outsideBounds.add(p); + } + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + if (direction != Direction.UP && direction != Direction.DOWN + && !chunks.containsKey(ChunkPos.pack(next))) missingNeighbours++; + if (graph.contains(next) && visited.add(next)) queue.addLast(next); + } + } + List sources = flowRoots.stream().filter(root -> componentWater.contains(root.position())) + .map(FlowRoot::id).sorted().toList(); + var component = new FlowComponent(actualFlowComponents.size(), sources, externalWater, newWater, + new CavernHydrology.FlowBounds(minX, minY, minZ, maxX, maxY, maxZ), + outsidePrediction.size(), actualBoundsOf(outsidePrediction), outsideBounds.size(), actualBoundsOf(outsideBounds), + missingNeighbours, minY == level.getMinY()); + actualFlowComponents.add(component); + for (long source : sources) componentBySource.put(source, component); + } + } + + CavernHydrology.FlowBounds actualBoundsOf(List positions) { + if (positions.isEmpty()) return null; + BlockPos first = positions.getFirst(); + int minX = first.getX(), maxX = minX, minY = first.getY(), maxY = minY, minZ = first.getZ(), maxZ = minZ; + for (BlockPos p : positions) { + minX = Math.min(minX, p.getX()); maxX = Math.max(maxX, p.getX()); + minY = Math.min(minY, p.getY()); maxY = Math.max(maxY, p.getY()); + minZ = Math.min(minZ, p.getZ()); maxZ = Math.max(maxZ, p.getZ()); + } + return new CavernHydrology.FlowBounds(minX, minY, minZ, maxX, maxY, maxZ); + } + + boolean isWithinDensityPredictionBounds(BlockPos p) { + return waterBounds(plan).stream().anyMatch(b -> { + return p.getX() >= b.minX() && p.getX() <= b.maxX() && p.getY() >= b.minY() && p.getY() <= b.maxY() + && p.getZ() >= b.minZ() && p.getZ() <= b.maxZ(); + }); + } + + boolean riverCell(CavernHydrology.Cell cell) { + return cell != null && plan.features().stream().anyMatch(feature -> feature.id() == cell.featureId() + && feature.kind() == CavernHydrology.Kind.RIVER); + } + + boolean riverSpring(CavernHydrology.Spring spring) { + var cell = plan.cellAt(spring.outlet().x(), spring.outlet().z()); + return riverCell(cell) && spring.outlet().y() > cell.waterY() && spring.outlet().y() <= cell.carveTop(); + } + + void verifyRiftWitnesses() { + riftWitnessReports.clear(); + helper.assertTrue(!riftLayout.rifts().isEmpty(), "The alpha.9 reference layout must contain its seeded fractures"); + for (var rift : riftLayout.rifts()) { + int x = (int) Math.rint(rift.main().x()), z = (int) Math.rint(rift.main().z()); + LevelChunk chunk = chunks.get(ChunkPos.pack(x >> 4, z >> 4)); + helper.assertTrue(chunk != null, "A rift core witness must use an already inspected chunk"); + int formerRock = 0, air = 0, water = 0, lava = 0, decoration = 0; + for (int y = level.getMinY(); y <= level.getMaxY(); y++) { + BlockPos p = new BlockPos(x, y, z); + float before = random.sampleBlockValueUncached(riftDensity.terrain(), x, y, z); + if (before > 0) formerRock++; + helper.assertTrue(sample(x, y, z) <= 0, "The compiled fracture core must remain open through every height: " + p); + BlockState state = chunk.getBlockState(p); + helper.assertFalse(isTerrain(state), "No ground or sediment may refill the natural fracture core: " + p + "=" + state); + if (state.isAir()) air++; + else if (state.getFluidState().is(FluidTags.WATER)) water++; + else if (state.getFluidState().is(FluidTags.LAVA)) lava++; + else decoration++; + } + riftWitnessReports.add(Map.of("rift_id", rift.id(), "x", x, "z", z, "former_rock_blocks_removed", formerRock, + "actual_air", air, "actual_water", water, "actual_lava", lava, "decoration_crossing_core", decoration, + "scope", "Entire dimension height; fluid provenance is checked separately, natural canopy crossings are reported")); + } + } + + BlockPos riverWaterNear(CavernHydrology.Feature river, CavernHydrology.Point point) { + for (int radius = 0; radius <= 2; radius++) for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue; + var cell = plan.cellAt(point.x() + dx, point.z() + dz); + if (cell == null || !cell.hasWater() || cell.featureId() != river.id()) continue; + for (int y = cell.waterY(); y > cell.bedY(); y--) { + BlockPos p = new BlockPos(cell.x(), y, cell.z()); + if (expectedWater.contains(p) && inspectedState(p) != null && inspectedState(p).getFluidState().is(FluidTags.WATER)) return p; + } + } + throw new IllegalStateException("River centreline has no retained water in its declared width: river=" + river.id() + ", point=" + point); + } + + boolean locallyConnectedRiver(CavernHydrology.Feature river, BlockPos from, BlockPos to) { + // The generated channel is five blocks wide. A three-block margin covers + // one complete local cross-section, never a distant route around a broken seam. + int minX = Math.min(from.getX(), to.getX()) - 3, maxX = Math.max(from.getX(), to.getX()) + 3; + int minZ = Math.min(from.getZ(), to.getZ()) - 3, maxZ = Math.max(from.getZ(), to.getZ()) + 3; + Set seen = new HashSet<>(); ArrayDeque queue = new ArrayDeque<>(); + queue.add(from); seen.add(from); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + if (p.equals(to)) return true; + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + if (next.getX() < minX || next.getX() > maxX || next.getZ() < minZ || next.getZ() > maxZ + || !expectedWater.contains(next) || !seen.add(next)) continue; + var cell = plan.cellAt(next.getX(), next.getZ()); + BlockState state = inspectedState(next); + if (cell != null && cell.featureId() == river.id() && state != null && state.getFluidState().is(FluidTags.WATER)) queue.addLast(next); + } + } + return false; + } + + boolean actualSpringReachesRiver(CavernHydrology.Spring spring) { + var outletCell = plan.cellAt(spring.outlet().x(), spring.outlet().z()); + if (!riverCell(outletCell)) return false; + Set seen = new HashSet<>(); ArrayDeque queue = new ArrayDeque<>(); + BlockPos source = position(spring.source()); queue.add(source); seen.add(source); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + var cell = plan.cellAt(p.getX(), p.getY(), p.getZ()); + if (expectedWater.contains(p)) { + if (cell != null && cell.featureId() == outletCell.featureId()) return true; + continue; + } + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + BlockState state = inspectedState(next); + if (state != null && state.getFluidState().is(FluidTags.WATER) && seen.add(next)) queue.addLast(next); + } + } + return false; + } + + Map verifyRiverEndPool(CavernHydrology.Feature river, CavernHydrology.Point endpoint, BlockPos root) { + BlockState rootState = inspectedState(root); + helper.assertTrue(rootState != null && rootState.getFluidState().is(FluidTags.WATER) + && rootState.getFluidState().isSource() && expectedWater.contains(root), + "The terminal-pool traversal must start in a retained real source: endpoint=" + endpoint + ", root=" + root); + Set seen = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + Set columns = new HashSet<>(); + queue.add(root); seen.add(root); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + columns.add(new CavernHydrology.Point(p.getX(), p.getZ())); + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + if (Math.hypot(next.getX() - endpoint.x(), next.getZ() - endpoint.z()) > 12 + || seen.contains(next) || !expectedWater.contains(next)) continue; + var cell = plan.cellAt(next.getX(), next.getZ()); + BlockState state = inspectedState(next); + if (cell != null && cell.featureId() == river.id() && state != null + && state.getFluidState().is(FluidTags.WATER) && state.getFluidState().isSource()) { + seen.add(next); queue.addLast(next); + } + } + } + var ordered = columns.stream().sorted(Comparator.comparingInt(CavernHydrology.Point::x) + .thenComparingInt(CavernHydrology.Point::z)).toList(); + // The channel radius is at most three. A distance greater than four from + // every centreline point distinguishes the pool from a bend or a straight channel. + var expanded = ordered.stream().filter(column -> river.path().stream().allMatch(point -> + Math.hypot(column.x() - point.x(), column.z() - point.z()) > 4)).toList(); + helper.assertTrue(columns.size() >= 90 && expanded.size() >= 16, + "Each terminal pool needs real source-connected widening within radius12: river=" + river.id() + + ", endpoint=" + endpoint + ", connected_columns=" + columns.size() + + ", columns_beyond_entire_channel=" + expanded.size()); + return Map.of("endpoint", endpoint, "inspection_radius", 12, "connected_source_blocks", seen.size(), + "connected_water_columns", columns.size(), "columns", ordered, + "minimum_distance_from_entire_centreline", 4, "expanded_columns", expanded, + "expanded_column_count", expanded.size(), "checked_after_fluid_ticks", simulationStarted); + } + + void verifyRiverRoutes() { + riverReports.clear(); + for (var river : plan.features()) { + if (river.kind() != CavernHydrology.Kind.RIVER) continue; + helper.assertTrue(river.path().size() >= 2, "A river needs its actual connected centreline"); + double length = 0; + List actualRoute = new ArrayList<>(); + for (int i = 0; i < river.path().size(); i++) { + var point = river.path().get(i); + var cell = plan.cellAt(point.x(), point.z()); + helper.assertTrue(cell != null && cell.hasWater() && cell.featureId() == river.id() && cell.waterY() == river.waterY(), + "Each river centreline point must belong to its own constant-level retained channel: " + point); + BlockPos actual = riverWaterNear(river, point); + if (i > 0) { + var previous = river.path().get(i - 1); + int dx = point.x() - previous.x(), dz = point.z() - previous.z(); + helper.assertTrue(Math.abs(dx) <= 1 && Math.abs(dz) <= 1 && (dx != 0 || dz != 0), + "River centreline cannot skip a terrain gap: " + previous + " -> " + point); + length += Math.hypot(dx, dz); + helper.assertTrue(locallyConnectedRiver(river, actualRoute.getLast(), actual), + "A real local water connection is required at each successive river point: " + previous + " -> " + point); + } + actualRoute.add(actual); + } + var first = river.path().getFirst(); var last = river.path().getLast(); + double displacement = Math.hypot(last.x() - first.x(), last.z() - first.z()); + helper.assertTrue(length >= 140 && displacement >= 100, + "A delivered river must be long, not a renamed short stream: length=" + length + ", displacement=" + displacement); + var endPools = List.of(verifyRiverEndPool(river, first, actualRoute.getFirst()), + verifyRiverEndPool(river, last, actualRoute.getLast())); + List linked = new ArrayList<>(); + for (var spring : plan.springs()) { + var cell = plan.cellAt(spring.outlet().x(), spring.outlet().z()); + if (riverSpring(spring) && cell.featureId() == river.id()) { + linked.add(spring.id()); + if (simulationStarted) helper.assertTrue(actualSpringReachesRiver(spring), + "The declared river cascade must really deliver water into its channel after 1800 ticks: " + spring.source()); + } + } + riverReports.add(Map.of("feature_id", river.id(), "length", length, "displacement", displacement, + "water_y", river.waterY(), "actual_local_route", actualRoute, "linked_spring_ids", linked, + "local_connection_checked", true, "spring_to_channel_checked_after_ticks", simulationStarted, + "actual_terminal_pools", endPools)); + } + } + + int allowedWetCut(CavernHydrology.Cell cell) { + if (plan.isTerraceCell(cell)) return CavernTerraces.MAX_CARVE; + return plan.features().stream().anyMatch(f -> f.id() == cell.featureId() && f.kind() == CavernHydrology.Kind.LAKE) + ? CavernHydrology.MAX_LAKE_CARVE : CavernHydrology.MAX_CARVE; + } + + void verifySedimentProfiles() { + actualDryShoreTopMaterials.clear(); + actualDryShoreMaterialsByDepth.clear(); + Set depths = new TreeSet<>(); + List wet = plan.cells().stream().filter(CavernHydrology.Cell::hasWater).toList(); + for (var cell : plan.cells()) { + int depth = cell.sedimentDepth(); + helper.assertTrue(depth >= 3 && depth <= 5, "Sediment deposits must be three to five blocks thick"); + depths.add(depth); + int allowedCut = cell.hasWater() ? allowedWetCut(cell) + : plan.isRiverCell(cell) ? CavernHydrology.MAX_RIVER_BANK_CARVE : 2; + helper.assertTrue(cell.carveTop() - cell.bedY() <= allowedCut, + "Carving must respect the separate wet, river-bank and ordinary dry-bank budgets: " + cell); + for (int d = 0; d <= depth + 1; d++) { + BlockPos p = new BlockPos(cell.x(), cell.bedY() - d, cell.z()); + helper.assertTrue(sample(p.getX(), p.getY(), p.getZ()) > 0, + "Sediment and its underlying support must replace existing natural terrain: " + p); + BlockState state = level.getBlockState(p); + helper.assertTrue(state.isCollisionShapeFullBlock(level, p), + "Every deposit layer and the unchanged support below it must stay full after gravity ticks: " + p); + if (d >= depth) continue; + BlockState expected = CavernMaterials.sedimentBlock(cell, level.getSeed(), d); + // Only the eight configured ore families can replace the painted + // host rock. Alpha.9 no longer uses the old ore_dirt/rock deposits. + boolean paintedRock = expected.is(BlockTags.BASE_STONE_OVERWORLD); + boolean admissibleRockReplacement = isRiftOre(state); + boolean materialMatches = state.equals(expected) || paintedRock && admissibleRockReplacement + || expected.is(Blocks.GRASS_BLOCK) && state.is(Blocks.DIRT); + helper.assertTrue(materialMatches, "The alpha.9 sediment profile must survive decoration: " + p + + " planned=" + expected + " layer=" + d + " actual=" + state); + if (!cell.hasWater()) { + actualDryShoreMaterialsByDepth.computeIfAbsent(d, ignored -> new TreeMap<>()) + .merge(blockId(state), 1, Integer::sum); + if (d == 0) actualDryShoreTopMaterials.merge(blockId(state), 1, Integer::sum); + } + } + if (!cell.hasWater()) { + int distance = wet.stream().mapToInt(w -> Math.abs(w.x() - cell.x()) + Math.abs(w.z() - cell.z())).min().orElse(0); + maxDryShoreDistance = Math.max(maxDryShoreDistance, distance); + } + } + if (level.getSeed() == 0L) { + helper.assertTrue(maxDryShoreDistance >= 4, "Reference terrain must demonstrate shores wider than a one-block rim"); + helper.assertTrue(actualDryShoreTopMaterials.getOrDefault("minecraft:sand", 0) > 0 + && actualDryShoreTopMaterials.getOrDefault("minecraft:gravel", 0) > 0 && actualDryShoreTopMaterials.size() >= 3, + "Reference finished terrain must demonstrate actual sand, gravel and a third shore material; top=" + + actualDryShoreTopMaterials + ", layers=" + actualDryShoreMaterialsByDepth); + helper.assertTrue(depths.size() > 1, "Reference deposits should demonstrate variable thickness"); + } + } + + void verifySpringOrigins() { + for (var spring : plan.springs()) { + BlockPos source = position(spring.source()); + BlockPos outlet = position(spring.outlet()); + helper.assertTrue(chunks.containsKey(ChunkPos.pack(source)) && chunks.containsKey(ChunkPos.pack(outlet)), + "Both spring mouth chunks must already be inspected FULL chunks: source=" + source + ", outlet=" + outlet); + helper.assertTrue(source.distManhattan(outlet) == 1 && source.getY() == outlet.getY(), + "A rock spring must emerge through one horizontal opening"); + helper.assertTrue(sample(source.getX(), source.getY(), source.getZ()) > 0, + "The spring source must replace one existing rock block"); + helper.assertTrue(level.getFluidState(source).is(FluidTags.WATER) && level.getFluidState(source).isSource(), + "The actual rock spring source must exist: " + source); + for (Direction direction : Direction.values()) { + BlockPos side = source.relative(direction); + helper.assertTrue(chunks.containsKey(ChunkPos.pack(side)), + "Every natural niche face must be checked within the existing inspected footprint, including chunk seams: " + side); + if (side.equals(outlet)) { + helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) <= 0 || riverSpring(spring), + "A spring mouth must be original air, or lie inside the explicitly carved river valley: " + outlet); + } else { + helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) > 0 + && level.getBlockState(side).isCollisionShapeFullBlock(level, side), + "A natural spring needs its rock roof, floor and three solid sides: " + side); + } + } + helper.assertTrue(plan.allowsSpringFlow(source.getX(), source.getY(), source.getZ()), + "Declared spring water must be included in the precise flow attribution envelope"); + } + if (simulationStarted) verifySpringMouthsAfterPostProcessing(); + } + + void verifySpringMouthsAfterPostProcessing() { + for (var spring : plan.springs()) { + BlockPos source = position(spring.source()), outlet = position(spring.outlet()); + helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(ChunkPos.pack(source)) + && level.isPositionTickingWithEntitiesLoaded(ChunkPos.pack(outlet)), + "Spring mouth verification must follow completion of the real ticking futures: source=" + source + ", outlet=" + outlet); + // ChunkMap.prepareTickingChunk calls postProcessGeneration before completing + // its ticking future. A merely FULL chunk may still contain pending decoration. + BlockState state = level.getBlockState(outlet); + helper.assertTrue(state.isAir() || state.is(Blocks.WATER), + "Post-processing must leave the natural spring mouth open; source=" + source + + ", outlet=" + outlet + ", blocking_state=" + state); + if (simulationStarted) helper.assertTrue(state.is(Blocks.WATER) && state.getFluidState().is(FluidTags.WATER), + "After 1800 real fluid ticks the source must feed its adjacent mouth: source=" + source + + ", outlet=" + outlet + ", actual=" + state); + } + springMouthsCheckedAfterPostProcessing = true; + } + + void verifyPostProcessingDoesNotRepairOldMouth() throws IOException { + List> checked = new ArrayList<>(); + if (!plan.springs().isEmpty()) { + var spring = plan.springs().getFirst(); + checked.add(verifyPostProcessingReentryAt("water", spring.id(), position(spring.outlet()))); + } + if (!lavaPlan.falls().isEmpty()) { + var fall = lavaPlan.falls().getFirst(); + checked.add(verifyPostProcessingReentryAt("lava", fall.id(), lavaPosition(fall.outlet()))); + } + if (!plan.spills().isEmpty()) { + var spill = plan.spills().getFirst(); + checked.add(verifyPostProcessingReentryAt("terrace_spill", spill.id(), position(spill.outlet()))); + } + if (checked.isEmpty()) return; + Path report = Path.of("diagnostics", "cavern-seed-" + level.getSeed() + "-postprocessing-reentry.json"); + Files.writeString(report, new GsonBuilder().setPrettyPrinting().create().toJson(Map.of( + "seed", level.getSeed(), "checked_outlets", checked, "passed", true, + "scope", "Re-entry after completed generation preserves later edits at sampled water and lava mouths; test world only")) + "\n"); + } + + Map verifyPostProcessingReentryAt(String fluid, long sourceId, BlockPos outlet) { + LevelChunk chunk = chunks.get(ChunkPos.pack(outlet)); + helper.assertTrue(chunk != null, "The " + fluid + " re-entry mouth must belong to the existing inspection footprint: " + outlet); + helper.assertTrue(java.util.Arrays.stream(chunk.getPostProcessing()).allMatch(markers -> markers == null || markers.isEmpty()), + "The re-entry regression requires a fully consumed post-processing marker list: " + chunk.getPos()); + BlockState original = level.getBlockState(outlet); + BlockState obstruction = Blocks.GLOW_LICHEN.defaultBlockState() + .setValue(MultifaceBlock.getFaceProperty(Direction.UP), true); + // No tick or neighbouring update occurs during this temporary obstruction test. + // It runs after the terrain/flow snapshots, and restores the exact state in finally. + try { + level.setBlock(outlet, obstruction, 2); + helper.assertTrue(level.getBlockState(outlet).equals(obstruction), "The temporary obstruction must be placed: " + outlet); + chunk.postProcessGeneration(level); + helper.assertTrue(level.getBlockState(outlet).equals(obstruction), + "Re-entering post-processing without a generation marker must preserve a later edit to the " + fluid + " mouth: " + outlet + + ", expected=" + obstruction + ", actual=" + level.getBlockState(outlet)); + } finally { + level.setBlock(outlet, original, 2); + } + helper.assertTrue(level.getBlockState(outlet).equals(original), "The temporary mouth test must restore its original state: " + outlet); + SanctuaryMod.LOGGER.info("{} mouth post-processing re-entry preserved a later edit without markers, seed {}, mouth {}", + fluid, level.getSeed(), outlet); + return Map.of("fluid", fluid, "source_id", sourceId, + "outlet", Map.of("x", outlet.getX(), "y", outlet.getY(), "z", outlet.getZ()), "passed", true, + "generation_markers_present", false, "temporary_state", obstruction.toString(), + "restored_state", original.toString()); + } + + List> springMouthDiagnostics() { + List> result = new ArrayList<>(); + for (var spring : plan.springs()) { + BlockPos source = position(spring.source()), outlet = position(spring.outlet()); + long sourceChunk = ChunkPos.pack(source), outletChunk = ChunkPos.pack(outlet); + Map item = new LinkedHashMap<>(); + item.put("spring_id", spring.id()); + item.put("source", spring.source()); + item.put("outlet", spring.outlet()); + item.put("source_state", level.getBlockState(source).toString()); + item.put("outlet_state", level.getBlockState(outlet).toString()); + item.put("outlet_original_density", sample(outlet.getX(), outlet.getY(), outlet.getZ())); + item.put("mouth_crosses_chunk_boundary", sourceChunk != outletChunk); + item.put("niche_touches_chunk_boundary", java.util.Arrays.stream(Direction.values()) + .anyMatch(direction -> ChunkPos.pack(source.relative(direction)) != sourceChunk)); + item.put("source_chunk_scheduler_ready", level.isPositionTickingWithEntitiesLoaded(sourceChunk)); + item.put("outlet_chunk_scheduler_ready", level.isPositionTickingWithEntitiesLoaded(outletChunk)); + result.add(item); + } + return result; + } + + void verifySpringDescent() { + springDescent.clear(); + for (var spring : plan.springs()) { + FlowComponent component = componentBySource.get(spring.id()); + helper.assertTrue(component != null, "The spring needs a connected component of actual water: " + spring.source()); + int drop = spring.source().y() - component.actualBounds().minY(); + if (riverSpring(spring)) { + var channel = plan.cellAt(spring.outlet().x(), spring.outlet().z()); + helper.assertTrue(spring.source().y() - channel.waterY() >= 5 && actualSpringReachesRiver(spring) + && component.newWaterBlocksSinceInitialSnapshot() > 0, + "The declared river cascade must really descend from its elevated rock source into the retained channel: " + spring.source()); + springDescent.put(spring.id(), spring.source().y() - channel.waterY()); + continue; + } + int predictedDrop = spring.source().y() - spring.flowPath().stream() + .mapToInt(CavernHydrology.Position::y).min().orElse(spring.source().y()); + helper.assertTrue(drop >= Math.min(16, predictedDrop) && drop >= 8 && component.newWaterBlocksSinceInitialSnapshot() > 0, + "Real fluid simulation must produce a descending component connected to each source: " + springState(spring) + + ", component=" + component); + springDescent.put(spring.id(), drop); + } + boolean reachesBottomInPlan = plan.springs().stream().anyMatch(spring -> spring.flowPath().stream() + .anyMatch(p -> p.y() == level.getMinY())); + if (level.getSeed() == 0L && reachesBottomInPlan) { + helper.assertTrue(actualFlowComponents.stream().anyMatch(component -> + !component.sourceIds().isEmpty() && component.reachesDimensionFloor()), + "Reference seed must demonstrate a source-connected cascade actually reaching Y0 after 1800 real fluid ticks"); + } + } + + List naturalSurfaces(int x, int z) { + return naturalSurfaceCache.computeIfAbsent(ChunkPos.pack(x, z), ignored -> { + List surfaces = new ArrayList<>(); + boolean above = false, twoAbove = false; + for (int y = level.getMaxY(); y >= 24; y--) { + boolean solid = sample(x, y, z) > 0; + if (solid && !above && !twoAbove) surfaces.add(y); + twoAbove = above; above = solid; + } + return List.copyOf(surfaces); + }); + } + + void findBiomeTargets(Set requested) { + var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + var resolver = generator.getBiomeSource().createUncachedResolver(random); + Set extraLowerChunks = new HashSet<>(), centralChunks = new HashSet<>(); + int darkTargets = 0; + // Pure density/biome probing loads no chunks. At most 24 ecology chunks are + // added; hydro already supplies its unchanged, finite site-and-halo footprint. + for (int radius = 0; radius <= 208; radius += 16) { + for (int x = -radius; x <= radius; x += 16) { + for (int z = -radius; z <= radius; z += 16) { + if (Math.max(Math.abs(x), Math.abs(z)) != radius || Math.hypot(x, z) > 224) continue; + List surfaces = naturalSurfaces(x + 4, z + 4); + if (surfaces.isEmpty()) continue; + long packed = ChunkPos.pack(x >> 4, z >> 4); + int highest = surfaces.getFirst(); + if (radius <= 64 && centralTerrainTargets.size() < 8 && centralChunks.add(packed) + && addEcologyChunk(requested, packed)) { + centralTerrainTargets.add(new BlockPos(x + 4, highest + 1, z + 4)); + } + for (int y : surfaces) { + String biome = resolver.getNoiseBiome((x + 4) >> 2, (y + 1) >> 2, (z + 4) >> 2).getRegisteredName(); + if (!biomeTargets.containsKey(biome) && addEcologyChunk(requested, packed)) + biomeTargets.put(biome, new BlockPos(x + 4, y + 1, z + 4)); + boolean dark = biome.equals("sanctuary:cavern_dark_grove"); + boolean bamboo = biome.equals("sanctuary:cavern_bamboo_grove"); + if ((dark || bamboo) && highest - y >= 12 && lowerTerraceTargets.size() < 10 && (!dark || darkTargets < 7) + && !extraLowerChunks.contains(packed) && hasNaturalTreeSpace(x + 4, y + 1, z + 4, 12) + && addEcologyChunk(requested, packed)) { + extraLowerChunks.add(packed); + lowerTerraceTargets.add(new BlockPos(x + 4, y + 1, z + 4)); + if (dark) darkTargets++; + } + } + } + } + } + CavernGrovesFeature.remarkablePlan(generator, random).ifPresent(tree -> { + remarkableTreeReport.put("planned", true); + remarkableTreeReport.put("base", tree.base()); + remarkableTreeReport.put("feature", tree.featureId()); + remarkableTreeReport.put("radius", tree.radius()); + remarkableTreeReport.put("height", tree.height()); + // The selected optional tree owns a single site; inspect its whole declared + // footprint without choosing a different seed or forcing any plantation. + for (int cx = (tree.base().getX() - tree.radius()) >> 4; cx <= (tree.base().getX() + tree.radius()) >> 4; cx++) + for (int cz = (tree.base().getZ() - tree.radius()) >> 4; cz <= (tree.base().getZ() + tree.radius()) >> 4; cz++) + requested.add(ChunkPos.pack(cx, cz)); + }); + remarkableTreeReport.putIfAbsent("planned", false); + } + + boolean addEcologyChunk(Set requested, long packed) { + if (requested.contains(packed)) return true; + if (ecologyExtraChunks.size() >= 24) return false; + ecologyExtraChunks.add(packed); + requested.add(packed); + return true; + } + + boolean hasNaturalTreeSpace(int x, int y, int z, int height) { + for (int dx = 0; dx <= 1; dx++) for (int dz = 0; dz <= 1; dz++) { + for (int d = 1; d <= 3; d++) if (sample(x + dx, y - d, z + dz) <= 0) return false; + for (int h = 0; h < height; h++) if (sample(x + dx, y + h, z + dz) > 0) return false; + } + return WoodlandGroveGeometry.treeRoom(this::sample, x, y, z, 2, 6, height); + } + + void verifyBiomesAndResources() { + for (var entry : biomeTargets.entrySet()) { + BlockPos p = entry.getValue(); + LevelChunk chunk = chunks.get(ChunkPos.pack(p.getX() >> 4, p.getZ() >> 4)); + helper.assertTrue(chunk.getNoiseBiome(p.getX() >> 2, p.getY() >> 2, p.getZ() >> 2) + .getRegisteredName().equals(entry.getKey()), + "The actual generated biome must match the seeded biome source at " + p); + } + Map counts = new HashMap<>(); + for (LevelChunk chunk : chunks.values()) { + for (var section : chunk.getSections()) section.getStates().count((state, count) -> counts.merge(state, (long) count, Long::sum)); + for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x += 4) { + for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z += 4) { + BlockPos ground = highestActualGround(chunk, x, z); + if (ground == null) continue; + String id = chunk.getNoiseBiome(x >> 2, (ground.getY() + 1) >> 2, z >> 2).getRegisteredName(); + surfaceBiomes.merge(id, 1, Integer::sum); + } + } + } + counts.forEach((state, count) -> { + String id = BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); + if (id.endsWith("_ore") || state.is(BlockTags.LOGS) || isTerrain(state) + || state.is(Blocks.WATER) || state.is(Blocks.LAVA) || state.is(Blocks.SUGAR_CANE)) { + resourceCounts.merge(id, count, Long::sum); + } + }); + verifyLowerVegetationAndPalette(); + verifyRiftTreesAndGeology(); + SanctuaryMod.LOGGER.info("Rift actual site resources, seed {}: {}; biome targets {}", + level.getSeed(), resourceCounts, biomeTargets); + if (level.getSeed() == 0L) { + helper.assertTrue(biomeTargets.size() >= 3, "Reference seed must demonstrate several real layered biomes on natural surfaces"); + for (String ore : List.of("coal", "iron", "copper", "gold", "redstone", "lapis", "diamond", "emerald")) { + long count = resourceCounts.getOrDefault("minecraft:" + ore + "_ore", 0L) + + resourceCounts.getOrDefault("minecraft:deepslate_" + ore + "_ore", 0L); + helper.assertTrue(count > 0, "Reference inspected site chunks must demonstrate reachable " + ore + + "; this is a seed-specific regression check, never a generation quota"); + } + } + } + + void verifyLowerVegetationAndPalette() { + var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + Set grassColors = new HashSet<>(), foliageColors = new HashSet<>(), waterColors = new HashSet<>(); + for (var holder : generator.getBiomeSource().possibleBiomes()) { + helper.assertFalse(holder.getRegisteredName().contains("dappled"), "Dappled Forest is reserved for future continents"); + grassColors.add(holder.value().getGrassColor(0, 0)); + foliageColors.add(holder.value().getFoliageColor()); + waterColors.add(holder.value().getWaterColor()); + } + helper.assertTrue(grassColors.size() == 1 && foliageColors.size() == 1 && waterColors.size() == 1, + "The island's layered palette must preserve coherent grass, foliage and water colours"); + for (var target : lowerTerraceTargets) { + var chunk = chunks.get(ChunkPos.pack(target)); + String lower = chunk.getNoiseBiome(target.getX() >> 2, target.getY() >> 2, target.getZ() >> 2).getRegisteredName(); + int top = naturalSurfaces(target.getX(), target.getZ()).getFirst(); + String upper = chunk.getNoiseBiome(target.getX() >> 2, (top + 1) >> 2, target.getZ() >> 2).getRegisteredName(); + if (!lower.equals(upper)) verticalBiomeContrasts++; + } + for (LevelChunk chunk : chunks.values()) { + chunk.findBlocks(CavernDiagnostics::isTerracePlant, (p, state) -> { + BlockPos floor = p.below(); + if (!level.getBlockState(floor).isCollisionShapeFullBlock(level, floor)) return; + var surfaces = naturalSurfaces(p.getX(), p.getZ()); + if (!surfaces.isEmpty() && surfaces.getFirst() == floor.getY() && state.is(BlockTags.FLOWERS)) upperFlowers++; + if (surfaces.isEmpty() || surfaces.getFirst() - floor.getY() < 8 || !surfaces.contains(floor.getY())) return; + helper.assertTrue(state.canSurvive(level, p), "A real lower-terrace plant must survive on its natural support: " + p); + List supports = new ArrayList<>(); + boolean threeSupports = true; + for (int d = 0; d < 3; d++) { + float density = sample(floor.getX(), floor.getY() - d, floor.getZ()); + supports.add(density); + threeSupports &= density > 0 && level.getBlockState(floor.below(d)).isCollisionShapeFullBlock(level, floor.below(d)); + } + if (!threeSupports) { + // Vanilla flowers and grass also decorate thin natural ledges. They are + // valid scenery, but cannot prove the new feature's three-support contract. + thinNaturalLedgePlants.add(Map.of("x", p.getX(), "y", p.getY(), "z", p.getZ(), + "plant", state.toString(), "floor", level.getBlockState(floor).toString(), "support_densities", supports)); + return; + } + lowerVegetation.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum); + lowerPlants.put(p.immutable(), state); + if (level.getBlockState(floor).is(Blocks.MOSS_BLOCK)) lowerPlantsOnMoss++; + lowerSurfaceBiomes.add(chunk.getNoiseBiome(p.getX() >> 2, p.getY() >> 2, p.getZ() >> 2).getRegisteredName()); + }); + for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) { + for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) { + BlockPos ground = highestActualGround(chunk, x, z); + if (ground == null) continue; + upperGround.put(ChunkPos.pack(x, z), ground); + surfaceMaterials.merge(blockId(chunk.getBlockState(ground)), 1, Integer::sum); + } + } + } + SanctuaryMod.LOGGER.info("Rift surfaces seed {}: {} lower plants, {} vertical biome contrasts, top materials {}", + level.getSeed(), lowerVegetation, verticalBiomeContrasts, surfaceMaterials); + if (level.getSeed() == 0L) { + helper.assertTrue(!lowerVegetation.isEmpty(), "Reference terrain must show vegetation on a real lower natural terrace"); + helper.assertTrue(verticalBiomeContrasts > 0, "Reference lower terraces must have genuinely vertical biome variation"); + helper.assertTrue(surfaceMaterials.getOrDefault("minecraft:stone", 0) >= 64, + "Reference terrain must demonstrate exposed natural stone areas, not uniform grass everywhere"); + } + } + + String blockId(BlockState state) { return BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); } + + BlockState inspectedState(BlockPos p) { + LevelChunk chunk = chunks.get(ChunkPos.pack(p)); + return chunk == null ? null : chunk.getBlockState(p); + } + + BlockPos highestActualGround(LevelChunk chunk, int x, int z) { + for (int y = chunk.getHeight(Heightmap.Types.OCEAN_FLOOR, x, z); y >= 24; y--) { + BlockPos p = new BlockPos(x, y, z); + if (isTerrain(chunk.getBlockState(p))) return p; + } + return null; + } + + boolean isVerticalLog(BlockState state) { + return state != null && state.is(BlockTags.LOGS) && state.hasProperty(BlockStateProperties.AXIS) + && state.getValue(BlockStateProperties.AXIS) == Direction.Axis.Y; + } + + int naturalRoofAbove(BlockPos base) { + for (int y = base.getY() + 2; y <= level.getMaxY(); y++) + if (sample(base.getX(), y, base.getZ()) > 0) return y; + return -1; + } + + boolean threeNaturalSupports(BlockPos base) { + for (int d = 1; d <= 3; d++) { + BlockPos p = base.below(d); + BlockState state = inspectedState(p); + if (sample(p.getX(), p.getY(), p.getZ()) <= 0 || state == null + || !state.isCollisionShapeFullBlock(level, p)) return false; + } + return true; + } + + String verifyConnectedMushroomCap(BlockPos base, int height) { + helper.assertTrue(sample(base.getX(), base.getY(), base.getZ()) <= 0, + "The mushroom stem must begin in original air above its natural support: " + base); + Set seen = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + List caps = new ArrayList<>(); + BlockPos contactStem = null, contactCap = null; + queue.add(base); seen.add(base); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + BlockState current = inspectedState(p); + boolean stem = current.is(Blocks.MUSHROOM_STEM); + if (!stem) caps.add(p); + hugeMushroomBlocks.put(p, current); + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + int dy = next.getY() - base.getY(); + if (Math.abs(next.getX() - base.getX()) > 3 || Math.abs(next.getZ() - base.getZ()) > 3 + || dy < 0 || dy > height) continue; + BlockState state = inspectedState(next); + if (state == null) continue; + boolean ownStem = next.getX() == base.getX() && next.getZ() == base.getZ() + && dy < height && state.is(Blocks.MUSHROOM_STEM); + boolean cap = dy >= Math.max(0, height - 3) + && (state.is(Blocks.BROWN_MUSHROOM_BLOCK) || state.is(Blocks.RED_MUSHROOM_BLOCK)); + if (!ownStem && !cap) continue; + if (stem && cap && contactCap == null) { contactStem = p; contactCap = next; } + if (seen.add(next)) { + helper.assertTrue(sample(next.getX(), next.getY(), next.getZ()) <= 0, + "A connected mushroom stem/cap must occupy original air without excavating rock: " + next); + queue.addLast(next); + } + } + } + helper.assertTrue(!caps.isEmpty() && contactCap != null, + "A real mushroom needs its own cap physically joined to the rooted stem in six neighbours: base=" + base + + ", height=" + height + ", connected_cap_blocks=" + caps.size()); + caps.sort(Comparator.comparingInt((BlockPos p) -> p.getX()).thenComparingInt(BlockPos::getZ).thenComparingInt(BlockPos::getY)); + String capType = blockId(inspectedState(contactCap)); + hugeMushroomConnections.add(Map.of("base", base, "stem_height", height, "cap_type_at_contact", capType, + "contact_stem", contactStem, "contact_cap", contactCap, "connected_cap_blocks", caps.size(), + "cap_witnesses", caps.stream().map(p -> Map.of("position", p, "state", inspectedState(p).toString())).toList(), + "scope", "Local six-neighbour component from this rooted stem; only this stem column can connect the cap")); + return capType; + } + + void verifyRiftTreesAndGeology() { + Map rootedLogs = new HashMap<>(); + Map> leafColumns = new HashMap<>(); + List mushroomBases = new ArrayList<>(); + Map> exposed = new TreeMap<>(); + for (LevelChunk chunk : chunks.values()) chunk.findBlocks(state -> state.is(BlockTags.LOGS) + || state.is(BlockTags.LEAVES) || state.is(Blocks.MUSHROOM_STEM) + || state.is(Blocks.BROWN_MUSHROOM) || state.is(Blocks.RED_MUSHROOM) + || state.is(Blocks.ANDESITE) || state.is(Blocks.DIORITE) || state.is(Blocks.GRANITE) + || state.is(Blocks.COAL_ORE) || state.is(Blocks.IRON_ORE) || state.is(Blocks.COPPER_ORE) + || state.is(Blocks.DEEPSLATE_COAL_ORE) || state.is(Blocks.DEEPSLATE_IRON_ORE) + || state.is(Blocks.DEEPSLATE_COPPER_ORE) || state.is(Blocks.EMERALD_ORE) + || state.is(Blocks.DEEPSLATE_EMERALD_ORE), (p, state) -> { + if (state.is(BlockTags.LEAVES)) { + leafColumns.computeIfAbsent(ChunkPos.pack(p.getX(), p.getZ()), ignored -> new ArrayList<>()).add(p.immutable()); + } else if (isVerticalLog(state)) { + BlockState floor = inspectedState(p.below()); + if (floor == null || !isTerrain(floor) || !floor.isCollisionShapeFullBlock(level, p.below())) return; + // Actual feet on natural terrain, not horizontal fallen logs or branches + // above a lower trunk. A leaf must also be observed before counting a tree. + if (sample(p.getX(), p.getY() - 1, p.getZ()) <= 0 || sample(p.getX(), p.getY(), p.getZ()) > 0) return; + int vertical = 0; + while (vertical < 48) { + BlockState next = inspectedState(p.above(vertical)); + if (!isVerticalLog(next) || !next.is(state.getBlock())) break; + vertical++; + } + if (vertical >= 3) rootedLogs.put(p.immutable(), state); + } else if (state.is(Blocks.MUSHROOM_STEM) || state.is(Blocks.BROWN_MUSHROOM) || state.is(Blocks.RED_MUSHROOM)) { + BlockState floor = inspectedState(p.below()); + if (floor != null && isTerrain(floor) && floor.isCollisionShapeFullBlock(level, p.below())) mushroomBases.add(p.immutable()); + } else if (blockId(state).endsWith("_ore")) { + recordExposedOre(p, state); + } else if (state.is(Blocks.ANDESITE) || state.is(Blocks.DIORITE) || state.is(Blocks.GRANITE)) { + // Surface-rule deposits can be on vertical walls as well as the upper + // ground. Count only rock faces adjoining natural air, never buried ore. + boolean face = false; + for (Direction direction : Direction.values()) { + BlockPos side = p.relative(direction); + BlockState neighbour = inspectedState(side); + if (neighbour != null && (neighbour.isAir() || neighbour.is(BlockTags.LEAVES) + || neighbour.getCollisionShape(level, side).isEmpty()) + && sample(side.getX(), side.getY(), side.getZ()) <= 0) { face = true; break; } + } + if (face) exposed.computeIfAbsent(blockId(state), ignored -> new HashSet<>()).add(p.immutable()); + } + }); + Set ungrouped = new HashSet<>(rootedLogs.keySet()); + for (BlockPos first : rootedLogs.keySet().stream().sorted(Comparator.comparingInt((BlockPos p) -> p.getX()) + .thenComparingInt(BlockPos::getZ).thenComparingInt(BlockPos::getY)).toList()) { + if (!ungrouped.remove(first)) continue; + List footprint = new ArrayList<>(); + ArrayDeque queue = new ArrayDeque<>(); queue.add(first); + BlockState trunk = rootedLogs.get(first); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); footprint.add(p); + for (Direction direction : HORIZONTAL) { + BlockPos next = p.relative(direction); + if (ungrouped.contains(next) && rootedLogs.get(next).is(trunk.getBlock())) { + ungrouped.remove(next); queue.addLast(next); + } + } + } + if (trunk.is(Blocks.DARK_OAK_LOG)) { + Set feet = new HashSet<>(footprint); + BlockPos principal = footprint.stream().filter(p -> feet.contains(p.east()) && feet.contains(p.south()) + && feet.contains(p.east().south())) + .min(Comparator.comparingInt((BlockPos p) -> p.getX()).thenComparingInt(BlockPos::getZ)).orElse(null); + if (principal == null) { + // DarkOakTrunkPlacer in 26.3-pre.2 adds separate 2–4-log + // branches downwards from the canopy outside its 2x2 trunk. + // On sloping ledges these can touch natural ground; they do + // not prove another tree. Partial inspected groups do not either. + incompleteTreeEvidence.add(Map.of("base", first, "log", blockId(trunk), "footprint", List.copyOf(footprint), + "reason", "No complete 2x2 main-trunk footprint; possible vanilla hanging branch or partial inspection")); + continue; + } + List mainFeet = List.of(principal, principal.east(), principal.south(), principal.east().south()); + if (footprint.size() > 4) incompleteTreeEvidence.add(Map.of("base", principal, "log", blockId(trunk), + "footprint", footprint.stream().filter(p -> !mainFeet.contains(p)).toList(), + "reason", "Additional grounded branch columns adjacent to the complete 2x2 main trunk")); + first = principal; + footprint = mainFeet; + } + int height = 0; + while (height < 48) { + BlockState next = inspectedState(first.above(height)); + if (!isVerticalLog(next) || !next.is(trunk.getBlock())) break; + height++; + } + String logId = blockId(trunk), leafId = logId.replace("_log", "_leaves"); + BlockPos leaf = null; + searchLeaves: for (int dx = -6; dx <= 6; dx++) for (int dz = -6; dz <= 6; dz++) { + for (BlockPos p : leafColumns.getOrDefault(ChunkPos.pack(first.getX() + dx, first.getZ() + dz), List.of())) { + if (p.getY() >= first.getY() + 2 && p.getY() <= first.getY() + height + 8 + && blockId(inspectedState(p)).equals(leafId)) { leaf = p; break searchLeaves; } + } + } + if (leaf == null) { + incompleteTreeEvidence.add(Map.of("base", first, "log", logId, "reason", "No matching canopy leaf observed inside the finite footprint")); + continue; + } + var surfaces = naturalSurfaces(first.getX(), first.getZ()); + boolean lower = !surfaces.isEmpty() && surfaces.getFirst() - (first.getY() - 1) >= 8; + int roof = lower ? naturalRoofAbove(first) : -1; + String biome = chunks.get(ChunkPos.pack(first)).getNoiseBiome(first.getX() >> 2, first.getY() >> 2, first.getZ() >> 2).getRegisteredName(); + TreeWitness tree = new TreeWitness(logId, first, List.copyOf(footprint), height, leaf, lower, roof, biome); + trees.add(tree); + if (lower) { + // Vanilla may grow oak on a thin ledge too. Only the new sheltered + // dark-oak grove serves as evidence for the stronger floor contract. + if (trunk.is(Blocks.DARK_OAK_LOG)) { + helper.assertTrue(roof > first.getY() + 3 && footprint.size() == 4, + "A sheltered dark oak needs a natural roof and a real thick trunk: " + tree); + for (BlockPos p : footprint) helper.assertTrue(threeNaturalSupports(p), + "Every lower dark-oak foot must have three original and actual solid supports: " + p + ", tree=" + tree); + } + lowerTreeCounts.merge(logId, 1, Integer::sum); + } else { + upperTreeCounts.merge(logId, 1, Integer::sum); + if (trunk.is(Blocks.OAK_LOG) && Math.hypot(first.getX(), first.getZ()) <= 128) interiorOakTrees++; + } + } + for (BlockPos base : mushroomBases) { + var surfaces = naturalSurfaces(base.getX(), base.getZ()); + if (surfaces.isEmpty() || surfaces.getFirst() - (base.getY() - 1) < 8) continue; + int roof = naturalRoofAbove(base); + if (roof < base.getY() + 2 || !threeNaturalSupports(base)) continue; + BlockState state = inspectedState(base); + int height = 1; + if (state.is(Blocks.MUSHROOM_STEM)) { + while (height < 24 && inspectedState(base.above(height)).is(Blocks.MUSHROOM_STEM)) height++; + helper.assertTrue(height >= 4 && height <= 12 && roof > base.getY() + height, + "A real lower mushroom tree needs its full vanilla stem, cap and original roof: base=" + base + ", height=" + height + ", roof=" + roof); + String capType = verifyConnectedMushroomCap(base, height); + hugeMushroomCounts.merge(capType, 1, Integer::sum); + } else helper.assertTrue(state.canSurvive(level, base), "A real lower mushroom must survive under its natural roof: " + base); + mushrooms.add(new MushroomWitness(blockId(state), base, height, roof)); + } + for (var entry : exposed.entrySet()) { + exposedRocks.put(entry.getKey(), entry.getValue().size()); + Set remaining = new HashSet<>(entry.getValue()); + while (!remaining.isEmpty()) { + BlockPos first = remaining.iterator().next(); remaining.remove(first); + List patch = new ArrayList<>(); ArrayDeque queue = new ArrayDeque<>(); queue.add(first); + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); patch.add(p); + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + if (remaining.remove(next)) queue.addLast(next); + } + } + rockPatches.add(new RockPatch(entry.getKey(), patch.size(), first, actualBoundsOf(patch))); + } + } + rockPatches.sort(Comparator.comparingInt(RockPatch::exposedBlocks).reversed()); + for (var entry : upperGround.entrySet()) { + BlockPos floor = entry.getValue(); + boolean inner = Math.hypot(floor.getX(), floor.getZ()) <= 128; + boolean canopy = leafColumns.getOrDefault(entry.getKey(), List.of()).stream().anyMatch(p -> p.getY() > floor.getY()); + if (inner) { innerGroundColumns++; if (canopy) innerCanopyColumns++; } + else { exteriorGroundColumns++; if (canopy) exteriorCanopyColumns++; } + } + // Palette filtering skips columns with no relevant soil. The cached density + // profile is needed only to prove that an observed soil lies on a lower ledge. + for (LevelChunk chunk : chunks.values()) chunk.findBlocks(state -> state.is(Blocks.MOSS_BLOCK) + || state.is(Blocks.PODZOL) || state.is(Blocks.MYCELIUM), (p, soil) -> { + var surfaces = naturalSurfaces(p.getX(), p.getZ()); + if (!surfaces.isEmpty() && surfaces.getFirst() - p.getY() >= 8 && surfaces.contains(p.getY())) + lowerSoilCounts.merge(blockId(soil), 1, Integer::sum); + }); + verifyRemarkableTree(); + SanctuaryMod.LOGGER.info("Rift observations seed {}: upper tree roots {}, interior oak {}, lower tree roots {}, roofed mushrooms {}, exposed rocks {}", + level.getSeed(), upperTreeCounts, interiorOakTrees, lowerTreeCounts, mushrooms.size(), exposedRocks); + } + + void verifyRiftReferenceExamples() { + // Seed42 lost every sheltered dark oak when giant mushrooms always went + // first and deep humid supports were rejected. Keep both real plant types + // in the regression evidence; generation itself has no placement quota. + if (level.getSeed() == 0L || level.getSeed() == 42L) { + helper.assertTrue(lowerTreeCounts.getOrDefault("minecraft:dark_oak_log", 0) > 0, + "Reference lower groves need a real supported dark oak under natural rock: seed=" + level.getSeed() + + ", actual_lower_trees=" + lowerTreeCounts); + helper.assertTrue(!hugeMushroomCounts.isEmpty(), + "Reference lower groves must also retain a real giant mushroom with connected stem and cap: seed=" + level.getSeed() + + ", actual_giant_mushrooms=" + hugeMushroomCounts); + } + if (level.getSeed() == 0L) { + int total = upperTreeCounts.values().stream().mapToInt(Integer::intValue).sum(); + helper.assertTrue(total > 0 && upperTreeCounts.getOrDefault("minecraft:oak_log", 0) * 2 > total, + "Reference seed must show an actual oak majority among sampled upper tree root groups: " + upperTreeCounts); + helper.assertTrue(interiorOakTrees > 0, "Reference seed must show rooted oaks inside radius 128, not only on the perimeter"); + helper.assertTrue(lowerSoilCounts.getOrDefault("minecraft:moss_block", 0) > 0, + "Reference lower groves must demonstrate moss on existing natural ledges"); + for (String mineral : List.of("coal", "iron", "copper")) helper.assertTrue(outdoorOreCounts.getOrDefault(mineral, 0) > 0, + "Reference inspection must demonstrate naturally exposed outdoor " + mineral + " ore, not buried resources: " + exposedOreCounts); + for (String rock : List.of("minecraft:andesite", "minecraft:diorite", "minecraft:granite")) + helper.assertTrue(rockPatches.stream().anyMatch(patch -> patch.block().equals(rock) && patch.exposedBlocks() >= 8), + "Reference exposed geology must show a connected patch of at least eight visible " + rock + " blocks; actual=" + exposedRocks); + } + } + + void recordExposedOre(BlockPos p, BlockState state) { + String mineral = blockId(state).replace("minecraft:", "").replace("deepslate_", "").replace("_ore", ""); + List> faces = new ArrayList<>(); + boolean outdoor = false; + for (Direction direction : Direction.values()) { + BlockPos side = p.relative(direction); + BlockState neighbour = inspectedState(side); + if (neighbour == null || !neighbour.isAir()) continue; + var surfaces = naturalSurfaces(side.getX(), side.getZ()); + boolean sky = surfaces.isEmpty() || surfaces.getFirst() < side.getY(); + boolean openWall = !sky && direction.getAxis().isHorizontal(); + if (openWall) for (int step = 1; step <= 12; step++) { + BlockPos ray = p.relative(direction, step); + BlockState hit = inspectedState(ray); + if (hit == null || sample(ray.getX(), ray.getY(), ray.getZ()) > 0 + || hit.isCollisionShapeFullBlock(level, ray)) { openWall = false; break; } + } + String kind = sky ? "open_sky" : openWall ? "open_wall_12_blocks" : "sheltered_cavity"; + outdoor |= sky || openWall; + faces.add(Map.of("direction", direction.getName(), "air", side, "kind", kind)); + } + if (faces.isEmpty()) return; + exposedOreCounts.merge(mineral, 1, Integer::sum); + if (outdoor) outdoorOreCounts.merge(mineral, 1, Integer::sum); + exposedOreExamples.add(Map.of("position", p.immutable(), "block", blockId(state), "outdoor", outdoor, "air_faces", faces)); + } + + void verifyRemarkableTree() { + var selected = CavernGrovesFeature.remarkablePlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), random); + List remarkable = trees.stream().filter(tree -> !tree.lowerTerrace() && REMARKABLE_LOGS.contains(tree.log())).toList(); + remarkableTreeReport.put("observed_root_groups", remarkable); + String selectedSpecies = selected.map(tree -> tree.featureId().contains("jungle") ? "minecraft:jungle_log" + : tree.featureId().contains("pale_oak") ? "minecraft:pale_oak_log" + : tree.featureId().replace("minecraft:", "").replace("_bonemeal", "") + "_log").orElse(""); + if (!selectedSpecies.isEmpty() && !selectedSpecies.contains(":")) selectedSpecies = "minecraft:" + selectedSpecies; + int observedSelectedLogs = 0; + if (selected.isPresent()) { + var site = selected.orElseThrow(); + for (int x = site.base().getX() - site.radius(); x <= site.base().getX() + site.radius(); x++) + for (int z = site.base().getZ() - site.radius(); z <= site.base().getZ() + site.radius(); z++) + for (int y = site.base().getY(); y <= site.base().getY() + site.height(); y++) { + BlockState state = inspectedState(new BlockPos(x, y, z)); + helper.assertTrue(state != null, "The optional remarkable-tree footprint must be inspected in full"); + if (blockId(state).equals(selectedSpecies)) observedSelectedLogs++; + } + } + remarkableTreeReport.put("observed_selected_log_blocks_in_planned_volume", observedSelectedLogs); + remarkableTreeReport.put("planted", observedSelectedLogs > 0); + remarkableTreeReport.put("expected_log", selectedSpecies); + remarkableTreeReport.put("scope", "One optional planned site, inspected in full; no requirement for each species or for successful placement"); + helper.assertTrue(remarkable.size() <= 1, "At most one remarkable tree root group may be observed: " + remarkable); + for (TreeWitness tree : remarkable) { + helper.assertTrue(selected.isPresent(), "An observed remarkable tree needs a declared seed-selected site: " + tree); + var plan = selected.orElseThrow(); + helper.assertTrue(Math.abs(tree.base().getX() - plan.base().getX()) <= plan.radius() + && Math.abs(tree.base().getZ() - plan.base().getZ()) <= plan.radius() + && tree.base().getY() >= plan.base().getY() - 1 && tree.base().getY() <= plan.base().getY() + plan.height(), + "The optional remarkable tree must belong to its single planned site: " + tree + ", plan=" + plan); + } + } + + void verifyRiftSurvives() { + for (var entry : hugeMushroomBlocks.entrySet()) helper.assertTrue(entry.getValue().equals(inspectedState(entry.getKey())), + "The whole observed mushroom stem/cap must survive real fluid ticks: " + entry.getKey()); + for (TreeWitness tree : trees) { + for (BlockPos p : tree.footprint()) { + BlockState state = inspectedState(p), floor = inspectedState(p.below()); + helper.assertTrue(isVerticalLog(state) && blockId(state).equals(tree.log()) + && floor != null && floor.isCollisionShapeFullBlock(level, p.below()), + "Observed tree feet must retain stable support after 1800 actual fluid ticks: " + tree + ", foot=" + p); + } + } + for (MushroomWitness mushroom : mushrooms) { + BlockState state = inspectedState(mushroom.base()); + helper.assertTrue(state != null && blockId(state).equals(mushroom.block()) && threeNaturalSupports(mushroom.base()) + && (state.is(Blocks.MUSHROOM_STEM) || state.canSurvive(level, mushroom.base())), + "Observed lower mushrooms must remain on their natural support after 1800 ticks: " + mushroom); + } + } + + void verifyLowerPlantsStillSurvive() { + for (BlockPos p : lowerPlants.keySet()) { + BlockState current = level.getBlockState(p); + helper.assertTrue(isTerracePlant(current) && current.canSurvive(level, p) + && level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below()), + "Lower-terrace vegetation must remain on stable support after the real fluid simulation: " + p); + } + } + + static BlockPos position(CavernHydrology.Position p) { return new BlockPos(p.x(), p.y(), p.z()); } + + static BlockPos lavaPosition(CavernLavaDeposit.Position p) { return new BlockPos(p.x(), p.y(), p.z()); } + + void verifyStarterLava() { + if (!expectedPoolLava.isEmpty()) { + helper.assertTrue(expectedPoolLava.size() == 18 && lavaPlan.access().isPresent(), + "The retained starter pocket must have eighteen sources and a dry approach, excluding separate falls"); + } + for (BlockPos p : expectedLava) { + helper.assertTrue(sample(p.getX(), p.getY(), p.getZ()) > 0, + "Every initial lava source must replace existing natural rock: " + p); + helper.assertTrue(level.getFluidState(p).is(FluidTags.LAVA) && level.getFluidState(p).isSource(), + "Each declared pocket or volcanic-fall source must remain present: " + p); + var fall = lavaPlan.falls().stream().filter(candidate -> lavaPosition(candidate.source()).equals(p)).findFirst(); + if (fall.isPresent()) { + BlockPos outlet = lavaPosition(fall.orElseThrow().outlet()); + helper.assertTrue(p.distManhattan(outlet) == 1 && p.getY() == outlet.getY() + && sample(outlet.getX(), outlet.getY(), outlet.getZ()) <= 0, + "A volcanic seep must use one adjacent naturally empty outlet: " + outlet); + if (simulationStarted) helper.assertTrue(level.getFluidState(outlet).is(FluidTags.LAVA), + "The volcanic outlet must actually receive lava after the measured ticks: " + outlet); + } + for (Direction direction : Direction.values()) { + if (direction == Direction.UP && fall.isEmpty()) continue; + BlockPos side = p.relative(direction); + if (expectedLava.contains(side) || fall.isPresent() && side.equals(lavaPosition(fall.orElseThrow().outlet()))) continue; + helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) > 0 + && level.getBlockState(side).isCollisionShapeFullBlock(level, side), + "Lava needs its original rock pocket or five closed source-niche faces: " + side); + } + } + for (var support : lavaPlan.supports()) { + BlockPos p = lavaPosition(support); + BlockState state = level.getBlockState(p); + helper.assertTrue(sample(p.getX(), p.getY(), p.getZ()) > 0 + && state.isCollisionShapeFullBlock(level, p) && !state.ignitedByLava(), + "Lava supports must remain natural, full and nonflammable: " + p); + } + lavaPlan.access().ifPresent(access -> { + BlockPos p = lavaPosition(access); + helper.assertTrue(level.getBlockState(p).isAir() && level.getBlockState(p.above()).isAir() + && level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below()), + "The retained lava pocket needs a stable dry two-block-high access: " + p); + }); + Set flow = new HashSet<>(); + for (LevelChunk chunk : chunks.values()) chunk.findBlocks(state -> state.getFluidState().is(FluidTags.LAVA) + || state.is(Blocks.FIRE) || state.is(Blocks.SOUL_FIRE), (p, state) -> { + helper.assertFalse(state.is(Blocks.FIRE) || state.is(Blocks.SOUL_FIRE), "Generated lava must not ignite vegetation: " + p); + if (!expectedPoolLava.contains(p)) flow.add(p.immutable()); + }); + if (!simulationStarted) initialLavaFlow.addAll(flow); + analyseLavaFlow(flow, true); + } + + void analyseLavaFlow(Set flow, boolean requireValid) { + lavaFlowReports.clear(); + Set seen = new HashSet<>(); + Map descent = new HashMap<>(); + for (BlockPos start : flow.stream().sorted(Comparator.comparingInt((BlockPos p) -> p.getX()) + .thenComparingInt(BlockPos::getY).thenComparingInt(BlockPos::getZ)).toList()) { + if (!seen.add(start)) continue; + List component = new ArrayList<>(); + ArrayDeque queue = new ArrayDeque<>(); queue.add(start); + int boundary = 0, newBlocks = 0; + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); component.add(p); + if (!initialLavaFlow.contains(p)) newBlocks++; + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + if (HORIZONTAL.contains(direction) && !chunks.containsKey(ChunkPos.pack(next))) boundary++; + if (flow.contains(next) && seen.add(next)) queue.addLast(next); + } + } + Set wet = new HashSet<>(component); + var roots = lavaPlan.falls().stream().filter(fall -> wet.contains(lavaPosition(fall.source()))).toList(); + var bounds = actualBoundsOf(component); + for (var fall : roots) descent.put(fall.id(), fall.source().y() - bounds.minY()); + lavaFlowReports.add(Map.of("source_ids", roots.stream().map(CavernLavaFlows.Fall::id).toList(), + "actual_lava_blocks", component.size(), "new_lava_blocks_since_initial", newBlocks, + "actual_bounds", bounds, "horizontal_neighbours_outside_inspection", boundary)); + if (requireValid) { + helper.assertTrue(!roots.isEmpty(), "Every lava component outside the retained pocket must connect to a declared volcanic source: " + bounds); + helper.assertTrue(boundary == 0, "Lava reached the boundary of the finite inspected footprint; further flow is unproven: " + bounds); + if (simulationStarted) helper.assertTrue(newBlocks > 0, "A volcanic fall must actually advance through vanilla lava ticks: " + bounds); + } + } + if (requireValid && simulationStarted) for (var fall : lavaPlan.falls()) helper.assertTrue(descent.getOrDefault(fall.id(), 0) >= 8, + "Each volcanic fall must demonstrate at least eight real descending lava blocks after1800ticks: source=" + fall.source() + + ", descent=" + descent.get(fall.id())); + } + + void startWhenFluidChunksReady() { + if (closed || simulationStarted || helper.getTick() < 20) return; + List pending = fluidChunks.stream().filter(packed -> !level.isPositionTickingWithEntitiesLoaded(packed)).sorted().toList(); + if (!pending.isEmpty()) { + if (helper.getTick() % 200 == 0) { + SanctuaryMod.LOGGER.info("Hydrology awaiting actual fluid scheduler readiness, seed {}, tick {}, {} pending chunks: {}", + level.getSeed(), helper.getTick(), pending.size(), pending.stream().limit(8).map(packed -> { + var pos = ChunkPos.unpack(packed); + return pos + " tickets=" + level.shouldTickBlocksAt(packed) + " entities=" + level.areEntitiesLoaded(packed) + + " tickingFuture=" + level.getChunkSource().isPositionTicking(packed); + }).toList()); + } + helper.assertTrue(helper.getTick() < 600, + "Hydrology could not start real fluid simulation: " + pending.size() + " chunks did not become scheduler-ready"); + return; + } + verifySpringMouthsAfterPostProcessing(); + for (var fall : lavaPlan.falls()) { + BlockPos outlet = lavaPosition(fall.outlet()); + BlockState state = inspectedState(outlet); + helper.assertTrue(state != null && (state.isAir() || state.is(Blocks.LAVA)), + "Completed volcanic post-processing must leave the natural mouth open: " + outlet + "=" + state); + } + logSpringStates("post-processing complete before measured fluid simulation"); + simulationStarted = true; + simulationStartGameTime = level.getGameTime(); + startFluidWitness(); + helper.runAfterDelay(30, () -> { + if (closed) return; + try { + verifyFluidWitnessAndRemoveIt(); + } catch (RuntimeException | Error failure) { + cleanup(); + throw failure; + } + }); + helper.runAfterDelay(1800, () -> { + if (closed) return; + try { + logSpringStates("after 1800 fluid ticks"); + // Export before assertions so a failed source still leaves its actual profile. + export("after-1800-ticks", false); + for (long packed : fluidChunks) helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(packed), + "Fluid chunk must remain scheduler-ready throughout the simulation: " + ChunkPos.unpack(packed)); + verifyNaturalShellAndFinishedWater(); + verifyAllObservedWaterIsPlanned(); + verifySpringDescent(); + verifyRiftWitnesses(); + verifyRiverRoutes(); + verifyLowerPlantsStillSurvive(); + verifyRiftSurvives(); + verifyCavernDecorationsAfterTicks(); + verifyTerraceFlows(); + export("after-1800-ticks", true); + verifyPostProcessingDoesNotRepairOldMouth(); + SanctuaryMod.LOGGER.info("Natural hydrology seed {}: {} basin sources retained through {} world ticks, {} seams checked", + level.getSeed(), expectedWater.size(), level.getGameTime() - simulationStartGameTime, seams); + helper.succeed(); + } catch (IOException exception) { + throw new IllegalStateException("Could not write natural hydrology diagnostics", exception); + } finally { + cleanup(); + } + }); + } + + void startFluidWitness() { + for (long packed : fluidChunks) helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(packed), + "Every fluid chunk must satisfy the exact production scheduler predicate: " + ChunkPos.unpack(packed)); + for (BlockPos witness : witnesses) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + for (int dy = -4; dy <= 0; dy++) helper.assertTrue(level.getBlockState(witness.offset(dx, dy, dz)).isAir(), + "A fluid witness must occupy empty development test space"); + } + } + } + witnessPlaced = true; + for (BlockPos witness : witnesses) { + 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 begins with an empty cell below its source"); + level.scheduleTick(witness, Fluids.WATER, 1); + } + // Natural sources rely on their production postprocessing ticks. Scheduling them + // here would mask a missing wake-up for a terrace, rock spring or lava outlet. + allWaterChunksTicking = true; + SanctuaryMod.LOGGER.info("Natural hydrology seed {}: {} actual scheduler-ready chunks, {} independent fluid witnesses, start tick {}", + level.getSeed(), fluidChunks.size(), witnesses.size(), simulationStartGameTime); + } + + void verifyFluidWitnessAndRemoveIt() { + try { + for (BlockPos witness : witnesses) { + helper.assertTrue(level.getFluidState(witness.below()).is(FluidTags.WATER), + "A real fluid witness must flow in each spring and lava chunk: " + witness + + ", schedulerReady=" + level.isPositionTickingWithEntitiesLoaded(ChunkPos.pack(witness))); + SanctuaryMod.LOGGER.info("Natural hydrology seed {}: independent fluid witness passed at {}", level.getSeed(), witness); + } + } finally { + removeWitness(); + } + } + + void removeWitness() { + if (!witnessPlaced) return; + for (BlockPos witness : witnesses) { + // Remove contained fluid before opening the walls of this disposable test fixture. + 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 logSpringStates(String phase) { + for (var spring : plan.springs()) SanctuaryMod.LOGGER.info("Spring state {}, seed {}: {}", phase, level.getSeed(), springState(spring)); + } + + String springState(CavernHydrology.Spring spring) { + BlockPos source = position(spring.source()), outlet = position(spring.outlet()); + List column = new ArrayList<>(); + for (int d = 0; d <= 10; d++) column.add("Y" + (outlet.getY() - d) + "=" + level.getBlockState(outlet.below(d))); + long packed = ChunkPos.pack(source); + return "source=" + source + "=" + level.getBlockState(source) + ", outlet=" + outlet + ", below=" + column + + ", tickets=" + level.shouldTickBlocksAt(packed) + ", entities=" + level.areEntitiesLoaded(packed) + + ", tickingFuture=" + level.getChunkSource().isPositionTicking(packed) + + ", schedulerReady=" + level.isPositionTickingWithEntitiesLoaded(packed); + } + + void cleanup() { + if (closed) return; + closed = true; + 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, boolean snapshotChecksPassed) throws IOException { + Path directory = Path.of("diagnostics"); + Files.createDirectories(directory); + String stem = "cavern-seed-" + level.getSeed() + "-" + phase; + Map report = new LinkedHashMap<>(); + report.put("schema_version", 1); + report.put("created_at_utc", Instant.now().toString()); + report.put("generator_noise_settings", ((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator()) + .generatorSettings().getRegisteredName()); + report.put("mod_version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow() + .getMetadata().getVersion().getFriendlyString()); + report.put("seed", level.getSeed()); + report.put("phase", phase); + report.put("snapshot_checks_passed", snapshotChecksPassed); + int[] visibleBlocks = {0, 0, 0, 0, 0, 0}; + Set actualFlow = new HashSet<>(); + Set actualLavaFlow = new HashSet<>(); + for (LevelChunk chunk : chunks.values()) chunk.findBlocks( + state -> !state.getFluidState().isEmpty() || state.is(Blocks.SUGAR_CANE), (p, state) -> { + if (state.getFluidState().is(FluidTags.WATER)) { + visibleBlocks[0]++; + if (!expectedWater.contains(p)) { + visibleBlocks[1]++; + actualFlow.add(p.immutable()); + } else visibleBlocks[5]++; + } else if (state.getFluidState().is(FluidTags.LAVA)) { + visibleBlocks[2]++; + if (state.getFluidState().isSource()) visibleBlocks[4]++; + if (!expectedPoolLava.contains(p)) actualLavaFlow.add(p.immutable()); + } + if (state.is(Blocks.SUGAR_CANE)) visibleBlocks[3]++; + }); + analyseActualFlow(actualFlow); + analyseLavaFlow(actualLavaFlow, false); + report.put("volcanic_lava_falls", lavaPlan.falls()); + report.put("actual_lava_flow_components", lavaFlowReports); + report.put("planned_retained_lava_pocket_sources", expectedPoolLava.size()); + report.put("actual_flow_components", actualFlowComponents); + report.put("flow_provenance_rule", "Actual water outside retained basins is six-neighbour connected to declared rock springs, with no horizontal neighbour outside the finite inspection"); + report.put("flow_prediction_role", "Bare-rock density prediction is a comparison, not a post-decoration safety envelope; trees may divert real water"); + report.put("flow_component_mergers", actualFlowComponents.stream().filter(component -> component.sourceIds().size() > 1).toList()); + report.put("actual_water_blocks", visibleBlocks[0]); + report.put("actual_lava_blocks", visibleBlocks[2]); + report.put("actual_lava_source_blocks", visibleBlocks[4]); + report.put("planned_closed_basin_water_blocks", declaredWater.size()); + report.put("expected_retained_closed_basin_water_blocks", expectedWater.size()); + report.put("natural_log_obstructions", naturalLogObstructions.entrySet().stream().map(entry -> Map.of( + "x", entry.getKey().getX(), "y", entry.getKey().getY(), "z", entry.getKey().getZ(), + "block", BuiltInRegistries.BLOCK.getKey(entry.getValue().getBlock()).toString(), + "state", entry.getValue().toString())).toList()); + report.put("actual_closed_basin_water_blocks", visibleBlocks[5]); + report.put("actual_water_blocks_outside_closed_basin", visibleBlocks[1]); + report.put("planned_starter_lava_sources", expectedLava.size()); + report.put("starter_lava_cells", lavaPlan.cells()); + report.put("starter_lava_natural_supports", lavaPlan.supports()); + report.put("starter_lava_access", lavaPlan.access().orElse(null)); + report.put("springs", plan.springs()); + report.put("terraces", plan.terraces()); + report.put("terrace_spills", plan.spills()); + report.put("actual_terrace_connections", terraceReports); + report.put("actual_cave_decoration_blocks", caveDecorationCounts); + report.put("actual_geode_cavity_connections", geodeConnections); + report.put("actual_cave_decoration_examples", caveDecorationWitnesses.entrySet().stream().limit(64).map(e -> Map.of("position", positionMap(e.getKey()), "state", e.getValue().toString())).toList()); + report.put("actual_lake_profiles", plan.features().stream().filter(f -> f.kind() == CavernHydrology.Kind.LAKE).map(f -> { + var wet = plan.cells().stream().filter(c -> c.featureId() == f.id() && c.hasWater()).toList(); + long actual = 0; + for (var c : wet) for (int y = c.bedY()+1; y <= c.waterY(); y++) + if (level.getFluidState(new BlockPos(c.x(), y, c.z())).is(FluidTags.WATER)) actual++; + return Map.of("feature_id", f.id(), "water_columns", wet.size(), "actual_water_blocks", actual, + "maximum_retained_depth", wet.stream().mapToInt(c -> c.waterY()-c.bedY()).max().orElse(0), + "checked_after_fluid_ticks", allWaterChunksTicking); + }).toList()); + report.put("geyser_water_source_count", geyserWater.size()); + report.put("actual_geyser_states", geyserStates.entrySet().stream().map(e -> Map.of("position", positionMap(e.getKey()), "states", e.getValue())).toList()); + report.put("spring_mouth_diagnostics", springMouthDiagnostics()); + report.put("spring_mouths_checked_after_post_processing", springMouthsCheckedAfterPostProcessing); + report.put("flow_roots", flowRoots.stream().map(root -> Map.of("id", root.id(), "kind", root.kind(), + "feature_id", root.featureId(), "x", root.position().getX(), "y", root.position().getY(), "z", root.position().getZ())).toList()); + report.put("actual_spring_descent_blocks", springDescent); + report.put("spring_descent_basis", "Lowest actual water in the source-connected component; merged sources share their explicitly reported component"); + report.put("maximum_dry_shore_manhattan_distance_to_water", maxDryShoreDistance); + report.put("actual_dry_shore_top_materials", actualDryShoreTopMaterials); + report.put("actual_dry_shore_materials_by_depth_below_top", actualDryShoreMaterialsByDepth); + report.put("sediment_material_verification", "Every actual layer is compared with CavernMaterials; only painted host rock permits the eight configured ore families, never arbitrary full blocks"); + report.put("actual_resources_in_inspected_chunks", resourceCounts); + report.put("actual_lower_terrace_vegetation", lowerVegetation); + report.put("lower_vegetation_scope", "Observed plants on lower natural floors with three original and actual solid supports; vanilla plants on thinner ledges are reported separately"); + report.put("thin_natural_ledge_plants", thinNaturalLedgePlants); + report.put("initial_lower_plants_on_moss_observed_not_required", lowerPlantsOnMoss); + report.put("initial_upper_surface_flowers", upperFlowers); + report.put("lower_vegetation_examples", lowerPlants.entrySet().stream().limit(64).map(entry -> Map.of( + "x", entry.getKey().getX(), "y", entry.getKey().getY(), "z", entry.getKey().getZ(), + "initial_state", entry.getValue().toString(), "current_state", level.getBlockState(entry.getKey()).toString(), + "floor_state", level.getBlockState(entry.getKey().below()).toString())).toList()); + report.put("actual_lower_terrace_biomes", lowerSurfaceBiomes); + report.put("verified_vertical_biome_contrasts", verticalBiomeContrasts); + report.put("actual_upper_surface_material_columns", surfaceMaterials); + report.put("upper_ground_measurement", "Highest actual terrain block per inspected column, excluding leaves, logs and plants; canopy height is not ground height"); + report.put("actual_upper_tree_root_groups", upperTreeCounts); + report.put("actual_lower_tree_root_groups", lowerTreeCounts); + report.put("tree_root_evidence", trees); + report.put("tree_root_measurement", "Vertical trunks at least three logs tall, natural ground at the foot, matching observed leaves; adjacent same-species feet at the same Y form one group including 2x2 trunks"); + report.put("incomplete_tree_or_grounded_branch_evidence", incompleteTreeEvidence); + report.put("interior_oak_root_groups_radius_128", interiorOakTrees); + report.put("canopy_projection", Map.of("interior_ground_columns", innerGroundColumns, + "interior_columns_with_leaves_above_ground", innerCanopyColumns, "exterior_ground_columns", exteriorGroundColumns, + "exterior_columns_with_leaves_above_ground", exteriorCanopyColumns, + "scope", "Projection over inspected terrain columns only; separate from trunk groups and not an island-wide percentage")); + report.put("actual_roofed_mushrooms", mushrooms); + report.put("actual_huge_mushroom_trees_by_cap", hugeMushroomCounts); + report.put("actual_huge_mushroom_stem_cap_connections", hugeMushroomConnections); + report.put("actual_lower_soil_columns_by_material", lowerSoilCounts); + report.put("lower_soil_scope", "Actual moss/podzol/mycelium on lower original surfaces; seed probabilities are not imposed as measured quotas"); + report.put("rift_layout", riftLayout); + report.put("rift_core_witnesses", riftWitnessReports); + report.put("river_routes", riverReports); + report.put("remarkable_tree", remarkableTreeReport); + report.put("actual_exposed_rock_blocks", exposedRocks); + report.put("actual_exposed_rock_connected_patches", rockPatches); + report.put("rock_patch_scope", "Six-neighbour connected exposed blocks; their actual neighbouring space is open and the original density there is empty"); + report.put("actual_ore_blocks_with_air_face", exposedOreCounts); + report.put("actual_outdoor_ore_blocks_with_air_face", outdoorOreCounts); + report.put("actual_exposed_ore_locations", exposedOreExamples); + report.put("ore_exposure_definition", "Actual AIR on at least one face. Outdoor means above the raw highest surface in that air column, or a 12-block natural horizontal opening inspected in full; other air faces are reported as sheltered cavities"); + report.put("ecology_added_chunks", ecologyExtraChunks.stream().map(ChunkPos::unpack).toList()); + report.put("central_terrain_targets", centralTerrainTargets); + report.put("ecology_sampling", "Hydrology footprint plus at most 24 targeted ecology chunks and the one optional remarkable-tree volume; selected biome/ledge targets bias this sample, never an island-wide forest inventory"); + report.put("lower_terrace_targets", lowerTerraceTargets.stream().map(p -> Map.of("x", p.getX(), "y", p.getY(), "z", p.getZ())).toList()); + report.put("actual_biomes_sampled_on_surface_every_four_blocks", surfaceBiomes); + report.put("verified_real_biome_locations", biomeTargets.entrySet().stream().map(entry -> Map.of( + "biome", entry.getKey(), "x", entry.getValue().getX(), "y", entry.getValue().getY(), "z", entry.getValue().getZ())).toList()); + report.put("resource_scope", "Exact palette counts in the listed FULL chunks before tick simulation; not an island-wide total"); + report.put("actual_cane_blocks", visibleBlocks[3]); + report.put("checked_chunk_seam_adjacencies", seams); + report.put("simulation_started_after_all_fluid_chunks_were_scheduler_ready", allWaterChunksTicking); + report.put("official_entity_load_wait_ms", entityLoadWaitMillis); + report.put("entity_load_scope", "All inspection tickets declared before FULL loading; waitForEntities with radius zero on those same chunks before accelerated tick simulation"); + report.put("all_fluid_chunks_currently_scheduler_ready", fluidChunks.stream().allMatch(level::isPositionTickingWithEntitiesLoaded)); + report.put("simulation_world_ticks_elapsed", simulationStarted ? level.getGameTime() - simulationStartGameTime : 0); + report.put("independent_fluid_witnesses", witnesses.stream().map(p -> Map.of("x", p.getX(), "y", p.getY(), "z", p.getZ())).toList()); + 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); + exportLowerMap(directory.resolve(stem + "-lower-terraces.png"), phase); + exportCanopyMap(directory.resolve(stem + "-canopy.png"), phase); + for (var spring : plan.springs()) exportSpringProfile(directory.resolve(stem + "-spring-" + spring.id() + ".png"), spring, phase); + for (var fall : lavaPlan.falls()) exportLavaProfile(directory.resolve(stem + "-lava-" + fall.id() + ".png"), fall, phase); + for (var rift : riftLayout.rifts()) exportRiftProfile(directory.resolve(stem + "-rift-" + rift.id() + ".png"), rift, phase); + } + + void exportRiftProfile(Path output, RiftShape.Rift rift, String phase) throws IOException { + int scale = 2, halfWidth = 16, panel = (halfWidth * 2 + 1) * scale; + BufferedImage image = new BufferedImage(820, level.getHeight() * scale + 100, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + g.setColor(new Color(0xF0F0EC)); g.fillRect(0, 0, image.getWidth(), image.getHeight()); + g.setColor(new Color(0x17222C)); g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16)); + g.drawString("Rift cross-section | seed " + level.getSeed() + " | " + phase, 20, 25); + g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); + g.drawString("Left: original raised-island density. Centre: fractured density. Right: actual decorated blocks.", 20, 46); + g.drawString("Perpendicular to rift at " + rift.main().x() + ", " + rift.main().z() + ". Not a Minecraft render.", 20, 66); + for (int offset = -halfWidth; offset <= halfWidth; offset++) { + int x = (int) Math.rint(rift.main().x() - rift.main().dz() * offset); + int z = (int) Math.rint(rift.main().z() + rift.main().dx() * offset); + for (int y = level.getMinY(); y <= level.getMaxY(); y++) { + int py = 85 + (level.getMaxY() - y) * scale, px = (offset + halfWidth) * scale; + boolean before = random.sampleBlockValueUncached(riftDensity.terrain(), x, y, z) > 0; + boolean after = sample(x, y, z) > 0; + g.setColor(new Color(before ? 0x999A96 : 0x142738)); g.fillRect(20 + px, py, scale, scale); + g.setColor(new Color(after ? 0x999A96 : 0x142738)); g.fillRect(70 + panel + px, py, scale, scale); + BlockState state = inspectedState(new BlockPos(x, y, z)); + g.setColor(new Color(state == null ? 0x3A3030 : blockColor(state, y))); + g.fillRect(120 + panel * 2 + px, py, scale, scale); + } + } + g.dispose(); ImageIO.write(image, "png", output.toFile()); + } + + void exportLavaProfile(Path output, CavernLavaFlows.Fall fall, String phase) throws IOException { + int dx = fall.outlet().x() - fall.source().x(), dz = fall.outlet().z() - fall.source().z(); + int top = Math.min(level.getMaxY(), fall.source().y() + 8), scale = 3; + BufferedImage image = new BufferedImage(780, (top - level.getMinY() + 1) * scale + 95, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + g.setColor(new Color(0xF0F0EC)); g.fillRect(0, 0, image.getWidth(), image.getHeight()); + g.setColor(new Color(0x17222C)); g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16)); + g.drawString("Actual volcanic flow | seed " + level.getSeed() + " | " + phase, 20, 25); + g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); + g.drawString("Source " + fall.source() + ". Plane follows its outlet; orange=lava, blue=water.", 20, 46); + g.drawString("Only inspected chunks. Sideways deviations and provenance are recorded in JSON. Not a Minecraft render.", 20, 66); + for (int step = -8; step <= 24; step++) for (int y = level.getMinY(); y <= top; y++) { + BlockState state = inspectedState(new BlockPos(fall.source().x() + dx * step, y, fall.source().z() + dz * step)); + g.setColor(new Color(state == null ? 0x3A3030 : blockColor(state, y))); + g.fillRect(20 + (step + 8) * scale, 85 + (top - y) * scale, scale, scale); + } + g.dispose(); ImageIO.write(image, "png", output.toFile()); + } + + void exportLowerMap(Path output, String phase) throws IOException { + Map plantsByChunk = new HashMap<>(); + for (BlockPos p : lowerPlants.keySet()) plantsByChunk.merge(ChunkPos.pack(p), 1, Integer::sum); + List selected = plantsByChunk.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed().thenComparing(Map.Entry.comparingByKey())) + .limit(6).map(Map.Entry::getKey).toList(); + if (selected.isEmpty()) selected = lowerTerraceTargets.stream().map(ChunkPos::pack).distinct().limit(6).toList(); + int tile = 208, scale = 12, rows = Math.max(1, (selected.size() + 2) / 3); + BufferedImage image = new BufferedImage(760, rows * 242 + 100, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + g.setColor(new Color(0xF0F0EC)); g.fillRect(0, 0, image.getWidth(), image.getHeight()); + g.setColor(new Color(0x17222C)); g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16)); + g.drawString("Actual lower terraces | seed " + level.getSeed() + " | " + phase, 20, 25); + g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); + g.drawString("Six inspected chunks with most observed lower plants. Highest valid lower ledge per column.", 20, 46); + g.drawString("Moss dark green; grass muted green; flowers pink; plants bright green; stone grey; no lower ledge dark blue.", 20, 64); + g.drawString("Upper floors are removed from this diagnostic view only. No new chunks loaded; not a Minecraft render.", 20, 82); + for (int i = 0; i < selected.size(); i++) { + LevelChunk chunk = chunks.get(selected.get(i)); + int left = 20 + (i % 3) * 242, top = 105 + (i / 3) * 242; + g.setColor(new Color(0x17222C)); g.drawString("Chunk " + chunk.getPos(), left, top); + for (int lx = 0; lx < 16; lx++) for (int lz = 0; lz < 16; lz++) { + int x = chunk.getPos().getMinBlockX() + lx, z = chunk.getPos().getMinBlockZ() + lz; + int color = 0x142738; + var surfaces = naturalSurfaces(x, z); + for (int y : surfaces) { + if (surfaces.getFirst() - y < 8) continue; + BlockPos floor = new BlockPos(x, y, z); + BlockState ground = chunk.getBlockState(floor), plant = chunk.getBlockState(floor.above()); + if (!ground.isCollisionShapeFullBlock(level, floor) || !isTerrain(ground)) continue; + color = blockColor(ground, y); + if (isTerracePlant(plant)) color = plant.is(BlockTags.FLOWERS) ? 0xE49AC5 : 0xA7CF70; + break; + } + g.setColor(new Color(color)); g.fillRect(left + lx * scale, top + 10 + lz * scale, scale, scale); + } + g.setColor(new Color(0x17222C)); g.drawRect(left, top + 10, tile - 16, tile - 16); + } + g.dispose(); ImageIO.write(image, "png", output.toFile()); + } + + int blockColor(BlockState state, int y) { + if (state.getFluidState().is(FluidTags.WATER)) return 0x207BD4; + if (state.getFluidState().is(FluidTags.LAVA)) return 0xED691E; + if (state.is(Blocks.SAND)) return 0xD5B668; + if (state.is(Blocks.GRAVEL)) return 0x9B8878; + if (state.is(Blocks.CLAY)) return 0x6D9FAD; + if (state.is(Blocks.MOSS_BLOCK)) return 0x58734C; + if (state.is(Blocks.GRASS_BLOCK)) return 0x879D73; + if (state.is(Blocks.PODZOL)) return 0x86633E; + if (state.is(Blocks.MYCELIUM)) return 0x958095; + if (state.is(Blocks.ANDESITE)) return 0x87999A; + if (state.is(Blocks.DIORITE)) return 0xDBD9CC; + if (state.is(Blocks.GRANITE)) return 0xBD8880; + if (state.is(Blocks.SULFUR)) return 0xD6C464; + if (state.isAir()) return 0x142738; + int value = 40 + Math.clamp(y, 0, level.getHeight()) * 180 / level.getHeight(); + return value << 16 | value << 8 | value; + } + + void exportCanopyMap(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 = 2, top = 125, left = 20; + BufferedImage image = new BufferedImage(Math.max(1060, (maxX - minX + 1) * scale + 40), + (maxZ - minZ + 1) * scale + top + 20, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + g.setColor(new Color(0xF0F0EC)); g.fillRect(0, 0, image.getWidth(), image.getHeight()); + g.setColor(new Color(0x17222C)); g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16)); + g.drawString("Actual canopy and rooted trees | seed " + level.getSeed() + " | " + phase, 20, 26); + g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); + g.drawString("Upper roots " + upperTreeCounts + "; interior oaks " + interiorOakTrees + ". Ground is measured below leaves.", 20, 48); + g.drawString("Leaves green; oak feet gold; other upper feet white; lower dark-oak feet purple; rare tree magenta. Circle: radius 128.", 20, 68); + g.drawString("Only inspected chunks, including selected ecology targets. Dark brown: uninspected; dark blue: void. Not a Minecraft render.", 20, 88); + g.drawString("Andesite blue-grey; diorite pale; granite pink; podzol brown; mycelium mauve. No new chunk is loaded for this view.", 20, 108); + 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--) { + BlockState state = chunk.getBlockState(new BlockPos(x, y, z)); + if (state.isAir() || isTerracePlant(state)) continue; + if (state.is(BlockTags.LEAVES)) { color = state.is(Blocks.DARK_OAK_LEAVES) ? 0x345947 : 0x5B8B51; break; } + if (state.is(BlockTags.LOGS)) { color = 0x73573C; break; } + if (state.getFluidState().isEmpty() && !isTerrain(state)) continue; + color = blockColor(state, y); break; + } + } + g.setColor(new Color(color)); g.fillRect(left + (x - minX) * scale, top + (z - minZ) * scale, scale, scale); + } + g.setColor(new Color(255, 255, 255, 110)); + g.drawOval(left + (-128 - minX) * scale, top + (-128 - minZ) * scale, 256 * scale, 256 * scale); + for (TreeWitness tree : trees) { + int color = tree.lowerTerrace() ? 0xB496E9 : tree.log().equals("minecraft:oak_log") ? 0xFFD76B + : REMARKABLE_LOGS.contains(tree.log()) ? 0xEE72CB : 0xF8F4E7; + int x = left + (tree.base().getX() - minX) * scale, z = top + (tree.base().getZ() - minZ) * scale; + g.setColor(new Color(0x17222C)); g.fillOval(x - 3, z - 3, 7, 7); + g.setColor(new Color(color)); g.fillOval(x - 2, z - 2, 5, 5); + } + g.dispose(); ImageIO.write(image, "png", output.toFile()); + } + + Map> shoreDiagnostics() { + Map> 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 exportSpringProfile(Path output, CavernHydrology.Spring spring, String phase) throws IOException { + int dx = spring.outlet().x() - spring.source().x(); + int dz = spring.outlet().z() - spring.source().z(); + int first = -8, last = 32, top = Math.min(level.getMaxY(), spring.source().y() + 12); + int scale = 3, width = (last - first + 1) * scale, height = (top - level.getMinY() + 1) * scale; + BufferedImage image = new BufferedImage(Math.max(720, width + 40), height + 105, 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, 15)); + graphics.drawString("Actual spring profile | seed " + level.getSeed() + " | " + phase, 20, 24); + graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); + graphics.drawString("Source " + spring.source() + "; plane follows its horizontal outlet.", 20, 44); + graphics.drawString("Water blue; rock grey; air dark blue; uninspected brown. Not a Minecraft render.", 20, 62); + graphics.drawString("Y=" + top + ".." + level.getMinY() + ". Sideways flow outside this plane is listed in the JSON.", 20, 80); + for (int step = first; step <= last; step++) { + for (int y = level.getMinY(); y <= top; y++) { + int x = spring.source().x() + dx * step, z = spring.source().z() + dz * step; + LevelChunk chunk = chunks.get(ChunkPos.pack(x >> 4, z >> 4)); + int color = 0x3A3030; + if (chunk != null) { + BlockState state = chunk.getBlockState(new BlockPos(x, y, z)); + color = state.isAir() ? 0x142738 : state.getFluidState().is(FluidTags.WATER) ? 0x207BD4 + : state.is(Blocks.SAND) ? 0xD5B668 : state.is(Blocks.GRAVEL) ? 0x9B8878 + : state.is(Blocks.CLAY) ? 0x6D9FAD : 0xA2A2A2; + } + graphics.setColor(new Color(color)); + graphics.fillRect(20 + (step - first) * scale, 95 + (top - y) * scale, scale, scale); + } + } + graphics.dispose(); ImageIO.write(image, "png", output.toFile()); + } + + 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; gravel brown; moss/grass muted green; flowers pink; stone 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.getFluidState().is(FluidTags.LAVA)) { color = 0xED691E; 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.is(BlockTags.FLOWERS)) { color = 0xE49AC5; break; } + if (state.is(Blocks.MOSS_BLOCK) || state.is(Blocks.GRASS_BLOCK)) { color = blockColor(state, y); 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()); + } + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/SanctuaryWorldGameTests.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/SanctuaryWorldGameTests.java index 5570759..124776d 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/SanctuaryWorldGameTests.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/SanctuaryWorldGameTests.java @@ -5,6 +5,7 @@ import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime; import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime; import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime; +import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime; import net.minecraft.tags.FluidTags; import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; import fr.koka.sanctuary.worldgen.IslandShape; @@ -32,7 +33,8 @@ public final class SanctuaryWorldGameTests { @GameTest(maxTicks = 3000) public void generatedSurfaceWaterRemainsContained(GameTestHelper helper) throws java.io.IOException { var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); - if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.start(helper); + if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.start(helper); + else if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.start(helper); else if (WoodlandHydrologyRuntime.enabled(generator)) WoodlandDiagnostics.start(helper); else if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.start(helper); else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.start(helper); @@ -42,7 +44,8 @@ public final class SanctuaryWorldGameTests { @GameTest(maxTicks = 200) public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) { var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); - if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.verifyLegacyIsolation(helper); + if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.verifyLegacyIsolation(helper); + else if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.verifyLegacyIsolation(helper); else if (WoodlandHydrologyRuntime.enabled(generator)) WoodlandDiagnostics.verifyLegacyIsolation(helper); else if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.verifyLegacyIsolation(helper); else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.verifyLegacyIsolation(helper); @@ -124,13 +127,24 @@ public final class SanctuaryWorldGameTests { if (chunk.getSection(0).hasOnlyAir()) continue; var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); helper.assertTrue(NaturalHydrologyRuntime.enabled(generator) || LayeredHydrologyRuntime.enabled(generator) - || WoodlandHydrologyRuntime.enabled(generator) || RiftHydrologyRuntime.enabled(generator), + || WoodlandHydrologyRuntime.enabled(generator) || RiftHydrologyRuntime.enabled(generator) || CavernHydrologyRuntime.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); + if (CavernHydrologyRuntime.enabled(generator)) { + boolean water = state.getFluidState().is(FluidTags.WATER) + && CavernDiagnostics.isWithinSpringInspectionFootprint(CavernHydrologyRuntime.cavernPlan(helper.getLevel()), x, z); + final int bx = x, bz = z; + boolean lava = state.getFluidState().is(FluidTags.LAVA) + && CavernHydrologyRuntime.lavaPlan(helper.getLevel()).flowBounds().stream().anyMatch(b -> + bx >= (Math.floorDiv(b.minX(),16) - 1) * 16 && bx <= (Math.floorDiv(b.maxX(),16) + 2) * 16 - 1 + && bz >= (Math.floorDiv(b.minZ(),16) - 1) * 16 && bz <= (Math.floorDiv(b.maxZ(),16) + 2) * 16 - 1); + helper.assertTrue(state.isAir() || water || lava, "Lower margin must remain void except declared fluid falls: " + p); + continue; + } if (RiftHydrologyRuntime.enabled(generator)) { boolean water = state.getFluidState().is(FluidTags.WATER) && RiftDiagnostics.isWithinSpringInspectionFootprint(RiftHydrologyRuntime.riftPlan(helper.getLevel()), x, z); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java index 21397bd..533312b 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -1,5 +1,9 @@ package fr.koka.sanctuary; +import fr.koka.sanctuary.worldgen.CavernIslandBiomeSource; +import fr.koka.sanctuary.worldgen.CavernGrovesFeature; +import fr.koka.sanctuary.worldgen.CavernDecorationsFeature; +import fr.koka.sanctuary.worldgen.CavernShoreSugarCaneFeature; import fr.koka.sanctuary.worldgen.RiftIslandDensity; import fr.koka.sanctuary.worldgen.RiftIslandBiomeSource; import fr.koka.sanctuary.worldgen.RiftGrovesFeature; @@ -45,6 +49,10 @@ public final class SanctuaryMod implements ModInitializer { Registry.register(BuiltInRegistries.BIOME_SOURCE, id("rift_island"), RiftIslandBiomeSource.CODEC); Registry.register(BuiltInRegistries.FEATURE_TYPE, id("rift_groves"), RiftGrovesFeature.CODEC); Registry.register(BuiltInRegistries.FEATURE_TYPE, id("rift_shore_sugar_cane"), RiftShoreSugarCaneFeature.CODEC); + Registry.register(BuiltInRegistries.BIOME_SOURCE, id("cavern_island"), CavernIslandBiomeSource.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_groves"), CavernGrovesFeature.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_decorations"), CavernDecorationsFeature.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_shore_sugar_cane"), CavernShoreSugarCaneFeature.CODEC); LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available."); } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CavernSpringOutletMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CavernSpringOutletMixin.java new file mode 100644 index 0000000..2e934a3 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CavernSpringOutletMixin.java @@ -0,0 +1,29 @@ +package fr.koka.sanctuary.mixin; + +import fr.koka.sanctuary.worldgen.CavernSpringOutlets; +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 CavernSpringOutletMixin { + @Unique + private CavernSpringOutlets.Pending sanctuary$pendingCavernOutlets = CavernSpringOutlets.Pending.EMPTY; + + @Inject(method = "postProcessGeneration", at = @At("HEAD")) + private void sanctuary$captureCavernOutlets(ServerLevel level, CallbackInfo callback) { + sanctuary$pendingCavernOutlets = CavernSpringOutlets.capturePending(level, (LevelChunk) (Object) this); + } + + @Inject(method = "postProcessGeneration", at = @At("TAIL")) + private void sanctuary$finishCavernOutlets(ServerLevel level, CallbackInfo callback) { + var pending = sanctuary$pendingCavernOutlets; + sanctuary$pendingCavernOutlets = CavernSpringOutlets.Pending.EMPTY; + CavernSpringOutlets.finish(level, (LevelChunk) (Object) this, pending); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/SurfaceHydrologyMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/SurfaceHydrologyMixin.java index 476b179..c174035 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/SurfaceHydrologyMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/SurfaceHydrologyMixin.java @@ -5,6 +5,7 @@ import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime; import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime; import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime; +import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime; import java.util.Set; import java.util.concurrent.CompletableFuture; import net.minecraft.core.Holder; @@ -29,7 +30,12 @@ public abstract class SurfaceHydrologyMixin { StructureManager structures, BiomeManager biomeManager, WorldGenRegion region, Set> biomes, CallbackInfoReturnable> callback) { var generator = (NoiseBasedChunkGenerator) (Object) this; - if (RiftHydrologyRuntime.enabled(generator)) { + if (CavernHydrologyRuntime.enabled(generator)) { + callback.setReturnValue(callback.getReturnValue().thenApply(generated -> { + CavernHydrologyRuntime.apply(generator, randomState, generated); + return generated; + })); + } else if (RiftHydrologyRuntime.enabled(generator)) { callback.setReturnValue(callback.getReturnValue().thenApply(generated -> { RiftHydrologyRuntime.apply(generator, randomState, generated); return generated; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernBiomePalette.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernBiomePalette.java new file mode 100644 index 0000000..4f177e9 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernBiomePalette.java @@ -0,0 +1,34 @@ +package fr.koka.sanctuary.worldgen; + +/** Alpha.9 keeps the upper woodland climate and specializes sheltered lower layers. */ +public final class CavernBiomePalette { + public enum Kind { + OAK_FOREST, BIRCH_FOREST, CLEARING, DRY_WOODLAND, ROCKY_HEATH, + DARK_GROVE, BAMBOO_GROVE, SULFUR_DEPTHS, LUSH_CAVES, DRIPSTONE_CAVES + } + + private CavernBiomePalette() {} + + public static Kind select(int blockY, float variation, float moisture) { + double elevation = blockY - Math.clamp(variation, -1.0F, 1.0F) * 16 + - Math.clamp(moisture, -1.0F, 1.0F) * 10; + // Small climate pockets replace alpha.8's broad sulfur province. Neither + // mineral pockets nor cave vegetation can take over the upper forest. + if (blockY < 176 && elevation < 168 && variation > 0.22F && variation < 0.42F + && moisture > -0.42F && moisture < -0.12F) return Kind.SULFUR_DEPTHS; + if (variation > 0.48F) return Kind.ROCKY_HEATH; + if (blockY < 184 && elevation < 180) { + if (moisture > 0.05F && variation > -0.32F) return Kind.LUSH_CAVES; + if (moisture < -0.18F && variation < 0.22F) return Kind.DRIPSTONE_CAVES; + } + if (elevation < 214) { + if (variation < -0.45F && moisture > 0.40F) return Kind.BAMBOO_GROVE; + double shelteredMoisture = moisture + Math.min(0.55, (214 - elevation) / 160); + if (shelteredMoisture > -0.08) return Kind.DARK_GROVE; + } + if (moisture < -0.55F && variation > -0.25F) return Kind.DRY_WOODLAND; + if (Math.abs(variation) < 0.09F && moisture < 0.35F) return Kind.CLEARING; + if (variation < -0.33F) return Kind.BIRCH_FOREST; + return Kind.OAK_FOREST; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernDecorationsFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernDecorationsFeature.java new file mode 100644 index 0000000..bff72e3 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernDecorationsFeature.java @@ -0,0 +1,296 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import fr.koka.sanctuary.SanctuaryMod; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.tags.BlockTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.AmethystClusterBlock; +import net.minecraft.world.level.block.CaveVines; +import net.minecraft.world.level.block.GrowingPlantHeadBlock; +import net.minecraft.world.level.block.PotentSulfurBlock; +import net.minecraft.world.level.block.SpeleothemBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.PotentSulfurState; +import net.minecraft.world.level.block.state.properties.SpeleothemThickness; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.GeodeFeature; + +/** Decorations in existing lower cavities only. All writes belong to the owner's chunk. */ +public final class CavernDecorationsFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(CavernDecorationsFeature::new); + private static final ResourceKey GEODE = ResourceKey.create(Registries.FEATURE, SanctuaryMod.id("cavern_amethyst_geode")); + private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; + public enum Kind { LUSH, DRIPSTONE, SULFUR, GEODE } + public record Candidate(Kind kind, BlockPos floor, BlockPos ceiling) { + public Candidate { floor = floor.immutable(); ceiling = ceiling.immutable(); } + } + + @Override public MapCodec codec() { return CODEC; } + + /** Pure, bounded probes for development diagnostics; no chunk loads or successful-placement promise. */ + public static List candidates(NoiseBasedChunkGenerator generator, RandomState state, int chunkX, int chunkZ) { + if (!CavernHydrologyRuntime.enabled(generator) || Math.abs(chunkX) > 17 || Math.abs(chunkZ) > 17) return List.of(); + var density = generator.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + var biomes = generator.getBiomeSource().createUncachedResolver(state); + List found = new ArrayList<>(); + for (int[] offset : new int[][]{{4, 4}, {4, 12}, {12, 4}, {12, 12}, {8, 8}}) { + int x = chunkX * 16 + offset[0], z = chunkZ * 16 + offset[1], floors = 0; + for (int y = 182; y >= 40; y--) { + if (sampler.sample(x, y, z) <= 0 || sampler.sample(x, y + 1, z) > 0 + || sampler.sample(x, y + 2, z) > 0 || sampler.sample(x, y - 1, z) <= 0 + || sampler.sample(x, y - 2, z) <= 0) continue; + int roof = y + 3; + while (roof <= Math.min(230, y + 48) && sampler.sample(x, roof, z) <= 0) roof++; + if (roof > Math.min(230, y + 48) || roof - y < 6 + || sampler.sample(x, roof + 1, z) <= 0) continue; + BlockPos floor = new BlockPos(x, y, z), ceiling = new BlockPos(x, roof, z); + var biome = biomes.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2); + if (biome.is(CavernIslandBiomeSource.LUSH_CAVES)) found.add(new Candidate(Kind.LUSH, floor, ceiling)); + else if (biome.is(CavernIslandBiomeSource.DRIPSTONE_CAVES)) found.add(new Candidate(Kind.DRIPSTONE, floor, ceiling)); + else if (biome.is(CavernIslandBiomeSource.SULFUR_DEPTHS) && y < 176) + found.add(new Candidate(Kind.SULFUR, floor, ceiling)); + if (offset[0] == 8 && offset[1] == 8 && Math.floorMod(chunkSeed(state.seed(), chunkX, chunkZ, 0x9501L), 8) == 0) + found.add(new Candidate(Kind.GEODE, floor, ceiling)); + if (++floors >= 3) break; + } + } + // Geodes keep their entry free before this feature's moss, grass or pointed + // dripstone can occupy it. Stable sorting preserves every other candidate order. + found.sort(java.util.Comparator.comparingInt(candidate -> candidate.kind() == Kind.GEODE ? 0 : 1)); + return List.copyOf(found); + } + + @Override public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false; + var state = level.getLevel().getChunkSource().randomState(); + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + int cx = origin.getX() >> 4, cz = origin.getZ() >> 4; + boolean changed = false, geyser = false, geode = false; + for (Candidate candidate : candidates(noise, state, cx, cz)) { + BlockPos floor = candidate.floor(); + if (!stable(level, sampler, floor) || !level.getBlockState(floor.above()).isAir()) continue; + long seed = WoodlandGroveGeometry.seed(state.seed(), floor.getX(), floor.getY(), floor.getZ(), 0x9502L); + switch (candidate.kind()) { + case LUSH -> changed |= lush(level, noise, state, sampler, candidate, seed); + case DRIPSTONE -> changed |= dripstone(level, noise, state, sampler, candidate, seed); + case SULFUR -> { + if (!geyser && Math.floorMod(chunkSeed(state.seed(), cx, cz, 0x9503L), 2) == 0) + changed |= geyser = geyser(level, noise, state, sampler, candidate); + } + case GEODE -> { if (!geode) changed |= geode = geode(level, noise, state, sampler, candidate, seed); } + } + } + return changed; + } + + private boolean lush(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) { + BlockPos floor = site.floor(), roof = site.ceiling(); + int gap = roof.getY() - floor.getY(); + if (CavernHydrologyRuntime.protects(noise, state, floor, 3, gap + 2, 3)) return false; + boolean changed = false; + for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) { + if (dx * dx + dz * dz > 5) continue; + BlockPos p = floor.offset(dx, 0, dz); + if (!stable(level, sampler, p) || !stable(level, sampler, p.below()) || !stable(level, sampler, p.below(2)) + || !level.getBlockState(p.above()).isAir()) continue; + setBlock(level, p, Blocks.MOSS_BLOCK.defaultBlockState()); + long local = WoodlandGroveGeometry.seed(seed, p.getX(), p.getY(), p.getZ(), 0x9504L); + BlockState plant = (Math.floorMod(local, 5) == 0 ? Blocks.AZALEA : Math.floorMod(local, 3) == 0 + ? Blocks.MOSS_CARPET : Blocks.SHORT_GRASS).defaultBlockState(); + if (plant.canSurvive(level, p.above())) setBlock(level, p.above(), plant); + changed = true; + } + // Real cave-vine heads and bodies remain harvestable and luminous via BERRIES. + int length = Math.min(gap - 3, 3 + Math.floorMod(seed, 6)); + if (stable(level, sampler, roof) && stable(level, sampler, roof.above())) { + boolean free = true; + for (int d = 1; d <= length; d++) free &= level.getBlockState(roof.below(d)).isAir(); + if (free) for (int d = 1; d <= length; d++) { + BlockPos p = roof.below(d); + BlockState vine = (d == length ? Blocks.CAVE_VINES : Blocks.CAVE_VINES_PLANT).defaultBlockState() + .setValue(CaveVines.BERRIES, d == length || Math.floorMod(seed + d, 3) == 0); + if (d == length) vine = vine.setValue(GrowingPlantHeadBlock.AGE, 25); + if (!vine.canSurvive(level, p)) break; + setBlock(level, p, vine); changed = true; + } + } + return changed; + } + + private boolean dripstone(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) { + BlockPos floor = site.floor(), roof = site.ceiling(); + int gap = roof.getY() - floor.getY(); + if (CavernHydrologyRuntime.protects(noise, state, floor, 1, gap + 2, 3)) return false; + int length = Math.min((gap - 3) / 2, 1 + Math.floorMod(seed, 4)); + boolean changed = spike(level, sampler, floor, Direction.UP, length); + changed |= spike(level, sampler, roof, Direction.DOWN, length); + return changed; + } + + private boolean spike(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, BlockPos support, Direction direction, int length) { + if (length < 1 || !stable(level, sampler, support) || !stable(level, sampler, support.relative(direction.getOpposite()))) return false; + for (int d = 1; d <= length; d++) { + BlockPos p = support.relative(direction, d); + if (!level.getBlockState(p).isAir() || sampler.sample(p.getX(), p.getY(), p.getZ()) > 0) return false; + } + setBlock(level, support, Blocks.DRIPSTONE_BLOCK.defaultBlockState()); + for (int d = 1; d <= length; d++) { + SpeleothemThickness thickness = d == length ? SpeleothemThickness.TIP + : d == length - 1 ? SpeleothemThickness.FRUSTUM : d == 1 ? SpeleothemThickness.BASE : SpeleothemThickness.MIDDLE; + BlockState pointed = Blocks.POINTED_DRIPSTONE.defaultBlockState().setValue(SpeleothemBlock.TIP_DIRECTION, direction) + .setValue(SpeleothemBlock.THICKNESS, thickness); + setBlock(level, support.relative(direction, d), pointed); + } + return true; + } + + private boolean geyser(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site) { + BlockPos water = site.floor(), potent = water.below(), heat = water.below(2); + if (site.ceiling().getY() - water.getY() < 8 + || CavernHydrologyRuntime.protects(noise, state, water, 2, 8, 5)) return false; + for (int d = 0; d <= 4; d++) if (!stable(level, sampler, water.below(d))) return false; + for (Direction side : SIDES) for (int d = 0; d <= 2; d++) + if (!stable(level, sampler, water.below(d).relative(side))) return false; + for (int d = 1; d <= 7; d++) if (!level.getBlockState(water.above(d)).isAir()) return false; + // One source, four original rock walls, original deep support. Vanilla's + // periodic geyser ticker requires magma below potent sulfur and source water above. + setBlock(level, heat, Blocks.MAGMA_BLOCK.defaultBlockState()); + setBlock(level, water, Blocks.WATER.defaultBlockState()); + setBlock(level, potent, Blocks.POTENT_SULFUR.defaultBlockState().setValue(PotentSulfurBlock.STATE, PotentSulfurState.DORMANT)); + level.getChunk(water).markPosForPostProcessing(water); + level.getChunk(potent).markPosForPostProcessing(potent); + return true; + } + + private boolean geode(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) { + BlockPos origin = site.floor().offset(-1, -9, -1); + // Include the vanilla write range, its crystal neighbours and the entrance. + // This asymmetric envelope is centred explicitly so its positive edge is protected too. + if (origin.getY() - 6 < 24 || !level.getBlockState(site.floor().above(2)).isAir() + || CavernHydrologyRuntime.protects(noise, state, origin.offset(1, -6, 1), 7, 17, 0)) return false; + Map before = new LinkedHashMap<>(); + for (int dx = -6; dx <= 8; dx++) for (int dz = -6; dz <= 8; dz++) for (int dy = -6; dy <= 11; dy++) { + BlockPos p = origin.offset(dx, dy, dz); + BlockState original = level.getBlockState(p); + // No block-entity data or fluid scheduler state needs to be restored. + if (original.hasBlockEntity() || !original.getFluidState().isEmpty()) return false; + before.put(p, original); + } + Feature feature = level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(GEODE).value(); + if (!(feature instanceof GeodeFeature geode) || geode.minGenOffset() != -5 || geode.maxGenOffset() != 7) return false; + boolean accepted = false; + try { + if (!geode.place(level, noise, RandomSource.create(seed), origin) || !naturalGeodeChanges(level, sampler, before)) return false; + var entrance = geodeEntrance(level, sampler, site.floor().above(), origin, before); + if (entrance.isEmpty()) return false; + for (BlockPos p : entrance) if (!level.getBlockState(p).isAir()) setBlock(level, p, Blocks.AIR.defaultBlockState()); + if (!naturalGeodeChanges(level, sampler, before)) return false; + accepted = true; + return true; + } finally { + if (!accepted) before.forEach((p, original) -> { + if (!level.getBlockState(p).equals(original)) setBlock(level, p, original); + }); + } + } + + /** Test the actual vanilla mask, not unused bounding-box corners. A geode may + * excavate original rock but cannot fill original air or remove earlier decoration. */ + private static boolean naturalGeodeChanges(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + Map before) { + for (var entry : before.entrySet()) { + BlockPos p = entry.getKey(); + BlockState original = entry.getValue(), current = level.getBlockState(p); + if (original.equals(current)) continue; + if (!carvableOriginal(level, sampler, p, original)) return false; + } + return true; + } + + private static boolean carvableOriginal(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + BlockPos p, BlockState original) { + return original != null && original.isCollisionShapeFullBlock(level, p) + && !original.hasBlockEntity() && original.getFluidState().isEmpty() + && !original.is(BlockTags.LOGS) && !original.is(BlockTags.LEAVES) + && !original.is(Blocks.BUDDING_AMETHYST) && !original.is(Blocks.MAGMA_BLOCK) + && sampler.sample(p.getX(), p.getY(), p.getZ()) > 0; + } + + private static List geodeEntrance(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + BlockPos start, BlockPos origin, Map before) { + var previous = new java.util.HashMap(); + var distances = new java.util.HashMap(); + var queue = new java.util.ArrayDeque(); + queue.add(start); distances.put(start, 0); + BlockPos destination = null; + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + int distance = distances.get(p); + boolean adjacentBudding = false; + for (Direction direction : Direction.values()) + adjacentBudding |= level.getBlockState(p.relative(direction)).is(Blocks.BUDDING_AMETHYST) + || level.getBlockState(p.above().relative(direction)).is(Blocks.BUDDING_AMETHYST); + if (p.getY() <= start.getY() - 3 && level.getBlockState(p).isAir() + && level.getBlockState(p.above()).isAir() && adjacentBudding) { destination = p; break; } + if (distance >= 16) continue; + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + if (Math.abs(next.getX() - start.getX()) > 6 || Math.abs(next.getZ() - start.getZ()) > 6 + || next.getY() < origin.getY() - 5 || next.getY() > start.getY() + || distances.containsKey(next) || !before.containsKey(next) || !before.containsKey(next.above())) continue; + if (!entranceBlock(level, sampler, next, before) || !entranceBlock(level, sampler, next.above(), before)) continue; + distances.put(next, distance + 1); previous.put(next, p); queue.addLast(next); + } + } + if (destination == null) return List.of(); + var passage = new java.util.LinkedHashSet(); + for (BlockPos p = destination; p != null; p = previous.get(p)) { passage.add(p); passage.add(p.above()); } + return List.copyOf(passage); + } + + private static boolean entranceBlock(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + BlockPos p, Map before) { + BlockState current = level.getBlockState(p); + if (current.isAir()) return true; + // Keep every budding block and attached crystal. Search around them instead. + if (current.is(Blocks.BUDDING_AMETHYST) || current.is(Blocks.AMETHYST_CLUSTER) + || current.is(Blocks.SMALL_AMETHYST_BUD) || current.is(Blocks.MEDIUM_AMETHYST_BUD) + || current.is(Blocks.LARGE_AMETHYST_BUD)) return false; + for (Direction direction : Direction.values()) { + BlockState neighbour = level.getBlockState(p.relative(direction)); + if (neighbour.getBlock() instanceof AmethystClusterBlock + && neighbour.getValue(AmethystClusterBlock.FACING) == direction) return false; + } + return carvableOriginal(level, sampler, p, before.get(p)); + } + + private static boolean stable(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, BlockPos p) { + BlockState state = level.getBlockState(p); + return sampler.sample(p.getX(), p.getY(), p.getZ()) > 0 && state.isCollisionShapeFullBlock(level, p) + && state.getFluidState().isEmpty() && !state.hasBlockEntity() && !state.is(BlockTags.LOGS) + && !state.is(BlockTags.LEAVES) && !state.is(Blocks.BUDDING_AMETHYST) && !state.is(Blocks.MAGMA_BLOCK); + } + + private static long chunkSeed(long seed, int x, int z, long salt) { + return WoodlandGroveGeometry.seed(seed, x * 16, 0, z * 16, salt); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernGrovesFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernGrovesFeature.java new file mode 100644 index 0000000..deaf4fe --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernGrovesFeature.java @@ -0,0 +1,296 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; +import java.util.WeakHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.data.worldgen.features.TreeFeatures; +import net.minecraft.data.worldgen.features.VegetationFeatures; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.BlockTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +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.ChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; + +/** Alpha.9: sheltered vanilla mushroom trees and dark oak on natural lower floors, + * plus one globally selected remarkable tree. + * Placement runs only in new generation. No world journal or mutable placement counter is retained. */ +public final class CavernGrovesFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(CavernGrovesFeature::new); + private static final Map> REMARKABLE = + Collections.synchronizedMap(new WeakHashMap<>()); + + public record RemarkableTree(BlockPos base, String featureId, int radius, int height) { + public RemarkableTree { base = base.immutable(); } + } + private record Species(ResourceKey key, int width, int radius, int height) {} + private record Candidate(int x, int z, long rank) {} + @FunctionalInterface + private interface Protection { + boolean intersects(BlockPos base, int radius, int height, int rootDepth); + } + private static final Species CHERRY = new Species(TreeFeatures.CHERRY, 1, 9, 15); + private static final Species[] SPECIES = {CHERRY, CHERRY, CHERRY, CHERRY, + new Species(TreeFeatures.SPRUCE, 1, 5, 15), new Species(TreeFeatures.JUNGLE_TREE_NO_VINE, 1, 5, 14), + new Species(TreeFeatures.ACACIA, 1, 7, 14), new Species(TreeFeatures.MANGROVE, 1, 10, 20), + new Species(TreeFeatures.PALE_OAK_BONEMEAL, 2, 6, 12)}; + + @Override public MapCodec codec() { return CODEC; } + + public static Optional remarkablePlan(ServerLevel level) { + if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty(); + return remarkablePlan(noise, level.getChunkSource().randomState()); + } + + public static Optional remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) { + if (!CavernHydrologyRuntime.enabled(noise)) return Optional.empty(); + return REMARKABLE.computeIfAbsent(state, ignored -> { + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + Protection protection = (base, radius, height, rootDepth) -> + CavernHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth); + Species species = SPECIES[Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), 0, 0, 0, 0x70EEL), SPECIES.length)]; + var candidates = new ArrayList(); + for (int x = -152; x <= 152; x += 16) for (int z = -152; z <= 152; z += 16) { + if (Math.hypot(x, z) <= 168) candidates.add(new Candidate(x, z, + WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x71AFL))); + } + candidates.sort(Comparator.comparingLong(Candidate::rank).thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)); + for (Candidate candidate : candidates) { + int bed = WoodlandGroveGeometry.highestSurface(sampler, candidate.x(), candidate.z()); + if (!WoodlandGroveGeometry.naturalFootprint(sampler, candidate.x(), bed, candidate.z(), species.width())) continue; + BlockPos base = new BlockPos(candidate.x(), bed + 1, candidate.z()); + if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species))) continue; + if (!WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(), + species.width(), species.radius(), species.height())) continue; + return Optional.of(new RemarkableTree(base, species.key().identifier().toString(), species.radius(), species.height())); + } + return Optional.empty(); + }); + } + + @Override + public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false; + int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16; + if (Math.abs((long) minX) > 272 || Math.abs((long) minZ) > 272) return false; + var state = level.getLevel().getChunkSource().randomState(); + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + Protection protection = (base, radius, height, rootDepth) -> + CavernHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth); + boolean changed = false; + int[] heights = new int[256]; + Arrays.fill(heights, Integer.MIN_VALUE); + var remarkable = remarkablePlan(noise, state); + if (remarkable.isPresent()) { + var tree = remarkable.get(); + BlockPos base = tree.base(); + if ((base.getX() >> 4) == (minX >> 4) && (base.getZ() >> 4) == (minZ >> 4)) { + for (Species species : SPECIES) if (species.key().identifier().toString().equals(tree.featureId())) { + changed |= tree(level, generator, sampler, state.seed(), base, species, protection, 0x723BL); + break; + } + } + } + // The scarce tall rooms usually go to dark oak first; one quarter of chunks + // give mushroom trees first choice. This changes priority, never attempt budgets + // or natural fit requirements, and uses a separate deterministic chunk seed. + boolean mushroomsFirst = Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), minX, 0, minZ, 0x8503L), 4) == 0; + if (mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights); + // Four spaced origins per chunk; the 2x2 trunk and full canopy still have to fit the cave. + for (int x = minX + 4; x < minX + 16; x += 8) for (int z = minZ + 4; z < minZ + 16; z += 8) { + int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), placed = 0; + for (int bed = highest - 8; bed >= 40; bed--) { + BlockPos base = new BlockPos(x, bed + 1, z); + if (!ground(level, base.below()) || !level.getBlockState(base).isAir()) continue; + var biome = level.getBiome(base); + boolean dark = biome.is(CavernIslandBiomeSource.DARK_GROVE); + boolean bamboo = biome.is(CavernIslandBiomeSource.BAMBOO_GROVE); + if (!dark && !bamboo) continue; + long placementSeed = WoodlandGroveGeometry.seed(state.seed(), x, bed, z, 0x730DL); + if (dark) { + if (tree(level, generator, sampler, state.seed(), base, + new Species(TreeFeatures.DARK_OAK, 2, 6, 12), protection, 0x730DL)) { changed = true; placed++; } + } else if (Math.floorMod(placementSeed, 8) == 0) { + if (tree(level, generator, sampler, state.seed(), base, + new Species(TreeFeatures.JUNGLE_TREE_NO_VINE, 1, 5, 14), protection, 0x735BL)) { changed = true; placed++; } + } else if (Math.floorMod(placementSeed, 3) == 0 + && WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1) + && WoodlandGroveGeometry.treeRoom(sampler, x, bed + 1, z, 1, 3, 16) + && !blocked(protection, base, 4, 17) && actualRoom(level, base, 1, 3, 16)) { + setBlock(level, base.below(), Blocks.PODZOL.defaultBlockState()); + changed |= level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(VegetationFeatures.BAMBOO_NO_PODZOL) + .value().place(level, generator, RandomSource.create(placementSeed), base); + placed++; + } + if (placed >= 2) break; + } + } + if (!mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights); + // Coherent ground patches: about 40% moss, 40% podzol, 20% mycelium. + // Small mushrooms are accents (one opportunity per hundred columns), not the canopy. + for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) { + int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), floors = 0; + long detail = WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x843EL); + boolean smallMushroomPlaced = false; + for (int bed = highest - 8; bed >= 32; bed--) { + BlockPos floor = new BlockPos(x, bed, z), plant = floor.above(); + if (!ground(level, floor) || !level.getBlockState(plant).isAir()) continue; + var biome = level.getBiome(plant); + if (!biome.is(CavernIslandBiomeSource.DARK_GROVE) && !biome.is(CavernIslandBiomeSource.BAMBOO_GROVE)) continue; + if (!WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1) || blocked(protection, plant, 1, 2)) continue; + long patch = WoodlandGroveGeometry.seed(state.seed(), Math.floorDiv(x, 7), 0, Math.floorDiv(z, 7), 0x742DL); + int soilKind = Math.floorMod(patch, 5); + BlockState soil = (soilKind < 2 ? Blocks.MOSS_BLOCK : soilKind < 4 ? Blocks.PODZOL : Blocks.MYCELIUM).defaultBlockState(); + setBlock(level, floor, soil); + if (!smallMushroomPlaced && Math.floorMod(detail, 100) == 0) { + BlockState mushroom = (Math.floorMod(detail >>> 8, 2) == 0 ? Blocks.BROWN_MUSHROOM : Blocks.RED_MUSHROOM).defaultBlockState(); + if (mushroom.canSurvive(level, plant)) { + setBlock(level, plant, mushroom); + smallMushroomPlaced = true; + } + } + changed = true; + if (++floors >= 3) break; + } + } + return changed; + } + + private boolean mushroomTrees(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler, + long seed, Protection protection, int minX, int minZ, int[] heights) { + boolean changed = false; + RandomSource mushroomSites = RandomSource.create(WoodlandGroveGeometry.seed(seed, minX, 0, minZ, 0x8501L)); + int mushroomAttempts = 1 + mushroomSites.nextInt(3); + for (int attempt = 0; attempt < mushroomAttempts; attempt++) { + int x = minX + 3 + mushroomSites.nextInt(10), z = minZ + 3 + mushroomSites.nextInt(10); + int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights); + for (int bed = highest - 8; bed >= 32; bed--) { + BlockPos base = new BlockPos(x, bed + 1, z); + if (!ground(level, base.below()) || !level.getBlockState(base).isAir() + || !level.getBiome(base).is(CavernIslandBiomeSource.DARK_GROVE)) continue; + long mushroomSeed = WoodlandGroveGeometry.seed(seed, x, bed, z, 0x8502L + attempt); + var key = Math.floorMod(mushroomSeed, 2) == 0 ? TreeFeatures.HUGE_BROWN_MUSHROOM : TreeFeatures.HUGE_RED_MUSHROOM; + Feature feature = level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(key).value(); + if (!(feature instanceof AbstractHugeMushroomFeature mushroom)) break; + int height = mushroom.getTreeHeight(RandomSource.create(mushroomSeed)); + int radius = mushroom.foliageRadius(); + if (!mushroomRoom(level, sampler, protection, base, radius, height)) continue; + BlockState originalFloor = level.getBlockState(base.below()); + setBlock(level, base.below(), (Math.floorMod(mushroomSeed, 5) == 0 ? Blocks.MYCELIUM : Blocks.PODZOL).defaultBlockState()); + // Vanilla consumes its height draw first. Replaying the same random seed + // gives precisely the height whose natural envelope was checked above. + boolean placed = mushroom.place(level, generator, RandomSource.create(mushroomSeed), base); + if (!placed) setBlock(level, base.below(), originalFloor); + changed |= placed; + break; + } + } + return changed; + } + + private boolean tree(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler, long seed, + BlockPos base, Species species, Protection protection, long salt) { + if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species)) + || !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), species.width()) + || !actualRoom(level, base, species.width(), species.radius(), species.height()) + || !WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(), species.width(), species.radius(), species.height())) return false; + for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) { + BlockPos floor = base.offset(dx, -1, dz); + if (!ground(level, floor)) return false; + } + for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) { + setBlock(level, base.offset(dx, -1, dz), Blocks.PODZOL.defaultBlockState()); + } + return level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(species.key()).value() + .place(level, generator, RandomSource.create(WoodlandGroveGeometry.seed(seed, base.getX(), base.getY(), base.getZ(), salt)), base); + } + + private static boolean actualRoom(WorldGenLevel level, BlockPos base, int width, int radius, int height) { + for (int y = 0; y <= height; y++) { + int from = y < 3 ? 0 : -radius, to = y < 3 ? width - 1 : radius; + for (int dx = from; dx <= to; dx++) for (int dz = from; dz <= to; dz++) { + BlockState current = level.getBlockState(base.offset(dx, y, dz)); + if (!current.getFluidState().isEmpty() || !(current.isAir() || current.is(BlockTags.LEAVES) + || current.getBlock() instanceof VegetationBlock)) return false; + } + } + return true; + } + + private static boolean ground(WorldGenLevel level, BlockPos floor) { + BlockState state = level.getBlockState(floor); + if (state.is(Blocks.DEEPSLATE)) { + // Deep humid ledges can support organic soil. Check every tree foot rather + // than admitting this rock globally, including for the remarkable tree. + var biome = level.getBiome(floor.above()); + return biome.is(CavernIslandBiomeSource.DARK_GROVE) || biome.is(CavernIslandBiomeSource.BAMBOO_GROVE); + } + return state.is(Blocks.STONE) || state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.PODZOL) + || state.is(Blocks.MYCELIUM) || state.is(Blocks.ROOTED_DIRT) || state.is(Blocks.COARSE_DIRT) + || state.is(Blocks.GRANITE) || state.is(Blocks.DIORITE) || state.is(Blocks.ANDESITE) || state.is(Blocks.TUFF) + || state.is(Blocks.COBBLESTONE) || state.is(Blocks.MOSSY_COBBLESTONE) || state.is(Blocks.PACKED_MUD); + } + + private static int localTop(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection, + int minX, int minZ, int x, int z, int[] heights) { + int index = (x - minX) * 16 + z - minZ; + if (heights[index] != Integer.MIN_VALUE) return heights[index]; + // In an untouched column the heightmap bounds the original surface cheaply. + // A protected water/lava column may have been carved: recover its raw top + // without depending on the current hydrology plan's representation. + int ceiling = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - 1; + if (protection.intersects(new BlockPos(x, level.getMinY(), z), 0, level.getHeight() - 1, 0)) + ceiling = level.getMaxY(); + return heights[index] = WoodlandGroveGeometry.highestSurfaceBelow(sampler, x, z, ceiling); + } + + private static boolean mushroomRoom(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection, + BlockPos base, int radius, int height) { + if (blocked(protection, base, radius + 1, height + 1, 4) + || !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), 1)) return false; + for (int depth = 1; depth <= 3; depth++) { + BlockPos floor = base.below(depth); + if (!level.getBlockState(floor).isCollisionShapeFullBlock(level, floor)) return false; + } + // A complete conservative column around the cap handles both brown umbrellas + // and the lower rounded red cap. Neither natural rock nor existing logs move. + for (int y = 0; y <= height + 1; y++) for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) { + BlockPos p = base.offset(dx, y, dz); + BlockState current = level.getBlockState(p); + if (sampler.sample(p.getX(), p.getY(), p.getZ()) > 0 || !current.getFluidState().isEmpty() + || !(current.isAir() || current.is(BlockTags.LEAVES))) return false; + } + // Being below another surface is not enough: the same column needs an actual + // original rock ceiling above the whole mushroom, not an open hillside. + for (int y = base.getY() + height + 2; y <= level.getMaxY(); y++) + if (sampler.sample(base.getX(), y, base.getZ()) > 0) return true; + return false; + } + + private static int rootDepth(Species species) { return species.key().equals(TreeFeatures.MANGROVE) ? 16 : 4; } + + private static boolean blocked(Protection protection, BlockPos base, int radius, int height) { + return blocked(protection, base, radius, height, 4); + } + + private static boolean blocked(Protection protection, BlockPos base, int radius, int height, int rootDepth) { + return protection.intersects(base, radius, height, rootDepth); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernHydrology.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernHydrology.java new file mode 100644 index 0000000..20dfd89 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernHydrology.java @@ -0,0 +1,966 @@ +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.9 river catchments, wider basins and rocky 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 CavernHydrology { + public static final int RADIUS = 192; + public static final int MAX_CARVE = 16; + public static final int MAX_DEPTH = 8; + public static final int MAX_LAKE_CARVE = 12; + public static final int MAX_RIVER_BANK_CARVE = 12; + 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 CavernHydrology() {} + + @FunctionalInterface + public interface Sampler { float sample(int x, int y, int z); } + + public enum Kind { POND, LAKE, RIVER, TERRACE } + 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 flowPath, FlowBounds flowBounds) { + public Spring { flowPath = List.copyOf(flowPath); } + } + public record Terrace(long id, List basinIds) { + public Terrace { basinIds = List.copyOf(basinIds); } + } + /** A retained upper basin supplies this outlet. toFeatureId=-1 denotes an open terminal fall. */ + public record Spill(long id, long fromFeatureId, long toFeatureId, Position source, Position outlet, + List flowPath, FlowBounds flowBounds) { + public Spill { 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 path, int waterCells, Bounds bounds) { + public Feature { path = List.copyOf(path); } + } + + public static final class Plan { + private final Map cells; + private final List allCells; + private final Map> layers; + private final Map> chunks; + private final List features; + private final List springs; + private final Map> springChunks; + private final Set springFlow; + private final int sampledColumns; + private final long densitySamples; + private final List terraces; + private final List spills; + private final Set spillOpenings; + + private Plan(Map source, List features, List springs, Set springFlow, int columns, long samples) { + this(source, features, springs, springFlow, columns, samples, CavernTerraces.Result.EMPTY); + } + + private Plan(Map source, List features, List springs, Set springFlow, + int columns, long samples, CavernTerraces.Result lower) { + List combined = new ArrayList<>(source.values()); + combined.addAll(lower.cells()); + List sorted = combined.stream() + .sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z).thenComparingInt(Cell::bedY)).toList(); + allCells = List.copyOf(sorted); + Map ordered = new LinkedHashMap<>(); + Map> stacked = new HashMap<>(); + Map> byChunk = new HashMap<>(); + for (Cell cell : sorted) { + ordered.put(key(cell.x(), cell.z()), cell); + stacked.computeIfAbsent(key(cell.x(), cell.z()), ignored -> new ArrayList<>()).add(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); + stacked.replaceAll((ignored, values) -> List.copyOf(values)); + layers = Map.copyOf(stacked); + chunks = Map.copyOf(byChunk); + List allFeatures = new ArrayList<>(features); + allFeatures.addAll(lower.features()); + this.features = List.copyOf(allFeatures); + this.springs = List.copyOf(springs); + Set allFlow = new HashSet<>(springFlow); + allFlow.addAll(lower.predictedFlow()); + this.springFlow = Set.copyOf(allFlow); + terraces = List.copyOf(lower.terraces()); + spills = List.copyOf(lower.spills()); + spillOpenings = spills.stream().map(Spill::outlet).collect(java.util.stream.Collectors.toUnmodifiableSet()); + Map> 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 List cellsAt(int x, int z) { return layers.getOrDefault(key(x, z), List.of()); } + /** Return the modified vertical interval, including its two unchanged support layers. */ + public Cell cellAt(int x, int y, int z) { + for (Cell cell : cellsAt(x, z)) if (y >= cell.bedY() - cell.sedimentDepth() - 1 + && y <= Math.max(cell.waterY(), cell.carveTop())) return cell; + return null; + } + public Collection cells() { return allCells; } + public List cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); } + public List features() { return features; } + public boolean isRiverCell(Cell cell) { return features.stream().anyMatch(f -> f.kind() == Kind.RIVER && f.id() == cell.featureId()); } + public boolean isTerraceCell(Cell cell) { return features.stream().anyMatch(f -> f.kind() == Kind.TERRACE && f.id() == cell.featureId()); } + public List terraces() { return terraces; } + public List spills() { return spills; } + public boolean isSpillOpening(int x, int y, int z) { return spillOpenings.contains(new Position(x, y, z)); } + public List springs() { return springs; } + public List 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_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 columns = new HashMap<>(); + private final Map coarse = new HashMap<>(); + private final Map cells = new HashMap<>(); + private final List features = new ArrayList<>(); + private final List springs = new ArrayList<>(); + private final Set springFlow = new HashSet<>(); + private long samples; + + Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; } + + Plan build() { + List candidates = candidates(); + addRiver(); + int attempts = 0; + for (Candidate candidate : candidates) { + if (features.size() >= 4 || 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 ? 700 + (int) (random(seed, center.x(), center.z()) * 400) + : 100 + (int) (random(seed + 1, center.x(), center.z()) * 140); + // 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, column.top() - 2}) { + Set footprint = growBasin(center, level, target); + if (footprint == null) continue; + long id = featureId(features.size()); + Map wet = kind == Kind.LAKE ? lakeCells(footprint, level, id) : waterCells(footprint, level, id); + if (wet == null || !contained(wet)) continue; + addFeature(id, kind, level, List.of(center), wet); + break; + } + } + addRiverSpring(); + addSedimentSheets(); + addLowerWallSprings(); + addSprings(); + Plan surface = new Plan(cells, features, springs, springFlow, columns.size(), samples); + var lower = CavernTerraces.create(seed, sampler, surface); + return new Plan(cells, features, springs, springFlow, columns.size(), samples, lower); + } + + /** A connected catchment is excavated only in a thick natural plateau. The route is + * selected on the actual fissured density, not painted across missing rock. Its two + * terminal pools share the same retained waterline; a separate rock spring feeds it. */ + private void addRiver() { + List heights = coarse.values().stream().filter(y -> y >= 160).sorted().toList(); + if (heights.isEmpty()) return; + Set levels = new java.util.LinkedHashSet<>(); + for (double quantile : new double[]{.55, .35, .75, .15, .90}) { + int top = heights.get(Math.min(heights.size() - 1, (int) (heights.size() * quantile))); + for (int cut : new int[]{4, 8, 0}) levels.add(top - cut); + } + for (int level : levels) { + Set allowed = new HashSet<>(); + for (int x = -168; x <= 168; x += 4) for (int z = -168; z <= 168; z += 4) { + if (!inside(x, z, 170)) continue; + boolean safe = riverRock(x, z, level); + for (int dx : new int[]{-3, 3}) for (int dz : new int[]{-3, 3}) { + if (safe && !riverRock(x + dx, z + dz, level)) safe = false; + } + if (safe) allowed.add(key(x, z)); + } + for (int attempt = 0; attempt < 4 && allowed.size() > 100; attempt++) { + List route = longestRoute(allowed, level); + if (route.size() < 36) break; + Point first = route.getFirst(), last = route.getLast(); + if (Math.hypot(last.x() - first.x(), last.z() - first.z()) < 100) break; + List path = smoothRoute(route); + Set wet = new HashSet<>(); + Set bad = new HashSet<>(); + for (Point at : path) { + int radius = field(at.x(), at.z(), 23, 8119) > .52 ? 3 : 2; + for (int dx = -radius - 1; dx <= radius + 1; dx++) for (int dz = -radius - 1; dz <= radius + 1; dz++) { + if (!riverRock(at.x() + dx, at.z() + dz, level)) { + for (int gx = -1; gx <= 1; gx++) for (int gz = -1; gz <= 1; gz++) + bad.add(key(Math.floorDiv(at.x(), 4) * 4 + gx * 4, Math.floorDiv(at.z(), 4) * 4 + gz * 4)); + } + if (dx * dx + dz * dz <= radius * radius + 1) wet.add(key(at.x() + dx, at.z() + dz)); + } + } + if (!bad.isEmpty()) { allowed.removeAll(bad); continue; } + // Both end pools are required and remain connected to the channel. + boolean pools = true; + for (Point endpoint : List.of(first, last)) { + Set pool = growRiverPool(endpoint, level); + if (pool.size() < 90) { pools = false; break; } + wet.addAll(pool); + } + if (!pools) { allowed.remove(key(first.x(), first.z())); allowed.remove(key(last.x(), last.z())); continue; } + long id = featureId(features.size()); + Map river = new HashMap<>(); + boolean safe = true; + for (long k : wet) { + Point at = point(k); + Column rock = column(at.x(), at.z()); + int depth = 2; + boolean edge = false; + for (int[] d : CARDINALS) edge |= !wet.contains(key(at.x() + d[0], at.z() + d[1])); + if (edge) depth = 1; + int bed = level - depth; + if (rock.top() < bed || rock.top() - bed > MAX_CARVE || rock.solidBottom() > bed - 6) { safe = false; break; } + river.put(k, new Cell(at.x(), at.z(), level, bed, Math.max(level, rock.top()), + wetMaterial(at.x(), at.z(), edge), id, 3)); + } + if (!safe || !contained(river)) break; + addFeature(id, Kind.RIVER, level, path, river); + return; + } + } + } + + private Set growRiverPool(Point center, int level) { + Set result = new HashSet<>(); + PriorityQueue queue = new PriorityQueue<>(NODE_ORDER); + Set seen = new HashSet<>(); + queue.add(new Node(center.x(), center.z(), 0)); + while (!queue.isEmpty() && result.size() < 240) { + Node at = queue.remove(); + if (!seen.add(key(at.x(), at.z())) || Math.hypot(at.x() - center.x(), at.z() - center.z()) > 12 + || !riverRock(at.x(), at.z(), level)) continue; + boolean retained = true; + for (int[] d : CARDINALS) retained &= solid(at.x() + d[0], level, at.z() + d[1]); + if (!retained) continue; + result.add(key(at.x(), at.z())); + for (int[] d : CARDINALS) { + int x = at.x() + d[0], z = at.z() + d[1]; + queue.add(new Node(x, z, Math.hypot(x - center.x(), z - center.z()) + + field(x, z, 8, 411) * 6 + Math.max(0, column(x,z).top() - level) * .4)); + } + } + return result; + } + + private boolean riverRock(int x, int z, int level) { + Column rock = column(x, z); + return rock.top() >= level && rock.top() <= level + 12 && rock.solidBottom() <= level - 8; + } + + private List longestRoute(Set allowed, int level) { + Set remaining = new HashSet<>(allowed); + List longest = List.of(); + while (!remaining.isEmpty()) { + long start = remaining.stream().min(Long::compare).orElseThrow(); + Map first = routeTree(start, allowed); + remaining.removeAll(first.keySet()); + if (first.size() < 36) continue; + long far = lastKey(first); + Map second = routeTree(far, first.keySet()); + long end = lastKey(second); + Map shaped = riverRouteTree(far, end, first.keySet(), level); + List path = new ArrayList<>(); + for (long at = end; ; at = shaped.get(at)) { + path.add(point(at)); + if (at == far) break; + } + if (path.size() > longest.size()) longest = path; + } + return longest; + } + + private Map riverRouteTree(long start, long end, Set allowed, int level) { + Map parent = new HashMap<>(); + Map costs = new HashMap<>(); + PriorityQueue queue = new PriorityQueue<>(NODE_ORDER); + Point origin = point(start); + parent.put(start, start); costs.put(start, 0.0); queue.add(new Node(origin.x(), origin.z(), 0)); + while (!queue.isEmpty()) { + Node at = queue.remove(); + long current = key(at.x(), at.z()); + if (at.cost() > costs.getOrDefault(current, Double.POSITIVE_INFINITY)) continue; + if (current == end) break; + for (int[] d : NEIGHBORS) { + int x = at.x() + d[0] * 4, z = at.z() + d[1] * 4; + long next = key(x, z); + if (!allowed.contains(next)) continue; + double cost = at.cost() + Math.hypot(d[0], d[1]) * (1 + Math.max(0, column(x,z).top() - level) * .14 + + field(x, z, 27, 8113) * 1.8); + if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue; + parent.put(next, current); costs.put(next, cost); queue.add(new Node(x,z,cost)); + } + } + return parent; + } + + private List smoothRoute(List coarsePath) { + record Smooth(double x, double z) {} + List values = coarsePath.stream().map(p -> new Smooth(p.x(), p.z())).toList(); + // Bend over several grid steps rather than rounding each voxel-sized corner. + for (int pass = 0; pass < 2; pass++) { + List next = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + if (i == 0 || i == values.size() - 1) { next.add(values.get(i)); continue; } + double x = 0, z = 0, sum = 0; + for (int offset = -4; offset <= 4; offset++) { + int weight = 5 - Math.abs(offset); + Smooth p = values.get(Math.clamp(i + offset, 0, values.size() - 1)); + x += p.x() * weight; z += p.z() * weight; sum += weight; + } + next.add(new Smooth(x / sum, z / sum)); + } + values = next; + } + for (int pass = 0; pass < 2; pass++) { + List next = new ArrayList<>(); next.add(values.getFirst()); + for (int i = 1; i < values.size(); i++) { + Smooth a = values.get(i - 1), b = values.get(i); + next.add(new Smooth(a.x() * .75 + b.x() * .25, a.z() * .75 + b.z() * .25)); + next.add(new Smooth(a.x() * .25 + b.x() * .75, a.z() * .25 + b.z() * .75)); + } + next.add(values.getLast()); values = next; + } + List path = new ArrayList<>(); + for (int i = 1; i < values.size(); i++) { + Smooth a = values.get(i - 1), b = values.get(i); + int steps = Math.max(1, (int) Math.ceil(Math.max(Math.abs(b.x() - a.x()), Math.abs(b.z() - a.z())))); + for (int step = 0; step <= steps; step++) { + double t = step / (double) steps; + Point p = new Point((int) Math.round(a.x() + (b.x() - a.x()) * t), (int) Math.round(a.z() + (b.z() - a.z()) * t)); + if (path.isEmpty() || !path.getLast().equals(p)) path.add(p); + } + } + return path; + } + + private Map routeTree(long start, Set allowed) { + Map parent = new LinkedHashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + parent.put(start, start); queue.add(start); + while (!queue.isEmpty()) { + long current = queue.removeFirst(); + Point at = point(current); + for (int[] d : NEIGHBORS) { + long next = key(at.x() + d[0] * 4, at.z() + d[1] * 4); + if (allowed.contains(next) && !parent.containsKey(next)) { parent.put(next, current); queue.addLast(next); } + } + } + return parent; + } + + private long lastKey(Map values) { + long result = 0; + for (long key : values.keySet()) result = key; + return result; + } + + /** A real spring emerges from one of the river's taller rock banks and drops into the + * connected retained channel. The source replaces rock; its mouth is the carved valley. */ + private void addRiverSpring() { + Feature river = features.stream().filter(f -> f.kind() == Kind.RIVER).findFirst().orElse(null); + if (river == null) return; + List bank = cells.values().stream().filter(c -> c.featureId() == river.id()) + .sorted(Comparator.comparingInt(Cell::carveTop).reversed().thenComparingInt(Cell::x).thenComparingInt(Cell::z)).toList(); + for (Cell wet : bank) for (int[] d : CARDINALS) { + int x = wet.x() + d[0], z = wet.z() + d[1]; + if (cells.containsKey(key(x, z))) continue; + for (int height : new int[]{8, 7, 6, 5}) { + int y = wet.waterY() + height; + if (wet.carveTop() < y) continue; + Position source = new Position(x, y, z), outlet = new Position(wet.x(), y, wet.z()); + boolean safe = true; + for (int sy = y - 3; sy <= y + 1; sy++) safe &= flowSolid(new Position(x, sy, z), outlet); + for (int[] side : CARDINALS) { + if (side[0] == -d[0] && side[1] == -d[1]) continue; + safe &= flowSolid(new Position(x + side[0], y, z + side[1]), outlet); + } + if (!safe) continue; + Flow flow = traceSpring(source, outlet); + if (flow == null || flow.volume().stream().noneMatch(p -> { + Cell c = cells.get(key(p.x(), p.z())); + return c != null && c.featureId() == river.id() && p.y() <= c.waterY() && p.y() > c.bedY(); + })) continue; + long id = mix(seed ^ 0xA81BE2L); + springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds())); + springFlow.addAll(flow.volume()); + return; + } + } + } + + private List 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 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 - 40) && 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 + 6 + && column.solidBottom() <= Math.min(column.top(), level - 1) - 4; + } + + private Set growBasin(Point center, int level, int target) { + if (!eligible(center.x(), center.z(), level)) return null; + Set wet = new HashSet<>(); + Map costs = new HashMap<>(); + PriorityQueue 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()) > 40 || 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 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() >= 1200 || Math.hypot(x - center.x(), z - center.z()) > 48 + || !eligible(x, z, level)) return null; + wet.add(neighbor); + work.add(neighbor); + } + } + return wet; + } + + private Map waterCells(Set footprint, int level, long id) { + Map 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, 8 - 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; + } + + /** Deepen only sufficiently thick interiors, with one-block shore steps. Natural weak + * columns keep a shallower bed instead of receiving a manufactured lake foundation. */ + private Map lakeCells(Set footprint, int level, long id) { + Map distance = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + for (long k : footprint) { + Point at = point(k); + for (int[] d : CARDINALS) if (!footprint.contains(key(at.x() + d[0], at.z() + d[1]))) { + distance.put(k, 0); queue.addLast(k); break; + } + } + while (!queue.isEmpty()) { + long k = queue.removeFirst(); Point at = point(k); + for (int[] d : CARDINALS) { + long next = key(at.x() + d[0], at.z() + d[1]); + if (footprint.contains(next) && !distance.containsKey(next)) { + distance.put(next, distance.get(k) + 1); queue.addLast(next); + } + } + } + Map result = new HashMap<>(); + for (long k : footprint) { + Point at = point(k); + if (!eligible(at.x(), at.z(), level)) return null; + Column rock = column(at.x(), at.z()); + int sediment = sedimentDepth(at.x(), at.z()); + int wanted = Math.max(level - rock.top() + 1, Math.min(MAX_DEPTH, 1 + distance.get(k))); + int depth = Math.min(wanted, Math.min(level - rock.solidBottom() - sediment - 1, + MAX_LAKE_CARVE - Math.max(0, rock.top() - level))); + if (depth < 1 || rock.top() < level - depth) return null; + int bed = level - depth; + result.put(k, new Cell(at.x(), at.z(), level, bed, Math.max(level, rock.top()), + wetMaterial(at.x(), at.z(), distance.get(k) == 0), id, sediment)); + } + return result; + } + + private boolean contained(Map 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 path, Map 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 connectedTo(Set footprint, long start) { + Set connected = new HashSet<>(); + List 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 order = Comparator.comparingDouble(ShoreNode::cost) + .thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature); + Set rivers = features.stream().filter(f -> f.kind() == Kind.RIVER).map(Feature::id) + .collect(java.util.stream.Collectors.toSet()); + PriorityQueue queue = new PriorityQueue<>(order); + Map 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; + boolean riverBank = rivers.contains(node.feature()); + Column column = column(node.x(), node.z()); + Cell existing = cells.get(key(node.x(), node.z())); + if (existing == null) { + boolean springRock = springs.stream().anyMatch(spring -> + Math.abs(spring.source().x() - node.x()) <= 4 && Math.abs(spring.source().z() - node.z()) <= 4); + if (springRock) continue; + double extent = riverBank ? 18 + field(node.x(), node.z(), 25, 131) * 6 + : 9 + field(node.x(), node.z(), 25, 131) * 10; + if (node.cost() > extent) continue; + int delta = column.top() - node.level(); + if (delta < 0 || delta > (riverBank ? 14 : 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() / (riverBank ? 2.5 : 4.5)); + int cut = riverBank ? MAX_RIVER_BANK_CARVE : 2; + int bed = Math.max(node.level(), Math.max(column.top() - cut, 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() + (riverBank ? 14 : 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 > (riverBank ? 24 : 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 volume, List 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 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 (!separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz()) || nearModifiedGround(source, 32)) 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()); + long dx = source.x() - cell.x(), dz = source.z() - cell.z(); + if (dx * dx + dz * dz + (long) dy * dy < (long) distance * 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 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 (!separatedSpring(source) || !springNiche(source, candidate.dx(), candidate.dz()) + || nearModifiedGround(source, 32)) 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 reach = new HashMap<>(); + Map parent = new HashMap<>(); + ArrayDeque 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 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 queue, + Map reach, Map 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); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernHydrologyRuntime.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernHydrologyRuntime.java new file mode 100644 index 0000000..14583c1 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernHydrologyRuntime.java @@ -0,0 +1,191 @@ +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.9 only: immutable plans cached by world state, applied to fresh terrain before decoration. */ +public final class CavernHydrologyRuntime { + private static final Map PLANS = + Collections.synchronizedMap(new WeakHashMap<>()); + private static final Map LAVA_PLANS = + Collections.synchronizedMap(new WeakHashMap<>()); + + private CavernHydrologyRuntime() {} + + public static boolean enabled(NoiseBasedChunkGenerator generator) { + return generator.generatorSettings().is(SanctuarySpawn.CAVERN_SETTINGS); + } + + public static CavernHydrology.Plan cavernPlan(ServerLevel level) { + return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), + level.getChunkSource().randomState()); + } + + public static CavernHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) { + if (!enabled(generator)) throw new IllegalArgumentException("Cavern 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 = CavernHydrology.create(state.seed(), + (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z)); + SanctuaryMod.LOGGER.info("Sanctuary cavern hydrology: seed {}, {} features, {} columns, {} springs, {} ms", + state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(), + (System.nanoTime() - start) / 1_000_000); + SanctuaryMod.LOGGER.info("Sanctuary cavern catchments: {}", plan.features().stream().map(f -> f.kind() + " Y" + f.waterY() + " water=" + f.waterCells() + " path=" + f.path().size() + " " + f.bounds()).toList()); + return plan; + }); + } + + public static CavernLavaDeposit.Plan lavaPlan(ServerLevel level) { + return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), + level.getChunkSource().randomState()); + } + + public static CavernLavaDeposit.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 = CavernLavaDeposit.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 = new java.util.ArrayList<>(plan.springs().stream().map(CavernHydrology.Spring::outlet) + .filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList()); + for (var spill : plan.spills()) { + var outlet = spill.outlet(); + if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()) outlets.add(outlet); + } + var lava = lavaPlan(generator, randomState); + for (var fall : lava.falls()) { + var outlet = fall.outlet(); + if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()) + outlets.add(new CavernHydrology.Position(outlet.x(), outlet.y(), outlet.z())); + } + 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 = CavernMaterials.sedimentBlock(cell, randomState.seed(), depth); + 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)); + } + + /** Reserve water, sediment and hot-rock volumes before placing vegetation. */ + public static boolean protects(NoiseBasedChunkGenerator generator, RandomState randomState, BlockPos base, + int radius, int height, int rootDepth) { + var water = plan(generator, randomState); + var lava = lavaPlan(generator, randomState); + int low = base.getY() - rootDepth, high = base.getY() + height; + for (int x = base.getX() - radius; x <= base.getX() + radius; x++) { + for (int z = base.getZ() - radius; z <= base.getZ() + radius; z++) { + for (var cell : water.cellsAt(x, z)) + if (low <= cell.carveTop() && high >= cell.bedY() - cell.sedimentDepth() - 1) return true; + } + } + for (var spring : water.springs()) { + var bounds = spring.flowBounds(); + if (base.getX() + radius >= bounds.minX() - 2 && base.getX() - radius <= bounds.maxX() + 2 + && base.getZ() + radius >= bounds.minZ() - 2 && base.getZ() - radius <= bounds.maxZ() + 2 + && low <= bounds.maxY() + 2 && high >= bounds.minY() - 2) return true; + var source = spring.source(); + if (Math.abs(base.getX() - source.x()) <= radius + 3 && Math.abs(base.getZ() - source.z()) <= radius + 3 + && low <= source.y() + 3 && high >= source.y() - 3) return true; + } + for (var bounds : lava.flowBounds()) { + if (base.getX() + radius >= bounds.minX() - 8 && base.getX() - radius <= bounds.maxX() + 8 + && base.getZ() + radius >= bounds.minZ() - 8 && base.getZ() - radius <= bounds.maxZ() + 8 + && low <= bounds.maxY() + 8 && high >= bounds.minY() - 8) return true; + } + for (var spill : water.spills()) { + var bounds = spill.flowBounds(); + if (base.getX() + radius >= bounds.minX() - 3 && base.getX() - radius <= bounds.maxX() + 3 + && base.getZ() + radius >= bounds.minZ() - 3 && base.getZ() - radius <= bounds.maxZ() + 3 + && low <= bounds.maxY() + 3 && high >= bounds.minY() - 3) return true; + } + for (var cell : lava.cells()) if (Math.abs(base.getX() - cell.x()) <= radius + 2 && Math.abs(base.getZ() - cell.z()) <= radius + 2 + && low <= cell.y() + 2 && high >= cell.y() - 2) return true; + for (var support : lava.supports()) if (Math.abs(base.getX() - support.x()) <= radius + 2 && Math.abs(base.getZ() - support.z()) <= radius + 2 + && low <= support.y() + 2 && high >= support.y() - 2) return true; + return false; + } + + private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) { + if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) { + throw new IllegalStateException("Cavern hydrology differs from terrain at " + pos + " for seed " + seed); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernIslandBiomeSource.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernIslandBiomeSource.java new file mode 100644 index 0000000..63581c2 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernIslandBiomeSource.java @@ -0,0 +1,99 @@ +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; + +/** Seeded alpha.9 rift climate, separate from all saved earlier biome-source codecs. */ +public final class CavernIslandBiomeSource extends BiomeSource { + public static final ResourceKey OAK_FOREST = key("cavern_oak_forest"); + public static final ResourceKey BIRCH_FOREST = key("cavern_birch_forest"); + public static final ResourceKey CLEARING = key("cavern_clearing"); + public static final ResourceKey DRY_WOODLAND = key("cavern_dry_woodland"); + public static final ResourceKey ROCKY_HEATH = key("cavern_rocky_heath"); + public static final ResourceKey DARK_GROVE = key("cavern_dark_grove"); + public static final ResourceKey BAMBOO_GROVE = key("cavern_bamboo_grove"); + public static final ResourceKey SULFUR_DEPTHS = key("cavern_sulfur_depths"); + public static final ResourceKey LUSH_CAVES = key("cavern_lush_caves"); + public static final ResourceKey DRIPSTONE_CAVES = key("cavern_dripstone_caves"); + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + Biome.CODEC.fieldOf("oak_forest").forGetter(source -> source.oakForest), + Biome.CODEC.fieldOf("birch_forest").forGetter(source -> source.birchForest), + Biome.CODEC.fieldOf("clearing").forGetter(source -> source.clearing), + Biome.CODEC.fieldOf("dry_woodland").forGetter(source -> source.dryWoodland), + Biome.CODEC.fieldOf("rocky_heath").forGetter(source -> source.rockyHeath), + Biome.CODEC.fieldOf("dark_grove").forGetter(source -> source.darkGrove), + Biome.CODEC.fieldOf("bamboo_grove").forGetter(source -> source.bambooGrove), + Biome.CODEC.fieldOf("sulfur_depths").forGetter(source -> source.sulfurDepths), + Biome.CODEC.fieldOf("lush_caves").forGetter(source -> source.lushCaves), + Biome.CODEC.fieldOf("dripstone_caves").forGetter(source -> source.dripstoneCaves) + ).apply(instance, CavernIslandBiomeSource::new)); + + private final Holder oakForest; + private final Holder birchForest; + private final Holder clearing; + private final Holder dryWoodland; + private final Holder rockyHeath; + private final Holder darkGrove; + private final Holder bambooGrove; + private final Holder sulfurDepths; + private final Holder lushCaves; + private final Holder dripstoneCaves; + + public CavernIslandBiomeSource(Holder oakForest, Holder birchForest, Holder clearing, + Holder dryWoodland, Holder rockyHeath, Holder darkGrove, + Holder bambooGrove, Holder sulfurDepths, Holder lushCaves, Holder dripstoneCaves) { + this.oakForest = oakForest; + this.birchForest = birchForest; + this.clearing = clearing; + this.dryWoodland = dryWoodland; + this.rockyHeath = rockyHeath; + this.darkGrove = darkGrove; + this.bambooGrove = bambooGrove; + this.sulfurDepths = sulfurDepths; + this.lushCaves = lushCaves; + this.dripstoneCaves = dripstoneCaves; + } + + @Override + protected MapCodec codec() { return CODEC; } + + @Override + protected Stream> collectPossibleBiomes() { + return Stream.of(oakForest, birchForest, clearing, dryWoodland, rockyHeath, darkGrove, bambooGrove, sulfurDepths, lushCaves, dripstoneCaves); + } + + @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 (CavernBiomePalette.select(y, variation, moisture)) { + case OAK_FOREST -> oakForest; + case BIRCH_FOREST -> birchForest; + case CLEARING -> clearing; + case DRY_WOODLAND -> dryWoodland; + case ROCKY_HEATH -> rockyHeath; + case DARK_GROVE -> darkGrove; + case BAMBOO_GROVE -> bambooGrove; + case SULFUR_DEPTHS -> sulfurDepths; + case LUSH_CAVES -> lushCaves; + case DRIPSTONE_CAVES -> dripstoneCaves; + }; + }; + } + + private static ResourceKey key(String path) { + return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path)); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernLavaDeposit.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernLavaDeposit.java new file mode 100644 index 0000000..562bbdc --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernLavaDeposit.java @@ -0,0 +1,331 @@ +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; + +/** Alpha.9 lava: an optional covered pocket and up to two deep rock outlets. + * All sources replace natural rock; Minecraft alone produces the falling lava. */ +public final class CavernLavaDeposit { + 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_ORDER = Comparator.comparingInt(Position::x) + .thenComparingInt(Position::z).thenComparingInt(Position::y); + private static final Comparator CELL_ORDER = Comparator.comparingInt(Cell::x) + .thenComparingInt(Cell::z).thenComparingInt(Cell::y); + + private CavernLavaDeposit() {} + + 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 cells; + private final List lavaCells; + private final List supports; + private final Optional access; + private final Map> chunks; + private final Map> supportChunks; + private final List falls; + private final Map> fallChunks; + + private Plan(List cells, Set supports, Position access) { + this(cells, supports, access, List.of()); + } + + private Plan(List cells, Set supports, Position access, List falls) { + this.falls = List.copyOf(falls); + Map> groupedFalls = new HashMap<>(); + for (var fall : falls) groupedFalls.computeIfAbsent( + key(fall.source().x() >> 4, fall.source().z() >> 4), ignored -> new ArrayList<>()).add(fall); + groupedFalls.replaceAll((ignored, values) -> List.copyOf(values)); + fallChunks = Map.copyOf(groupedFalls); + 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> 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> 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 cells() { return cells; } + public List lavaCells() { return lavaCells; } + public List supports() { return supports; } + public List falls() { return falls; } + public List fallsInChunk(int x, int z) { return fallChunks.getOrDefault(key(x, z), List.of()); } + public List flowSources() { return falls.stream().map(CavernLavaFlows.Fall::source).toList(); } + public List flowBounds() { return falls.stream().map(CavernLavaFlows.Fall::flowBounds).toList(); } + /** Player feet on a natural ledge, with three blocks of headroom below a roof or sky. */ + public Optional access() { return access; } + public List cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); } + public List supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); } + } + + public static Plan create(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan hydrology) { + Plan pocket = new Search(seed, sampler, hydrology).find(); + List falls = CavernLavaFlows.create(seed, sampler, hydrology, pocket); + List cells = new ArrayList<>(pocket.cells()); + Set supports = new HashSet<>(pocket.supports()); + for (var fall : falls) { + Position source = fall.source(); + cells.add(new Cell(source.x(), source.y(), source.z(), true)); + supports.addAll(fall.supports()); + } + return new Plan(cells, supports, pocket.access().orElse(null), falls); + } + + private record Candidate(int x, int z, int dx, int dz, double score) {} + private record LowerCandidate(int x, int y, int z, int dx, int dz, double score) {} + + private static final class Search { + private final long seed; + private final CavernHydrology.Sampler sampler; + private final CavernHydrology.Plan hydrology; + private final Map heights = new HashMap<>(); + + Search(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan hydrology) { + this.seed = seed; + this.sampler = sampler; + this.hydrology = hydrology; + } + + Plan find() { + Plan lower = findLowerPocket(); + if (lower != null) return lower; + List 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); + } + + /** Prefer a real lower ledge below an overhang. The chamber uses exactly the same rock + * shell as the outdoor fallback; only the entrance may now have a natural ceiling. */ + private Plan findLowerPocket() { + List candidates = new ArrayList<>(); + for (int x = -176; x <= 176; x += 16) for (int z = -176; z <= 176; z += 16) { + if (!inside(x, z, RADIUS - 8)) continue; + for (int coarseY = 64; coarseY <= 160; coarseY += 8) { + if (!solid(x, coarseY, z) || solid(x, coarseY + 8, z)) continue; + int level = coarseY; + for (int y = coarseY + 7; y > coarseY; y--) { + if (solid(x, y, z)) { level = y; break; } + } + if (level > 160 || top(x, z) < level + 12) continue; + for (int[] direction : DIRECTIONS) { + int inwardX = x - direction[0] * 16, inwardZ = z - direction[1] * 16; + if (!inside(inwardX, inwardZ, RADIUS - 12) + || !solid(inwardX, level + 2, inwardZ) || !solid(inwardX, level + 10, inwardZ)) continue; + candidates.add(new LowerCandidate(x, level, z, direction[0], direction[1], + random(seed + level + 0x108E2L, x - direction[0], z - direction[1]) + + (160 - level) * .002)); + } + } + } + candidates.sort(Comparator.comparingDouble(LowerCandidate::score).reversed() + .thenComparingInt(LowerCandidate::x).thenComparingInt(LowerCandidate::z) + .thenComparingInt(LowerCandidate::y).thenComparingInt(LowerCandidate::dx).thenComparingInt(LowerCandidate::dz)); + int attempts = 0; + for (LowerCandidate candidate : candidates) { + if (attempts++ >= 128) break; + for (int along = 0; along < 16; 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; + for (int level = Math.min(160, candidate.y() + 5); level >= Math.max(64, candidate.y() - 5); level--) { + // Reject solid cave walls and missing floors before expensive shell probes. + if (!solid(x, level, z) || solid(x, level + 1, z)) continue; + Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz(), level, true); + if (plan != null) return plan; + } + } + } + return null; + } + + private Plan tryNiche(int entryX, int entryZ, int dx, int dz) { + return tryNiche(entryX, entryZ, dx, dz, top(entryX, entryZ), false); + } + + private Plan tryNiche(int entryX, int entryZ, int dx, int dz, int level, boolean underground) { + int cx = entryX - dx * ENTRY_DISTANCE, cz = entryZ - dz * ENTRY_DISTANCE; + if (!inside(cx, cz, RADIUS - 12) || level < 64 || level > (underground ? 160 : 300) + || top(cx, cz) < level + 12) return null; + // A lower entrance has genuine overhead island rock, but does not need a sky view. + if (underground && top(entryX, entryZ) < level + 12) return null; + int airLimit = underground ? level + 3 : 383; + for (int y = level + 1; y <= airLimit; y++) if (solid(entryX, y, entryZ)) return null; + for (int y = level - 2; y <= level; y++) if (!solid(entryX, y, entryZ)) return null; + if (underground ? !clearOfWater(cx, level, cz) : !clearOfWater(cx, cz)) return null; + + List cells = new ArrayList<>(); + Set changed = new HashSet<>(); + Set 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 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 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; + } + for (var spill : hydrology.spills()) { + var bounds = spill.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 boolean clearOfWater(int x, int level, int z) { + // This box covers chamber, dry approach, intact shell and roof. A lake far above is + // allowed; an actual waterfall crossing this level still excludes the entire pocket. + var pocket = new CavernHydrology.FlowBounds(x - 10, level - 4, z - 10, x + 10, level + 10, z + 10); + for (var cell : hydrology.cells()) { + var ground = new CavernHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1, + cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z()); + if (distanceSquared(pocket, ground) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spring : hydrology.springs()) { + if (distanceSquared(pocket, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spill : hydrology.spills()) { + if (distanceSquared(pocket, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + return true; + } + + private static long distanceSquared(CavernHydrology.FlowBounds a, CavernHydrology.FlowBounds b) { + long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX())); + long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY())); + long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ())); + return dx * dx + dy * dy + dz * dz; + } + + 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 cells, Set 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; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernLavaFlows.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernLavaFlows.java new file mode 100644 index 0000000..3650153 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernLavaFlows.java @@ -0,0 +1,241 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.worldgen.CavernLavaDeposit.Position; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Selects a few volcanic seeps in deep exposed rock. Sources replace rock; their outlets and + * entire falling columns are left empty for vanilla lava ticks. No retaining wall is generated. */ +public final class CavernLavaFlows { + public static final int MIN_SOURCE_Y = 40; + public static final int MAX_SOURCE_Y = 160; + public static final int MAX_FALLS = 2; + public static final int HORIZONTAL_REACH = 3; + public static final int FIRE_CLEARANCE = 8; + public static final int WATER_CLEARANCE = 24; + private static final int RADIUS = 224; + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final Comparator POSITION_ORDER = Comparator.comparingInt(Position::x) + .thenComparingInt(Position::z).thenComparingInt(Position::y); + + private CavernLavaFlows() {} + + /** flowBounds predicts undecorated rock drainage; it does not override Minecraft physics. + * supports contains only unchanged natural rock around the source, never the source itself. */ + public record Fall(long id, Position source, Position outlet, List flowPath, + CavernHydrology.FlowBounds flowBounds, List supports) { + public Fall { flowPath = List.copyOf(flowPath); supports = List.copyOf(supports); } + } + + public static List create(long seed, CavernHydrology.Sampler sampler, + CavernHydrology.Plan water, CavernLavaDeposit.Plan pocket) { + return new Search(seed, sampler, water, pocket).find(); + } + + private record Candidate(int x, int y, int z, int dx, int dz, double score) {} + private record Node(Position at, int reach) {} + private record Trace(List path, CavernHydrology.FlowBounds bounds) {} + + private static final class Search { + private final long seed; + private final CavernHydrology.Sampler sampler; + private final CavernHydrology.Plan water; + private final CavernLavaDeposit.Plan pocket; + private final List falls = new ArrayList<>(); + + Search(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan water, CavernLavaDeposit.Plan pocket) { + this.seed = seed; this.sampler = sampler; this.water = water; this.pocket = pocket; + } + + List find() { + List candidates = new ArrayList<>(); + // Coarse probes below the surface can see a lower overhang that a heightmap hides. + for (int x = -216; x <= 216; x += 8) for (int z = -216; z <= 216; z += 8) { + if ((long) x * x + (long) z * z >= (long) RADIUS * RADIUS) continue; + for (int y = MIN_SOURCE_Y; y <= MAX_SOURCE_Y; y += 8) { + if (!solid(x, y, z)) continue; + for (int[] d : CARDINALS) { + if (solid(x + d[0] * 4, y, z + d[1] * 4)) continue; + double score = random(seed + y, x + d[0], z + d[1]) + + (MAX_SOURCE_Y - y) * .002; + candidates.add(new Candidate(x, y, z, d[0], d[1], score)); + } + } + } + candidates.sort(Comparator.comparingDouble(Candidate::score).reversed() + .thenComparingInt(Candidate::x).thenComparingInt(Candidate::z) + .thenComparingInt(Candidate::y).thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz)); + int attempts = 0; + for (Candidate candidate : candidates) { + if (falls.size() >= MAX_FALLS || attempts++ >= 160) 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, 2, -2, 4, -4}) { + Position source = new Position(x, candidate.y() + dy, z); + if (source.y() < MIN_SOURCE_Y || source.y() > MAX_SOURCE_Y || !separated(source)) continue; + List supports = niche(source, candidate.dx(), candidate.dz()); + if (supports == null) continue; + Position outlet = new Position(x + candidate.dx(), source.y(), z + candidate.dz()); + Trace flow = trace(source, outlet); + if (flow == null || flow.bounds().minY() > source.y() - 12 || !clearOfWater(flow.bounds())) continue; + long id = mix(seed ^ (0x1A7AFA11L + falls.size() * 0x9E3779B97F4A7C15L)); + falls.add(new Fall(id, source, outlet, flow.path(), flow.bounds(), supports)); + placed = true; + break; + } + if (placed) break; + } + if (placed) break; + } + } + return List.copyOf(falls); + } + + private boolean separated(Position source) { + CavernHydrology.FlowBounds point = bounds(source); + if (!clearOfWater(point)) return false; + for (Fall fall : falls) { + if (distanceSquared(source, fall.source()) < 80L * 80 + || distanceSquared(point, fall.flowBounds()) < 40L * 40) return false; + } + for (var cell : pocket.cells()) { + if (distanceSquared(source, new Position(cell.x(), cell.y(), cell.z())) < 32L * 32) return false; + } + return true; + } + + private List niche(Position source, int dx, int dz) { + if (!solid(source)) return null; + Set supports = new HashSet<>(); + // The source sits in rock, with a short intact roof, floor and three back/side faces. + // The spill column needs no floor: its absence is precisely what makes a cascade. + for (int dy = -3; dy <= 8; dy++) { + if (dy == 0) continue; + Position at = new Position(source.x(), source.y() + dy, source.z()); + if (!solid(at)) return null; + supports.add(at); + } + for (int[] d : CARDINALS) { + if (d[0] == dx && d[1] == dz) continue; + for (int depth = 1; depth <= 2; depth++) { + Position at = new Position(source.x() + d[0] * depth, source.y(), source.z() + d[1] * depth); + if (!solid(at)) return null; + supports.add(at); + } + } + for (int dy = 0; dy >= -8; dy--) { + if (solid(source.x() + dx, source.y() + dy, source.z() + dz)) return null; + } + return supports.stream().sorted(POSITION_ORDER).toList(); + } + + private boolean clearOfWater(CavernHydrology.FlowBounds bounds) { + for (var cell : water.cells()) { + var column = new CavernHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1, + cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z()); + if (distanceSquared(bounds, column) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spring : water.springs()) { + if (distanceSquared(bounds, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spill : water.spills()) { + if (distanceSquared(bounds, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + return true; + } + + /** Descending lava resets its horizontal reach to three in this non-ultrawarm dimension. + * Explore all downhill branches through rock, so selection does not depend on chunk order. */ + private Trace trace(Position source, Position outlet) { + Map reach = new HashMap<>(); + Map parent = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + reach.put(source, HORIZONTAL_REACH); + queue.addLast(new Node(source, HORIZONTAL_REACH)); + Position lowest = source; + while (!queue.isEmpty()) { + Node node = queue.removeFirst(); + Position at = node.at(); + if (node.reach() < reach.getOrDefault(at, -1)) continue; + if (at.y() < lowest.y()) lowest = at; + if (reach.size() > 12000 || Math.abs(at.x() - source.x()) > 64 || Math.abs(at.z() - source.z()) > 64) return null; + if (at.y() <= 0) continue; + Position below = new Position(at.x(), at.y() - 1, at.z()); + if (!flowSolid(below, source)) { + enqueue(below, HORIZONTAL_REACH, at, reach, parent, queue); + 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)) enqueue(next, node.reach() - 1, at, reach, parent, queue); + } + } + if (!reach.containsKey(outlet)) return null; + int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY, minZ = source.z(), maxZ = minZ; + for (Position at : reach.keySet()) { + minX = Math.min(minX, at.x()); maxX = Math.max(maxX, at.x()); + minY = Math.min(minY, at.y()); maxY = Math.max(maxY, at.y()); + minZ = Math.min(minZ, at.z()); maxZ = Math.max(maxZ, at.z()); + } + List path = new ArrayList<>(); + for (Position at = lowest; at != null; at = parent.get(at)) path.add(at); + Collections.reverse(path); + return new Trace(List.copyOf(path), new CavernHydrology.FlowBounds(minX, minY, minZ, maxX, maxY, maxZ)); + } + + private boolean flowSolid(Position at, Position source) { + if (at.equals(source)) return false; + var cell = water.cellAt(at.x(), at.y(), at.z()); + if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false; + return solid(at); + } + + private boolean solid(Position at) { return solid(at.x(), at.y(), at.z()); } + private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; } + } + + private static void enqueue(Position next, int available, Position from, Map reach, + Map parent, ArrayDeque queue) { + if (available <= reach.getOrDefault(next, -1)) return; + if (!reach.containsKey(next)) parent.put(next, from); + reach.put(next, available); + queue.addLast(new Node(next, available)); + } + + private static CavernHydrology.FlowBounds bounds(Position at) { + return new CavernHydrology.FlowBounds(at.x(), at.y(), at.z(), at.x(), at.y(), at.z()); + } + + private static long distanceSquared(Position a, Position b) { + long dx = a.x() - b.x(), dy = a.y() - b.y(), dz = a.z() - b.z(); + return dx * dx + dy * dy + dz * dz; + } + + private static long distanceSquared(CavernHydrology.FlowBounds a, CavernHydrology.FlowBounds b) { + long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX())); + long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY())); + long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ())); + return dx * dx + dy * dy + dz * dz; + } + + private static double random(long seed, int x, int z) { + return (mix(seed ^ ((long) x << 32) ^ (z & 0xffffffffL)) >>> 11) * 0x1.0p-53; + } + + private static long mix(long value) { + value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; + return value ^ (value >>> 31); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernMaterials.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernMaterials.java new file mode 100644 index 0000000..b81bae9 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernMaterials.java @@ -0,0 +1,44 @@ +package fr.koka.sanctuary.worldgen; + +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +/** Alpha.9 shore deposits: broad continuous patches through the existing sediment thickness. */ +public final class CavernMaterials { + private CavernMaterials() {} + + public static BlockState sedimentBlock(CavernHydrology.Cell cell, long seed, int depth) { + if (cell.material() == CavernHydrology.ShoreMaterial.GRASS) { + return (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState(); + } + if (cell.material() == CavernHydrology.ShoreMaterial.CLAY) return Blocks.CLAY.defaultBlockState(); + double patch = patch(seed, cell.x(), cell.z(), 32); + Block material; + if (cell.hasWater() && patch < 0.24) material = Blocks.CLAY; + else if (patch < 0.53) material = Blocks.SAND; + else if (patch < 0.68) material = Blocks.GRAVEL; + else { + double rock = patch(seed ^ 0x4c6179657273L, cell.x(), cell.z(), 40); + material = rock < 0.28 ? Blocks.GRANITE : rock < 0.45 ? Blocks.DIORITE + : rock < 0.65 ? Blocks.ANDESITE : Blocks.STONE; + } + return material.defaultBlockState(); + } + + private static double patch(long seed, int x, int z, int scale) { + 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 near = lerp(value(seed, gx, gz), value(seed, gx + 1, gz), tx); + double far = lerp(value(seed, gx, gz + 1), value(seed, gx + 1, gz + 1), tx); + return lerp(near, far, tz); + } + + private static double value(long seed, int x, int z) { + return (WoodlandGroveGeometry.seed(seed, x, 0, z, 0x53484f5245L) >>> 11) * 0x1.0p-53; + } + + private static double smooth(double t) { return t * t * (3 - 2 * t); } + private static double lerp(double a, double b, double t) { return a + (b - a) * t; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernShoreSugarCaneFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernShoreSugarCaneFeature.java new file mode 100644 index 0000000..9b53802 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernShoreSugarCaneFeature.java @@ -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 CavernShoreSugarCaneFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(CavernShoreSugarCaneFeature::new); + + @Override + public MapCodec codec() { + return CODEC; + } + + @Override + public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false; + var plan = CavernHydrologyRuntime.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; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernSpringOutlets.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernSpringOutlets.java new file mode 100644 index 0000000..9c562bf --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernSpringOutlets.java @@ -0,0 +1,108 @@ +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.9 outlet finishing, using vanilla's persisted and consumed generation queue. */ +public final class CavernSpringOutlets { + public record Pending(List sources, List 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 CavernSpringOutlets() {} + + /** 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) + || !CavernHydrologyRuntime.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 = CavernHydrologyRuntime.plan(noise, level.getChunkSource().randomState()); + List 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()); + } + for (var spill : plan.spills()) { + if (markedHere(chunk, spill.source())) sources.add(spill.source()); + if (markedHere(chunk, spill.outlet())) outlets.add(spill.outlet()); + } + for (var fall : CavernHydrologyRuntime.lavaPlan(noise, level.getChunkSource().randomState()).falls()) { + var source = new CavernHydrology.Position(fall.source().x(), fall.source().y(), fall.source().z()); + var outlet = new CavernHydrology.Position(fall.outlet().x(), fall.outlet().y(), fall.outlet().z()); + if (markedHere(chunk, source)) sources.add(source); + if (markedHere(chunk, outlet)) outlets.add(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) + || !CavernHydrologyRuntime.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); + var modified = CavernHydrologyRuntime.plan(noise, random).cellAt(outlet.x(), outlet.y(), outlet.z()); + boolean carved = modified != null && outlet.y() > modified.waterY() && outlet.y() > modified.bedY() + && outlet.y() <= modified.carveTop(); + if (!carved && 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.getFluidState().isSource()) { + if (current.is(Blocks.WATER)) level.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level)); + else if (current.is(Blocks.LAVA)) level.scheduleTick(pos, Fluids.LAVA, Fluids.LAVA.getTickDelay(level)); + } + } + } + + private static boolean markedHere(LevelChunk chunk, CavernHydrology.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, CavernHydrology.Position position) { + return (position.x() >> 4) == chunk.getPos().x() && (position.z() >> 4) == chunk.getPos().z(); + } + + private static BlockPos blockPos(CavernHydrology.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); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernTerraces.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernTerraces.java new file mode 100644 index 0000000..dfa11ef --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/CavernTerraces.java @@ -0,0 +1,435 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.worldgen.CavernHydrology.*; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.function.Predicate; + +/** Small connected water terraces inside existing cave ledges. Each retained pool is excavated + * in rock; one declared edge may overflow. No dam, shelf, cave fill or fluid column is generated. */ +public final class CavernTerraces { + public static final int MIN_Y = 56, MAX_Y = 208; + public static final int MAX_GROUPS = 2, MAX_DEPTH = 3, MAX_CARVE = 5; + private static final int RADIUS = 216, SEDIMENT = 3; + 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 CavernTerraces() {} + + public record Result(List cells, List features, List terraces, + List spills, Set predictedFlow) { + public static final Result EMPTY = new Result(List.of(), List.of(), List.of(), List.of(), Set.of()); + public Result { + cells = List.copyOf(cells); features = List.copyOf(features); terraces = List.copyOf(terraces); + spills = List.copyOf(spills); predictedFlow = Set.copyOf(predictedFlow); + } + } + + public static Result create(long seed, Sampler sampler, CavernHydrology.Plan surface) { + return new Search(seed, sampler, surface).build(); + } + + private record Candidate(int x, int y, int z, int dx, int dz, double score) {} + private record Floor(int top, int bottom, int ceiling) {} + private record Node(int x, int z, double cost) {} + private record FlowNode(Position at, int reach) {} + private record Flow(Set volume, List path, FlowBounds bounds) {} + private record Pool(Map cells, Position source, Position outlet) {} + private static final Floor NO_FLOOR = new Floor(-1, -1, -1); + + private static final class Search { + private final long seed; + private final Sampler sampler; + private final CavernHydrology.Plan surface; + private final Map floors = new HashMap<>(); + private final List acceptedCells = new ArrayList<>(); + private final List features = new ArrayList<>(); + private final List terraces = new ArrayList<>(); + private final List spills = new ArrayList<>(); + private final Set predicted = new HashSet<>(); + + Search(long seed, Sampler sampler, CavernHydrology.Plan surface) { + this.seed = seed; this.sampler = sampler; this.surface = surface; + } + + Result build() { + List candidates = new ArrayList<>(); + for (int x = -208; x <= 208; x += 8) for (int z = -208; z <= 208; z += 8) { + if (!inside(x, z)) continue; + for (int y = 64; y <= MAX_Y; y += 8) { + if (!solid(x, y, z) || solid(x, y + 8, z)) continue; + int level = y; + for (int exact = y + 7; exact > y; exact--) if (solid(x, exact, z)) { level = exact; break; } + if (level > MAX_Y || floor(x, z, level) == NO_FLOOR) continue; + for (int[] d : CARDINALS) if (!solid(x + d[0] * 4, level, z + d[1] * 4)) { + candidates.add(new Candidate(x, level, z, d[0], d[1], random(seed + level, x + d[0], z + d[1]))); + } + } + } + candidates.sort(Comparator.comparingDouble(Candidate::score).reversed() + .thenComparingInt(Candidate::x).thenComparingInt(Candidate::z).thenComparingInt(Candidate::y) + .thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz)); + int attempts = 0; + for (Candidate candidate : candidates) { + if (terraces.size() >= MAX_GROUPS || attempts++ >= 160) break; + boolean added = 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, -1, 1, -2, 2}) { + Position source = new Position(x, candidate.y() + dy, z); + Position outlet = new Position(x + candidate.dx(), source.y(), z + candidate.dz()); + if (!mouth(source, outlet) || !separated(source)) continue; + if (addGroup(source, outlet)) { added = true; break; } + } + if (added) break; + } + if (added) break; + } + } + return new Result(acceptedCells, features, terraces, spills, predicted); + } + + private boolean addGroup(Position source, Position outlet) { + long groupId = mix(seed ^ (0xA9CA7E2L + terraces.size() * 0x9E3779B97F4A7C15L)); + long upperId = mix(groupId + 1), lowerId = mix(groupId + 2); + Pool upper = grow(source, source.y(), source, outlet, upperId); + if (upper == null) return false; + + // The first impact on a slope is often a thin lip. Follow natural drainage across + // its ledges, then find a supported receiving pool at a genuinely lower level. + // The same bounded fluid trace is used before and after carving the receiving pool. + Flow drainage = trace(source, outlet, new ArrayList<>(upper.cells().values())); + if (drainage == null) return false; + List contacts = new ArrayList<>(); + for (Position at : drainage.volume()) { + int level = at.y() - 1; + if (level < MIN_Y || level > source.y() - 4 || !solid(at.x(), level, at.z())) continue; + Floor ground = floor(at.x(), at.z(), level); + if (ground != NO_FLOOR && ground.bottom() <= level - 7) + contacts.add(new Position(at.x(), level, at.z())); + } + contacts.sort(Comparator.comparingInt(Position::y).reversed() + .thenComparingInt(Position::x).thenComparingInt(Position::z)); + Pool lower = null; + Position landing = null; + int attempts = 0; + for (Position contact : contacts) { + if (attempts++ >= 48) break; + lower = grow(contact, contact.y(), null, null, lowerId); + if (lower != null) { landing = contact; break; } + } + if (lower == null) return false; + List groupCells = new ArrayList<>(upper.cells().values()); + groupCells.addAll(lower.cells().values()); + if (overlapping(groupCells)) return false; + Flow inlet = trace(source, outlet, groupCells); + if (inlet == null || !reaches(inlet, lower)) return false; + + // If the same lower ledge has another natural edge, connect it to an open terminal + // fall. Otherwise its second pool stays closed; the first inter-pool cascade remains. + Pool drained = terminalPool(landing, lowerId); + Flow terminal = null; + if (drained != null) { + List replacement = new ArrayList<>(upper.cells().values()); + replacement.addAll(drained.cells().values()); + if (!overlapping(replacement)) { + Flow in = trace(source, outlet, replacement); + Flow out = trace(drained.source(), drained.outlet(), replacement); + if (in != null && reaches(in, drained) && out != null + && out.bounds().minY() <= drained.source().y() - 12) { + lower = drained; inlet = in; terminal = out; groupCells = replacement; + } + } + } + acceptedCells.addAll(groupCells); + features.add(feature(upperId, upper, source)); + features.add(feature(lowerId, lower, landing)); + terraces.add(new Terrace(groupId, List.of(upperId, lowerId))); + spills.add(new Spill(mix(groupId + 3), upperId, lowerId, source, outlet, inlet.path(), inlet.bounds())); + predicted.addAll(inlet.volume()); + if (terminal != null) { + spills.add(new Spill(mix(groupId + 4), lowerId, -1, lower.source(), lower.outlet(), terminal.path(), terminal.bounds())); + predicted.addAll(terminal.volume()); + } + return true; + } + + private Pool terminalPool(Position center, long id) { + List candidates = new ArrayList<>(); + for (int dx = -12; dx <= 12; dx++) for (int dz = -12; dz <= 12; dz++) { + if (dx * dx + dz * dz > 144) continue; + candidates.add(new Position(center.x() + dx, center.y(), center.z() + dz)); + } + candidates.sort(Comparator.comparingDouble((Position p) -> Math.hypot(p.x() - center.x(), p.z() - center.z())) + .thenComparingInt(Position::x).thenComparingInt(Position::z)); + int tried = 0; + for (Position source : candidates) for (int[] d : CARDINALS) { + Position outlet = new Position(source.x() + d[0], source.y(), source.z() + d[1]); + if (!mouth(source, outlet)) continue; + if (tried++ >= 12) return null; + Pool pool = grow(center, center.y(), source, outlet, id); + if (pool != null) return pool; + } + return null; + } + + private Pool grow(Position center, int level, Position source, Position outlet, long id) { + PriorityQueue queue = new PriorityQueue<>(Comparator.comparingDouble(Node::cost) + .thenComparingInt(Node::x).thenComparingInt(Node::z)); + Map costs = new HashMap<>(); + Set wet = new HashSet<>(); + long origin = key(center.x(), center.z()); + queue.add(new Node(center.x(), center.z(), 0)); costs.put(origin, 0.0); + int target = 100 + (int) (random(seed + level, center.x(), center.z()) * 80); + while (!queue.isEmpty() && wet.size() < target) { + Node node = queue.remove(); long k = key(node.x(), node.z()); + if (wet.contains(k) || node.cost() > costs.getOrDefault(k, Double.POSITIVE_INFINITY) + || !poolColumn(node.x(), node.z(), level, source, outlet)) continue; + wet.add(k); + for (int[] d : NEIGHBORS) { + int x = node.x() + d[0], z = node.z() + d[1]; long next = key(x, z); + if (Math.hypot(x - center.x(), z - center.z()) > 16 || wet.contains(next)) continue; + double cost = node.cost() + Math.hypot(d[0], d[1]) * (1 + noise(x, z, 11) * 1.8); + if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue; + costs.put(next, cost); queue.add(new Node(x, z, cost)); + } + } + wet = connected(wet, origin); + if (wet.size() < 40 || source != null && !wet.contains(key(source.x(), source.z()))) return null; + Map cells = new HashMap<>(); + for (long k : wet) { + int x = (int) (k >> 32), z = (int) k; + boolean edge = false; + for (int[] d : CARDINALS) edge |= !wet.contains(key(x + d[0], z + d[1])); + int depth = edge ? 1 : 2 + (noise(x, z, 9) > .6 ? 1 : 0); + Floor floor = floor(x, z, level); + int bed = level - depth; + if (floor == NO_FLOOR || floor.top() - bed > MAX_CARVE || floor.bottom() > bed - SEDIMENT - 1) return null; + ShoreMaterial material = noise(x + 101, z - 87, 19) > .42 ? ShoreMaterial.CLAY : ShoreMaterial.STONE; + cells.put(k, new Cell(x, z, level, bed, floor.top(), material, id, SEDIMENT)); + } + for (Cell cell : cells.values()) for (int[] d : CARDINALS) { + int x = cell.x() + d[0], z = cell.z() + d[1]; + Cell next = cells.get(key(x, z)); + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + if (next != null && y > next.bedY()) continue; + if (source != null && cell.x() == source.x() && cell.z() == source.z() + && x == outlet.x() && z == outlet.z() && y == outlet.y()) continue; + if (!solid(x, y, z)) return null; + } + } + return new Pool(Map.copyOf(cells), source, outlet); + } + + private boolean poolColumn(int x, int z, int level, Position source, Position outlet) { + if (!inside(x, z)) return false; + Floor floor = floor(x, z, level); + if (floor == NO_FLOOR || floor.bottom() > level - 7 || !available(x, z, level - 8, floor.top())) return false; + for (int[] d : CARDINALS) { + if (source != null && x == source.x() && z == source.z() + && x + d[0] == outlet.x() && z + d[1] == outlet.z()) continue; + if (!solid(x + d[0], level, z + d[1])) return false; + } + return true; + } + + private Floor floor(int x, int z, int level) { + return floors.computeIfAbsent(new Position(x, level, z), ignored -> { + if (level < MIN_Y || level > MAX_Y) return NO_FLOOR; + int top = -1; + for (int y = level + 2; y >= level; y--) { + if (solid(x, y, z)) { top = y; break; } + } + if (top < 0) return NO_FLOOR; + for (int y = top + 1; y <= top + 4; y++) if (solid(x, y, z)) return NO_FLOOR; + int ceiling = top + 5; + while (ceiling < 384 && !solid(x, ceiling, z)) ceiling++; + if (ceiling >= 384) return NO_FLOOR; + int bottom = top; + while (bottom > top - 16 && solid(x, bottom - 1, z)) bottom--; + return new Floor(top, bottom, ceiling); + }); + } + + private boolean mouth(Position source, Position outlet) { + if (source.y() < MIN_Y || source.y() > MAX_Y || !solid(source.x(), source.y(), source.z())) return false; + // An adjacent natural step may receive the water before it descends again. Requiring + // a vertical shaft here would discard the gentle ledges that form cave terraces. + if (solid(outlet.x(), outlet.y(), outlet.z())) return false; + return floor(source.x(), source.z(), source.y()) != NO_FLOOR; + } + + private boolean available(int x, int z, int low, int high) { + for (Cell cell : surface.cellsAt(x, z)) if (low <= cell.carveTop() + 4 + && high >= cell.bedY() - cell.sedimentDepth() - 5) return false; + for (Cell cell : acceptedCells) if (cell.x() == x && cell.z() == z && low <= cell.carveTop() + 4 + && high >= cell.bedY() - cell.sedimentDepth() - 5) return false; + for (Spring spring : surface.springs()) if (inBounds(x, low, high, z, spring.flowBounds(), 6)) return false; + for (Spill spill : spills) if (inBounds(x, low, high, z, spill.flowBounds(), 6)) return false; + return true; + } + + private boolean separated(Position source) { + for (Spill spill : spills) if (Math.hypot(source.x() - spill.source().x(), source.z() - spill.source().z()) < 64) return false; + return available(source.x(), source.z(), source.y() - 8, source.y() + 4); + } + + private static boolean overlapping(List cells) { + Map> columns = new HashMap<>(); + for (Cell cell : cells) { + var existing = columns.computeIfAbsent(key(cell.x(), cell.z()), ignored -> new ArrayList<>()); + for (Cell old : existing) if (cell.bedY() - cell.sedimentDepth() - 1 <= old.carveTop() + && old.bedY() - old.sedimentDepth() - 1 <= cell.carveTop()) return true; + existing.add(cell); + } + return false; + } + + private Flow trace(Position source, Position outlet, List group) { + Map> changes = new HashMap<>(); + Set retained = new HashSet<>(); + for (Cell cell : group) { + changes.computeIfAbsent(key(cell.x(), cell.z()), ignored -> new ArrayList<>()).add(cell); + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) retained.add(new Position(cell.x(), y, cell.z())); + } + Map solidCache = new HashMap<>(); + Predicate open = at -> !solidCache.computeIfAbsent(at, p -> flowSolid(p, changes)); + Predicate canEnter = at -> !retained.contains(at) && open.test(at); + Predicate hole = at -> open.test(new Position(at.x(), at.y() - 1, at.z())); + Map reach = new HashMap<>(); Map parent = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + reach.put(source, 7); queue.addLast(new FlowNode(source, 7)); Position lowest = source; + while (!queue.isEmpty()) { + FlowNode node = queue.removeFirst(); Position at = node.at(); + if (node.reach() < reach.getOrDefault(at, -1)) continue; + if (at.y() < lowest.y()) lowest = at; + if (reach.size() > 16000 || Math.abs(at.x() - source.x()) > 64 || Math.abs(at.z() - source.z()) > 64) return null; + // Vanilla never replaces an existing source. Record the arriving contact, + // but do not manufacture a route through the separately retained basin. + if (retained.contains(at) && !at.equals(source)) continue; + if (at.y() <= 0) continue; + Position below = new Position(at.x(), at.y() - 1, at.z()); + if (open.test(below)) { enqueue(below, 7, at, reach, parent, queue); continue; } + if (node.reach() <= 0) continue; + for (Position next : downhillChoices(at, canEnter, hole)) + enqueue(next, node.reach() - 1, at, reach, parent, queue); + } + if (!reach.containsKey(outlet)) return null; + int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY, minZ = source.z(), maxZ = minZ; + for (Position at : reach.keySet()) { + minX = Math.min(minX, at.x()); maxX = Math.max(maxX, at.x()); minY = Math.min(minY, at.y()); maxY = Math.max(maxY, at.y()); + minZ = Math.min(minZ, at.z()); maxZ = Math.max(maxZ, at.z()); + } + List 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()), List.copyOf(path), new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ)); + } + + private boolean flowSolid(Position at, Map> changes) { + for (Cell cell : changes.getOrDefault(key(at.x(), at.z()), List.of())) + if (at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false; + Cell surfaceCell = surface.cellAt(at.x(), at.y(), at.z()); + if (surfaceCell != null && at.y() > surfaceCell.bedY() && at.y() <= surfaceCell.carveTop()) return false; + return solid(at.x(), at.y(), at.z()); + } + + private static boolean reaches(Flow flow, Pool pool) { + return flow.volume().stream().anyMatch(at -> { + Cell cell = pool.cells().get(key(at.x(), at.z())); + return cell != null && at.y() > cell.bedY() && at.y() <= cell.waterY(); + }); + } + + private static Feature feature(long id, Pool pool, Position center) { + int minX = Integer.MAX_VALUE, minZ = minX, maxX = Integer.MIN_VALUE, maxZ = maxX; + for (Cell cell : pool.cells().values()) { + minX = Math.min(minX, cell.x()); maxX = Math.max(maxX, cell.x()); minZ = Math.min(minZ, cell.z()); maxZ = Math.max(maxZ, cell.z()); + } + return new Feature(id, Kind.TERRACE, center.y(), List.of(new Point(center.x(), center.z())), pool.cells().size(), + new Bounds(minX, minZ, maxX, maxZ)); + } + + private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; } + private double noise(int x, int z, int scale) { + int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale); + double tx = smooth(Math.floorMod(x, scale) / (double) scale), tz = smooth(Math.floorMod(z, scale) / (double) scale); + double a = random(seed, gx, gz) * (1 - tx) + random(seed, gx + 1, gz) * tx; + double b = random(seed, gx, gz + 1) * (1 - tx) + random(seed, gx + 1, gz + 1) * tx; + return a * (1 - tz) + b * tz; + } + } + + /** WaterFluid's bare-rock downhill choice: consider holes through four recursive + * steps, keep only the nearest directions (including ties), spread everywhere + * only when no direction finds a hole. Retained sources cannot be entered. */ + static List downhillChoices(Position at, Predicate canEnter, Predicate hole) { + List selected = new ArrayList<>(); + int best = 1000; + for (int[] d : CARDINALS) { + Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]); + if (!canEnter.test(next)) continue; + int cost = hole.test(next) ? 0 : slopeDistance(next, 1, -d[0], -d[1], canEnter, hole); + if (cost < best) selected.clear(); + if (cost <= best) { selected.add(next); best = cost; } + } + return selected; + } + + private static int slopeDistance(Position at, int distance, int backX, int backZ, + Predicate canEnter, Predicate hole) { + int best = 1000; + for (int[] d : CARDINALS) { + if (d[0] == backX && d[1] == backZ) continue; + Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]); + if (!canEnter.test(next)) continue; + if (hole.test(next)) return distance; + if (distance < 4) best = Math.min(best, + slopeDistance(next, distance + 1, -d[0], -d[1], canEnter, hole)); + } + return best; + } + + private static void enqueue(Position next, int amount, Position from, Map reach, + Map parent, ArrayDeque queue) { + if (amount <= reach.getOrDefault(next, -1)) return; + if (!reach.containsKey(next)) parent.put(next, from); + reach.put(next, amount); queue.addLast(new FlowNode(next, amount)); + } + private static Set connected(Set footprint, long origin) { + if (!footprint.contains(origin)) return Set.of(); + Set seen = new HashSet<>(); ArrayDeque queue = new ArrayDeque<>(); + seen.add(origin); queue.add(origin); + while (!queue.isEmpty()) { + long at = queue.removeFirst(); int x = (int) (at >> 32), z = (int) at; + for (int[] d : CARDINALS) { + long next = key(x + d[0], z + d[1]); + if (footprint.contains(next) && seen.add(next)) queue.addLast(next); + } + } + return seen; + } + private static boolean inBounds(int x, int low, int high, int z, FlowBounds b, int margin) { + return x >= b.minX() - margin && x <= b.maxX() + margin && z >= b.minZ() - margin && z <= b.maxZ() + margin + && low <= b.maxY() + margin && high >= b.minY() - margin; + } + private static boolean inside(int x, int z) { 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 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); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuarySpawn.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuarySpawn.java index 542ea15..617241d 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuarySpawn.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuarySpawn.java @@ -33,6 +33,9 @@ public final class SanctuarySpawn { public static final ResourceKey RIFT_SETTINGS = ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_rift")); + public static final ResourceKey CAVERN_SETTINGS = + ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_cavern")); + private SanctuarySpawn() {} public static boolean usesSanctuaryGenerator(ServerLevel level) { @@ -43,7 +46,8 @@ public final class SanctuarySpawn { || generator.generatorSettings().is(NATURAL_SETTINGS) || generator.generatorSettings().is(LAYERED_SETTINGS) || generator.generatorSettings().is(WOODLAND_SETTINGS) - || generator.generatorSettings().is(RIFT_SETTINGS)); + || generator.generatorSettings().is(RIFT_SETTINGS) + || generator.generatorSettings().is(CAVERN_SETTINGS)); } /** Called only during first creation; saved spawns and /setworldspawn survive subsequent loads. */ diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 646d937..542b522 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -27,5 +27,15 @@ "biome.sanctuary.rift_rocky_heath": "Rocky Heath", "biome.sanctuary.rift_dark_grove": "Dark Undergrowth", "biome.sanctuary.rift_bamboo_grove": "Bamboo Grove", - "biome.sanctuary.rift_sulfur_depths": "Sulfur Depths" + "biome.sanctuary.rift_sulfur_depths": "Sulfur Depths", + "biome.sanctuary.cavern_oak_forest": "Sanctuary Oak Woodland", + "biome.sanctuary.cavern_birch_forest": "Sanctuary Birch Woodland", + "biome.sanctuary.cavern_clearing": "Flowered Clearing", + "biome.sanctuary.cavern_dry_woodland": "Dry Woodland", + "biome.sanctuary.cavern_rocky_heath": "Rocky Heath", + "biome.sanctuary.cavern_dark_grove": "Dark Undergrowth", + "biome.sanctuary.cavern_bamboo_grove": "Bamboo Grove", + "biome.sanctuary.cavern_sulfur_depths": "Sulfur Depths", + "biome.sanctuary.cavern_lush_caves": "Sanctuary Lush Caves", + "biome.sanctuary.cavern_dripstone_caves": "Sanctuary Dripstone Caves" } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 8bd2e14..0aae96b 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -27,5 +27,15 @@ "biome.sanctuary.rift_rocky_heath": "Lande rocheuse", "biome.sanctuary.rift_dark_grove": "Sous-bois sombre", "biome.sanctuary.rift_bamboo_grove": "Bosquet de bambous", - "biome.sanctuary.rift_sulfur_depths": "Profondeurs soufrées" + "biome.sanctuary.rift_sulfur_depths": "Profondeurs soufrées", + "biome.sanctuary.cavern_oak_forest": "Chênaie de Sanctuary", + "biome.sanctuary.cavern_birch_forest": "Bétulaie de Sanctuary", + "biome.sanctuary.cavern_clearing": "Clairière fleurie", + "biome.sanctuary.cavern_dry_woodland": "Bois sec", + "biome.sanctuary.cavern_rocky_heath": "Lande rocheuse", + "biome.sanctuary.cavern_dark_grove": "Sous-bois sombre", + "biome.sanctuary.cavern_bamboo_grove": "Bosquet de bambous", + "biome.sanctuary.cavern_sulfur_depths": "Profondeurs soufrées", + "biome.sanctuary.cavern_lush_caves": "Grottes luxuriantes de Sanctuary", + "biome.sanctuary.cavern_dripstone_caves": "Grottes de spéléothèmes de Sanctuary" } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_bamboo_grove.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_bamboo_grove.json new file mode 100644 index 0000000..3ebb172 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_bamboo_grove.json @@ -0,0 +1,167 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.sparse_jungle" + } + }, + "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:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:wolf", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 2 + }, + "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.95, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.8 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_birch_forest.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_birch_forest.json new file mode 100644 index 0000000..91cb95f --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_birch_forest.json @@ -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.7, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "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:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_clearing.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_clearing.json new file mode 100644 index 0000000..3d50b60 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_clearing.json @@ -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.75, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "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:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dark_grove.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dark_grove.json new file mode 100644 index 0000000..9dc6be9 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dark_grove.json @@ -0,0 +1,153 @@ +{ + "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.9, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dripstone_caves.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dripstone_caves.json new file mode 100644 index 0000000..4288481 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dripstone_caves.json @@ -0,0 +1,153 @@ +{ + "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.55, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.5 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dry_woodland.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dry_woodland.json new file mode 100644 index 0000000..93e49ed --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_dry_woodland.json @@ -0,0 +1,163 @@ +{ + "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.4, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:forest_flowers", + "sanctuary:rift_sparse_trees", + "minecraft:patch_bush", + "minecraft:flower_default", + "minecraft:patch_grass_forest", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.75 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_lush_caves.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_lush_caves.json new file mode 100644 index 0000000..0b819ad --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_lush_caves.json @@ -0,0 +1,153 @@ +{ + "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.9, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.5 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_oak_forest.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_oak_forest.json new file mode 100644 index 0000000..90068de --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_oak_forest.json @@ -0,0 +1,166 @@ +{ + "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", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "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:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_rocky_heath.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_rocky_heath.json new file mode 100644 index 0000000..3dd0af4 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_rocky_heath.json @@ -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.65, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "sanctuary:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_sulfur_depths.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_sulfur_depths.json new file mode 100644 index 0000000..340697f --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/cavern_sulfur_depths.json @@ -0,0 +1,156 @@ +{ + "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.5, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "sanctuary:cavern_groves", + "sanctuary:cavern_decorations", + "sanctuary:cavern_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.8 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/feature/cavern_amethyst_geode.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/feature/cavern_amethyst_geode.json new file mode 100644 index 0000000..b4254a3 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/feature/cavern_amethyst_geode.json @@ -0,0 +1,39 @@ +{ + "type": "minecraft:geode", + "blocks": { + "alternate_inner_layer_provider": { + "id": "minecraft:budding_amethyst" + }, + "cannot_replace": "#minecraft:features_cannot_replace", + "filling_provider": { + "id": "minecraft:air" + }, + "inner_layer_provider": { + "id": "minecraft:amethyst_block" + }, + "inner_placements": [ + "minecraft:small_amethyst_bud", + "minecraft:medium_amethyst_bud", + "minecraft:large_amethyst_bud", + "minecraft:amethyst_cluster" + ], + "invalid_blocks": "#minecraft:geode_invalid_blocks", + "middle_layer_provider": { + "id": "minecraft:calcite" + }, + "outer_layer_provider": { + "id": "minecraft:smooth_basalt" + } + }, + "crack": { + "generate_crack_chance": 1.0 + }, + "invalid_blocks_threshold": 1, + "layers": {}, + "outer_wall_distance": 1, + "use_alternate_layer0_chance": 0.2, + "distribution_points": 2, + "point_offset": 1, + "min_gen_offset": -5, + "max_gen_offset": 7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_deep_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_deep_strata.json new file mode 100644 index 0000000..cd4e707 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_deep_strata.json @@ -0,0 +1,37 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": -1000, + "max_threshold": -0.3, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:blackstone" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": 0.3, + "max_threshold": 1000, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:basalt" + } + }, + { + "type": "minecraft:block", + "result_state": "minecraft:deepslate" + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_depth_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_depth_strata.json new file mode 100644 index 0000000..ea61f93 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_depth_strata.json @@ -0,0 +1,71 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 136 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:cavern_deep_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 152 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_deep_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 168 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_deep_strata" + } + }, + "sanctuary:cavern_upper_strata" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_humid_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_humid_strata.json new file mode 100644 index 0000000..897df56 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_humid_strata.json @@ -0,0 +1,51 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": -1000, + "max_threshold": -0.36, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:cobblestone" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": 0.36, + "max_threshold": 1000, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:packed_mud" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": -0.14, + "max_threshold": 0.14, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:mossy_cobblestone" + } + }, + { + "type": "minecraft:block", + "result_state": "minecraft:tuff" + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_island.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_island.json new file mode 100644 index 0000000..467a49a --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_island.json @@ -0,0 +1,279 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:cavern_sulfur_depths" + }, + "then_run": "sanctuary:cavern_sulfur_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 136 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:cavern_deep_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 152 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_deep_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 168 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_deep_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:cavern_lush_caves" + }, + "then_run": "sanctuary:cavern_humid_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:cavern_dark_grove" + }, + "then_run": { + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 192 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:cavern_humid_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 208 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_humid_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 224 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_humid_strata" + } + } + ] + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:cavern_bamboo_grove" + }, + "then_run": { + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 192 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:cavern_humid_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 208 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_humid_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 224 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:cavern_humid_strata" + } + } + ] + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:cavern_rocky_heath" + }, + "then_run": "sanctuary:cavern_upper_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_exposure", + "min_threshold": 0.85, + "max_threshold": 1000, + "is_3d": false + }, + "then_run": "sanctuary:cavern_upper_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:cavern_dry_woodland" + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_exposure", + "min_threshold": 0.15, + "max_threshold": 1000, + "is_3d": false + }, + "then_run": "sanctuary:cavern_upper_strata" + } + }, + "sanctuary:starter_island", + "sanctuary:cavern_upper_strata" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_sulfur_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_sulfur_strata.json new file mode 100644 index 0000000..a84e73b --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_sulfur_strata.json @@ -0,0 +1,34 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": -0.15, + "max_threshold": 0.55, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:sulfur" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": -0.55, + "max_threshold": -0.3, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:cinnabar" + } + }, + "sanctuary:cavern_depth_strata" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_upper_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_upper_strata.json new file mode 100644 index 0000000..85c7916 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/cavern_upper_strata.json @@ -0,0 +1,51 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": -1000, + "max_threshold": -0.36, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:granite" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": 0.36, + "max_threshold": 1000, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:diorite" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:cavern_strata", + "min_threshold": -0.14, + "max_threshold": 0.14, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:andesite" + } + }, + { + "type": "minecraft:block", + "result_state": "minecraft:stone" + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_depth_boundary.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_depth_boundary.json new file mode 100644 index 0000000..ed46579 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_depth_boundary.json @@ -0,0 +1,5 @@ +{ + "base_amplitude": 1.0, + "base_octave": -6, + "octave_count": 2 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_exposure.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_exposure.json new file mode 100644 index 0000000..f4e40d6 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_exposure.json @@ -0,0 +1,5 @@ +{ + "base_amplitude": 1.0, + "base_octave": -7, + "octave_count": 2 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_strata.json new file mode 100644 index 0000000..ed46579 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/cavern_strata.json @@ -0,0 +1,5 @@ +{ + "base_amplitude": 1.0, + "base_octave": -6, + "octave_count": 2 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary_cavern.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary_cavern.json new file mode 100644 index 0000000..caae37b --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary_cavern.json @@ -0,0 +1,23 @@ +{ + "default_block": "minecraft:stone", + "default_fluid": "minecraft:air", + "disable_mob_generation": false, + "legacy_random_source": true, + "material_rule": "sanctuary:cavern_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_rift", + "ridges": 0.0, + "temperature": "sanctuary:temperate_variation", + "vegetation": "sanctuary:temperate_moisture" + }, + "sea_level": -64, + "spawn_target": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_decorations.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_decorations.json new file mode 100644 index 0000000..f638dba --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_decorations.json @@ -0,0 +1,6 @@ +{ + "feature": { + "type": "sanctuary:cavern_decorations" + }, + "placement": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_groves.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_groves.json new file mode 100644 index 0000000..76d9dc3 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_groves.json @@ -0,0 +1,6 @@ +{ + "feature": { + "type": "sanctuary:cavern_groves" + }, + "placement": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_shore_sugar_cane.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_shore_sugar_cane.json new file mode 100644 index 0000000..42a5504 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/cavern_shore_sugar_cane.json @@ -0,0 +1,6 @@ +{ + "feature": { + "type": "sanctuary:cavern_shore_sugar_cane" + }, + "placement": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json index fd7f051..948a778 100644 --- a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json @@ -5,17 +5,19 @@ "generator": { "type": "minecraft:noise", "biome_source": { - "type": "sanctuary:rift_island", - "oak_forest": "sanctuary:rift_oak_forest", - "birch_forest": "sanctuary:rift_birch_forest", - "clearing": "sanctuary:rift_clearing", - "dry_woodland": "sanctuary:rift_dry_woodland", - "dark_grove": "sanctuary:rift_dark_grove", - "bamboo_grove": "sanctuary:rift_bamboo_grove", - "sulfur_depths": "sanctuary:rift_sulfur_depths", - "rocky_heath": "sanctuary:rift_rocky_heath" + "type": "sanctuary:cavern_island", + "oak_forest": "sanctuary:cavern_oak_forest", + "birch_forest": "sanctuary:cavern_birch_forest", + "clearing": "sanctuary:cavern_clearing", + "dry_woodland": "sanctuary:cavern_dry_woodland", + "dark_grove": "sanctuary:cavern_dark_grove", + "bamboo_grove": "sanctuary:cavern_bamboo_grove", + "sulfur_depths": "sanctuary:cavern_sulfur_depths", + "rocky_heath": "sanctuary:cavern_rocky_heath", + "lush_caves": "sanctuary:cavern_lush_caves", + "dripstone_caves": "sanctuary:cavern_dripstone_caves" }, - "settings": "sanctuary:sanctuary_rift" + "settings": "sanctuary:sanctuary_cavern" } }, "minecraft:the_end": { diff --git a/mods/sanctuary/src/main/resources/sanctuary.mixins.json b/mods/sanctuary/src/main/resources/sanctuary.mixins.json index 3c4b531..e3497ba 100644 --- a/mods/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/mods/sanctuary/src/main/resources/sanctuary.mixins.json @@ -7,7 +7,8 @@ "SurfaceHydrologyMixin", "LayeredSpringOutletMixin", "WoodlandSpringOutletMixin", - "RiftSpringOutletMixin" + "RiftSpringOutletMixin", + "CavernSpringOutletMixin" ], "client": [ "client.SanctuarySkyMixin", diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernHydrologySmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernHydrologySmoke.java new file mode 100644 index 0000000..4c13859 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernHydrologySmoke.java @@ -0,0 +1,140 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.worldgen.CavernHydrology.*; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +/** Geometry tests complement engine checks of generated fluids, plants and post-processing. */ +public final class CavernHydrologySmoke { + private CavernHydrologySmoke() {} + + public static void main(String[] args) { + verifyDownhillChoice(); + var empty = CavernHydrology.create(0, (x, y, z) -> -1); + check(empty.cells().isEmpty() && empty.terraces().isEmpty() && empty.spills().isEmpty(), "void must stay empty"); + Sampler ledges = (x, y, z) -> { + if (Math.hypot(x, z) >= 160) return -1; + int floor = x <= 0 ? 144 : x <= 24 ? 120 : 96; + return y >= 40 && y <= floor || y >= 200 && y <= 220 ? 1 : -1; + }; + var first = CavernTerraces.create(0, ledges, empty); + check(!first.terraces().isEmpty(), "natural reference ledges must create connected terraces"); + check(first.equals(CavernTerraces.create(0, ledges, empty)), "same seed must replay immutable terrace geometry"); + check(!first.equals(CavernTerraces.create(42, ledges, empty)), "seed must affect terrace positions"); + verify(first, ledges); + Sampler steppedSlope = (x, y, z) -> { + if (Math.hypot(x, z) >= 160) return -1; + int floor = 144 - Math.min(4, Math.max(0, Math.floorDiv(x + 2, 3))); + return y >= 40 && y <= floor || y >= 200 && y <= 220 ? 1 : -1; + }; + var stepped = CavernTerraces.create(0, steppedSlope, empty); + check(!stepped.terraces().isEmpty(), "gentle natural steps must lead to a lower receiving pool"); + verify(stepped, steppedSlope); + check(stepped.spills().stream().filter(s -> s.toFeatureId() != -1).anyMatch(s -> + steppedSlope.sample(s.outlet().x(), s.outlet().y() - 1, s.outlet().z()) > 0), + "reference drainage must cross a shallow first impact, not require an immediate shaft"); + var noRoof = CavernTerraces.create(0, (x, y, z) -> y >= 200 ? -1 : ledges.sample(x, y, z), empty); + check(noRoof.terraces().isEmpty(), "lower terraces must have a natural cave roof"); + var thin = CavernTerraces.create(0, (x, y, z) -> { + int floor = x <= 0 ? 144 : x <= 24 ? 120 : 96; + return y < floor && y > floor - 8 ? -1 : ledges.sample(x, y, z); + }, empty); + check(thin.terraces().isEmpty(), "perforated foundations cannot receive terraced pools"); + + // This complete plan contains surface water on the roof above the cavity. Lookups must + // retain both levels in the same X/Z column and must never carve the natural roof away. + Sampler stackedLedge = (x, y, z) -> { + if (Math.hypot(x, z) >= 100 || y < 40) return -1; + int roofTop = Math.abs(x) < 28 && Math.abs(z) < 28 ? 216 : 220; + int floor = x <= 0 ? 144 : x <= 24 ? 120 : 96; + boolean cavern = Math.abs(z) <= 12 && x >= -48 && x <= 48; + return y <= roofTop && (!cavern || y <= floor || y >= 196) ? 1 : -1; + }; + var stacked = CavernHydrology.create(0, stackedLedge); + check(!stacked.terraces().isEmpty(), "surface water must not suppress safe lower layers"); + boolean shared = false; + for (var cell : stacked.cells()) { + check(stacked.cellAt(cell.x(), cell.bedY(), cell.z()).equals(cell), "height-aware lookup loses a layer"); + check(stacked.cellsInChunk(cell.x() >> 4, cell.z() >> 4).contains(cell), "chunk slice loses a layer"); + var layers = stacked.cellsAt(cell.x(), cell.z()); + shared |= layers.size() > 1; + for (var other : layers) if (!other.equals(cell)) check(other.carveTop() < cell.bedY() - cell.sedimentDepth() - 1 + || cell.carveTop() < other.bedY() - other.sedimentDepth() - 1, "vertical edits overlap"); + } + check(shared, "reference world must exercise two modified layers in one X/Z column"); + for (var spill : stacked.spills()) { + check(stacked.isSpillOpening(spill.outlet().x(), spill.outlet().y(), spill.outlet().z()), "declared outlet missing"); + check(stacked.allowsSpringFlow(spill.outlet().x(), spill.outlet().y(), spill.outlet().z()), "flow prediction omits spill"); + } + int maxLakeDepth = stacked.cells().stream().filter(c -> stacked.features().stream() + .anyMatch(f -> f.id() == c.featureId() && f.kind() == Kind.LAKE)) + .mapToInt(c -> c.waterY() - c.bedY()).max().orElse(0); + check(maxLakeDepth >= 6 && maxLakeDepth <= CavernHydrology.MAX_DEPTH, "surface lake must deepen within natural support budget"); + System.out.println("CavernHydrologySmoke: linked cave terraces, gravity paths, natural shells, stacked layers and deeper surface lakes passed."); + } + + private static void verifyDownhillChoice() { + Position start = new Position(0, 100, 0); + // Two open branches have identical floors at the junction, but water must + // choose the east drop two steps away over the west drop four steps away. + var nearer = CavernTerraces.downhillChoices(start, p -> p.z() == 0 && Math.abs(p.x()) <= 5, + p -> p.x() == 2 || p.x() == -4); + check(nearer.equals(java.util.List.of(new Position(1, 100, 0))), + "a longer competing drop must not invent a water route into its receiving basin"); + var tied = CavernTerraces.downhillChoices(start, p -> p.z() == 0 && Math.abs(p.x()) <= 5, + p -> Math.abs(p.x()) == 2); + check(new HashSet<>(tied).equals(java.util.Set.of(new Position(1, 100, 0), new Position(-1, 100, 0))), + "equally near drops must retain both vanilla branches"); + var closed = CavernTerraces.downhillChoices(start, p -> p.z() == 0 && Math.abs(p.x()) <= 5, p -> false); + check(new HashSet<>(closed).equals(new HashSet<>(tied)), "flat floor without a nearby drop spreads to both sides"); + } + + private static void verify(CavernTerraces.Result result, Sampler density) { + Map features = new HashMap<>(); + result.features().forEach(feature -> features.put(feature.id(), feature)); + for (var group : result.terraces()) { + check(group.basinIds().size() == 2, "two retained steps per group"); + var upper = features.get(group.basinIds().get(0)); var lower = features.get(group.basinIds().get(1)); + check(upper.waterY() - lower.waterY() >= 4, "terraces require distinct gravity levels"); + var spill = result.spills().stream().filter(s -> s.fromFeatureId() == upper.id() && s.toFeatureId() == lower.id()) + .findFirst().orElseThrow(() -> new AssertionError("missing inter-pool cascade")); + check(spill.flowPath().getFirst().equals(spill.source()), "cascade root must be retained basin water"); + check(spill.flowPath().stream().anyMatch(p -> result.cells().stream().anyMatch(c -> + c.featureId() == lower.id() && c.x() == p.x() && c.z() == p.z() && p.y() > c.bedY() && p.y() <= c.waterY())), + "cascade must actually reach the lower retained pool"); + } + var openings = new HashSet(); result.spills().forEach(spill -> openings.add(spill.outlet())); + for (var cell : result.cells()) { + check(cell.waterY() - cell.bedY() >= 1 && cell.waterY() - cell.bedY() <= CavernTerraces.MAX_DEPTH, "shallow lower depth"); + check(cell.carveTop() - cell.bedY() <= CavernTerraces.MAX_CARVE, "limited excavation"); + for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) + check(density.sample(cell.x(), y, cell.z()) > 0, "natural foundation and sediment support"); + boolean roof = false; + for (int y = cell.carveTop() + 5; y < 384; y++) roof |= density.sample(cell.x(), y, cell.z()) > 0; + check(roof, "cavity roof must remain natural"); + for (int[] d : new int[][]{{1,0},{-1,0},{0,1},{0,-1}}) { + int x = cell.x() + d[0], z = cell.z() + d[1]; + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + final int sy = y; + boolean adjacentWater = result.cells().stream().anyMatch(c -> c.x() == x && c.z() == z + && c.waterY() == cell.waterY() && sy > c.bedY() && sy <= c.waterY()); + check(adjacentWater || density.sample(x, y, z) > 0 || openings.contains(new Position(x,y,z)), + "every exterior fluid face must be natural rock or an explicitly declared outlet"); + } + } + } + for (var spill : result.spills()) { + var path = spill.flowPath(); + for (int i = 1; i < path.size(); i++) { + var a = path.get(i - 1); var b = path.get(i); + check(Math.abs(a.x() - b.x()) + Math.abs(a.y() - b.y()) + Math.abs(a.z() - b.z()) == 1 && b.y() <= a.y(), + "gravity connection must traverse adjacent non-ascending voxels"); + } + } + } + + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernLavaSmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernLavaSmoke.java new file mode 100644 index 0000000..28bf4f4 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernLavaSmoke.java @@ -0,0 +1,112 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.HashSet; + +/** Pure geometry regressions; actual fluid propagation is covered separately in Minecraft. */ +public final class CavernLavaSmoke { + private CavernLavaSmoke() {} + + public static void main(String[] args) { + CavernHydrology.Plan water = CavernHydrology.create(0, (x, y, z) -> -1); + CavernLavaDeposit.Plan empty = CavernLavaDeposit.create(0, (x, y, z) -> -1, water); + check(empty.cells().isEmpty() && empty.falls().isEmpty(), "void cannot create lava sites"); + check(CavernLavaFlows.create(0, (x, y, z) -> 1, water, empty).isEmpty(), + "sealed rock cannot create an outlet"); + + CavernHydrology.Sampler wall = (x, y, z) -> + x <= -32 && x >= -176 && Math.abs(z) <= 152 && y >= 48 && y <= 200 ? 1 : -1; + var plan = CavernLavaDeposit.create(42, wall, water); + check(plan.falls().size() == 2, "two natural wall outlets expected on reference wall"); + check(plan.falls().equals(CavernLavaDeposit.create(42, wall, water).falls()), "same seed must reproduce all sites"); + check(!plan.falls().equals(CavernLavaDeposit.create(43, wall, water).falls()), "seed must affect selection"); + check(plan.lavaCells().size() == 2 && plan.cells().size() == 2, + "each seep writes only its rock source, never a filled falling column"); + for (var cell : plan.cells()) check(wall.sample(cell.x(), cell.y(), cell.z()) > 0, + "every changed cell must start as natural rock"); + var supports = new HashSet<>(plan.supports()); + for (var support : supports) check(wall.sample(support.x(), support.y(), support.z()) > 0, + "source protection must be existing rock"); + for (var fall : plan.falls()) { + validate(fall, wall); + check(fall.flowBounds().minY() == 0, "reference wall must allow a cascade into the void"); + check(!supports.contains(fall.source()), "source itself must not be an unchanged support"); + check(plan.fallsInChunk(fall.source().x() >> 4, fall.source().z() >> 4).contains(fall), + "each source belongs to its own chunk"); + check(plan.cellsInChunk(fall.source().x() >> 4, fall.source().z() >> 4).stream() + .anyMatch(c -> c.x() == fall.source().x() && c.y() == fall.source().y() && c.z() == fall.source().z()), + "source chunk must receive its lava cell"); + } + check(plan.flowSources().size() == 2 && plan.flowBounds().size() == 2, "flow metadata must cover every source"); + + // The shelf extends well beyond the three-block lava reach. A water-like reach of seven + // would incorrectly predict a spill over its edge, rather than the small retained puddle. + CavernHydrology.Sampler shelf = (x, y, z) -> { + boolean upperWall = x <= -32 && x >= -176 && Math.abs(z) <= 152 && y >= 120 && y <= 200; + boolean lowerShelf = x >= -216 && x <= -24 && Math.abs(z) <= 216 && y == 100; + return upperWall || lowerShelf ? 1 : -1; + }; + var shelfFalls = CavernLavaFlows.create(42, shelf, water, empty); + check(!shelfFalls.isEmpty(), "reference shelf must receive lava"); + for (var fall : shelfFalls) { + validate(fall, shelf); + check(fall.flowBounds().minY() == 101, "lava must stop on a wide natural shelf within reach three"); + } + check(CavernLavaFlows.HORIZONTAL_REACH == 3, "non-ultrawarm lava reach"); + lowerPocketPriority(water); + System.out.println("CavernLavaSmoke: seeded deep rock sources, existing supports, chunk ownership and three-block lava reach passed."); + } + + private static void lowerPocketPriority(CavernHydrology.Plan water) { + CavernHydrology.Sampler layeredCliff = (x, y, z) -> { + if (Math.hypot(x, z) >= 220 || y < 64) return -1; + if (x <= 0) return y <= 260 ? 1 : -1; + return y <= 120 || y >= 200 && y <= 220 ? 1 : -1; + }; + var plan = CavernLavaDeposit.create(0, layeredCliff, water); + var entry = plan.access().orElseThrow(() -> new AssertionError("reference cave should contain a pocket")); + check(entry.y() == 121, "lower cave pocket must take priority over the available upper ledge"); + check(layeredCliff.sample(entry.x(), 220, entry.z()) > 0, "lower access must retain its natural ceiling"); + check(plan.lavaCells().size() - plan.falls().size() == 18, "lower pocket keeps exactly eighteen sources"); + for (int y = entry.y(); y < entry.y() + 3; y++) check(layeredCliff.sample(entry.x(), y, entry.z()) <= 0, + "lower pocket entrance must have three blocks of natural headroom"); + for (int y = entry.y() - 3; y < entry.y(); y++) check(layeredCliff.sample(entry.x(), y, entry.z()) > 0, + "lower pocket entrance must stand on three natural blocks"); + var changed = new HashSet(); + for (var cell : plan.cells()) { + check(layeredCliff.sample(cell.x(), cell.y(), cell.z()) > 0, "lower chamber only removes existing rock"); + changed.add(new CavernLavaDeposit.Position(cell.x(), cell.y(), cell.z())); + } + for (var support : plan.supports()) { + check(layeredCliff.sample(support.x(), support.y(), support.z()) > 0 && !changed.contains(support), + "lower pocket shell must remain natural and unchanged"); + } + CavernHydrology.Sampler upperOnly = (x, y, z) -> Math.hypot(x, z) < 220 + && y >= 100 && y <= (x <= 0 ? 240 : 200) ? 1 : -1; + var fallback = CavernLavaDeposit.create(0, upperOnly, water); + check(fallback.access().orElseThrow().y() == 201, "existing upper ledge remains a fallback when no lower site fits"); + check(fallback.lavaCells().size() - fallback.falls().size() == 18, "fallback keeps its original pocket volume"); + } + + private static void validate(CavernLavaFlows.Fall fall, CavernHydrology.Sampler sampler) { + check(fall.source().y() >= 40 && fall.source().y() <= 160, "source altitude"); + check(sampler.sample(fall.source().x(), fall.source().y(), fall.source().z()) > 0, "rock source"); + check(sampler.sample(fall.outlet().x(), fall.outlet().y(), fall.outlet().z()) <= 0, "naturally open outlet"); + var path = fall.flowPath(); + check(path.getFirst().equals(fall.source()) && path.get(1).equals(fall.outlet()), "explicit source/outlet path"); + check(fall.source().y() - path.getLast().y() >= 12, "genuine falling path"); + for (int i = 1; i < path.size(); i++) { + var a = path.get(i - 1); var b = path.get(i); + check(Math.abs(a.x() - b.x()) + Math.abs(a.y() - b.y()) + Math.abs(a.z() - b.z()) == 1, + "flow path must use adjacent voxels"); + check(b.y() <= a.y(), "lava cannot ascend"); + check(sampler.sample(b.x(), b.y(), b.z()) <= 0, "flow must stay outside natural rock"); + var bounds = fall.flowBounds(); + check(b.x() >= bounds.minX() && b.x() <= bounds.maxX() && b.y() >= bounds.minY() + && b.y() <= bounds.maxY() && b.z() >= bounds.minZ() && b.z() <= bounds.maxZ(), "path within declared bounds"); + } + } + + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernPaletteSmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernPaletteSmoke.java new file mode 100644 index 0000000..84102b2 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/CavernPaletteSmoke.java @@ -0,0 +1,36 @@ +package fr.koka.sanctuary.worldgen; + +/** Pure climate invariants; actual decor and abundance are measured in engine diagnostics. */ +public final class CavernPaletteSmoke { + public static void main(String[] args) { + int oldSulfur = 0, newSulfur = 0, lush = 0, dripstone = 0; + for (int y : new int[]{64, 112, 160, 176, 184, 216, 260}) { + for (int v = -100; v <= 100; v += 2) for (int m = -100; m <= 100; m += 2) { + float variation = v / 100F, moisture = m / 100F; + var old = RiftBiomePalette.select(y, variation, moisture); + var current = CavernBiomePalette.select(y, variation, moisture); + if (old == RiftBiomePalette.Kind.SULFUR_DEPTHS) oldSulfur++; + if (current == CavernBiomePalette.Kind.SULFUR_DEPTHS) { + newSulfur++; + require(y < 176, "Sulfur must remain below the upper woodlands"); + } + if (current == CavernBiomePalette.Kind.LUSH_CAVES) lush++; + if (current == CavernBiomePalette.Kind.DRIPSTONE_CAVES) dripstone++; + if (y >= 184 && old != RiftBiomePalette.Kind.SULFUR_DEPTHS) + require(current.name().equals(old.name()), "Existing upper forest climate must remain unchanged"); + } + } + require(newSulfur > 0 && newSulfur * 8 < oldSulfur, "The broad sulfur province must become localized pockets"); + require(lush > 0 && dripstone > 0, "Both lower cave climates must have a real selection domain"); + require(CavernBiomePalette.select(112, 0.1F, 0.5F) == CavernBiomePalette.Kind.LUSH_CAVES, + "Moist lower cavities must select lush caves"); + require(CavernBiomePalette.select(112, -0.1F, -0.5F) == CavernBiomePalette.Kind.DRIPSTONE_CAVES, + "Drier lower cavities must select dripstone caves"); + System.out.println("CavernPaletteSmoke passed: sulfur climate cells " + oldSulfur + " -> " + newSulfur + + "; lush=" + lush + ", dripstone=" + dripstone + " (uniform climate grid, not island abundance)"); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/packwiz/README.md b/packwiz/README.md index 3768f88..f569fa2 100644 --- a/packwiz/README.md +++ b/packwiz/README.md @@ -51,11 +51,14 @@ 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.7.mrpack +packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.9.mrpack ``` L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API. Il ne publie rien sur Modrinth. +L’alpha.9 reste en préparation : produire cet export après la réussite du +build de cette version, sans réimporter une nouvelle instance pour mettre +à jour l’instance Sanctuary Beta existante. Utiliser l'URL HTTP affichée de `pack.toml` dans packwiz-installer. Le serveur local doit rester actif pendant l'installation. Ce n'est pas une publication diff --git a/packwiz/pack.toml b/packwiz/pack.toml index a3f46e5..541f4f1 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.8" +version = "0.1.0-alpha.9" pack-format = "packwiz:1.1.0" [index]