diff --git a/CHANGELOG.md b/CHANGELOG.md index aef4aca..99a875f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.1.0-alpha.6 — 2026-09-08 + +- Palette tempérée plus douce, avec des couleurs communes pour l’herbe, le + feuillage et l’eau. Dappled Forest quitte l’île initiale et reste réservé + aux futurs continents ; les sauvegardes alpha.5 le conservent. +- Prairies fleuries et affleurements rocheux plus étendus, avec une répartition + des biomes selon l’altitude. Les corniches inférieures peuvent porter de la + mousse, des fougères, des fleurs et des petits arbustes. +- Sources recherchées aussi dans les parois des strates inférieures. Les + cascades peuvent rejoindre un palier ou descendre dans le vide. +- Retrait des courts ruisseaux à niveau constant : l’île conserve ses étangs + et petits lacs. La grande rivière avec source, chute et bassin reste à + développer ; elle n’est pas activée dans cette livraison. +- Nouvelle clé `sanctuary:sanctuary_layered`, avec les anciennes générations + conservées. Créer un **nouveau monde Sanctuary** pour cet incrément. + +Validation : dix tests serveur réussis sur chacune des graines `0`, `42` et +`8675309`, avec 1 800 ticks de fluides, contrôles des corniches et respect des +modifications ultérieures aux sources. `check build assemblePack` réussi. + ## 0.1.0-alpha.5 — 2026-09-08 - Plages plus larges et irrégulières, en nappes de sable, gravier et roche, avec diff --git a/README.md b/README.md index 93f5118..4f00052 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ décrits dans la vision ne sont pas encore implémentés. - Mod `sanctuary` indépendant et pack Sanctuary construit avec packwiz. - Preset **Sanctuary** sélectionnable à la création d'un monde, avec une île flottante finie, un terrain naturel et un extérieur vide. -- Île tempérée à dominante forestière : forêt, plaines, bouleaux, forêt fleurie - et Dappled Forest, avec leur végétation et leurs animaux vanilla. Les formes - de terrain reprennent le générateur 26.2, adapté à l'API 26.3. +- Île tempérée aux teintes douces : prairies fleuries, bosquets, mousse et + affleurements rocheux, avec des biomes qui varient aussi selon l’altitude. Les + formes de terrain reprennent le générateur 26.2, adapté à l'API 26.3. - Petits filons de charbon, fer, cuivre, or, redstone, lapis et diamant à des altitudes adaptées au terrain relevé. Les stocks se constatent après génération. - Spawn recherché sur le sol de l'île lors de la création. Les mondes ordinaires @@ -32,24 +32,26 @@ décrits dans la vision ne sont pas encore implémentés. - Nouveaux mondes sur 384 blocs (`Y=0..383`), île relevée de 64 blocs, dessous sculpté et nuages au-dessus du terrain. Les anciennes sauvegardes conservent leurs paramètres de génération. -- Étangs, petits lacs et cours d’eau calmes, plages plus larges et dépôts de +- Étangs, petits lacs, plages plus larges et dépôts de trois à cinq blocs de sable, gravier, pierre ou terre enherbée. La canne à sucre apparaît lorsque les rives respectent ses règles vanilla. -- Jusqu’à trois sources dans des niches rocheuses naturelles : Minecraft fait - couler leur eau en cascades, qui peuvent descendre jusque dans le vide. +- Les courts ruisseaux imposés sont retirés ; l’île garde ses étangs et petits + lacs. La grande rivière traversante reste à développer. +- Quelques sources dans des niches rocheuses naturelles, y compris aux étages + inférieurs : Minecraft fait couler leur eau en cascades, qui peuvent descendre jusque dans le vide. - Une petite poche de lave couverte, accessible depuis une corniche naturelle, peut fournir la matière nécessaire à l’obsidienne lorsque le relief convient. L'île a une emprise nominale de 512 blocs de diamètre, avec une bordure sculptée par des bruits liés à la seed. Aucune masse centrale n'est ajoutée pour forcer le terrain : le spawn cherche un emplacement naturel. Le catalogue -TerraMix, les continents déverrouillables, les grandes rivières, les océans, les ruines et +TerraMix, les continents déverrouillables, les océans, les ruines et les autres fonctionnalités restent à construire par tickets. -L’alpha.5 utilise de nouveaux paramètres de génération ; créer un **nouveau monde** -pour découvrir ses biomes, ses plages et ses sources. Les sauvegardes alpha.2, -alpha.3 et alpha.4 conservent leur génération. La validation de cet incrément -est suivie dans [Validation](docs/testing.md). +L’alpha.6 utilise de nouveaux paramètres de génération ; créer un **nouveau monde** +pour découvrir ses biomes par altitude et ses eaux. Les sauvegardes alpha.2 +à alpha.5 conservent leur génération. Dappled Forest est désormais réservé aux +futurs continents et reste présent dans les anciennes sauvegardes alpha.5. La validation de cet incrément est suivie dans [Validation](docs/testing.md). Le premier champ de recherche climatique est défini : nord froid, sud chaud, ouest sec et est humide, avec leurs combinaisons diagonales. Un outil de @@ -73,7 +75,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes. | Fabric API | 0.160.0+26.3 | | Fabric Loom | 1.17.20 | | Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 | -| Sanctuary / pack | 0.1.0-alpha.5 | +| Sanctuary / pack | 0.1.0-alpha.6 | Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise uniquement la bibliothèque standard et repère aussi une installation Python @@ -100,7 +102,7 @@ décrits dans [Validation](docs/testing.md). Résultats : -- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.5.jar` : mod à installer avec +- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.6.jar` : mod à installer avec Fabric API sur la version Minecraft indiquée. - `build/packwiz/` : pack de développement complet, avec le mod construit et l'index vérifié. Voir [Installation packwiz](packwiz/README.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e832702..34b0535 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,7 +9,9 @@ communautaire indépendant. Le dépôt ne redistribue pas le jeu. Les biomes de départ reprennent les configurations de forêt, plaine, forêt de bouleaux, forêt fleurie et Dappled Forest de Minecraft 26.3-pre-2, adaptées à -une île finie. Les règles des petits minerais sont adaptées à partir des +une île finie. Les variantes alpha.6 réutilisent les végétations vanilla pour +les prairies fleuries et les bosquets ; Dappled Forest reste disponible pour +les sauvegardes alpha.5. Les règles des petits minerais sont adaptées à partir des features vanilla de cette même version. Les références de blocs, végétation et minerais sont résolues par Minecraft ; leurs textures et modèles ne sont pas inclus. diff --git a/docs/backlog.md b/docs/backlog.md index fb05375..45269e1 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -134,6 +134,18 @@ vide. Le contrat et l’isolation des anciennes générations sont décrits dans [Génération](worldgen.md). Ce travail prépare les eaux des continents sans clore WG-06 : océans, grandes rivières et ouverture des continents restent à développer. +**Incrément île initiale — alpha.6 :** retrait des courts ruisseaux, conservation +des bassins, présence de roche élargie et sources de paroi dans les strates +inférieures. Les écoulements sont contrôlés dans le moteur et les anciennes +générations sont préservées. + +**Ticket suivant — grande rivière facultative :** chercher un long parcours qui +épouse l’île, avec source, chute et bassin, rives de sable, gravier et argile. +Le démontrer sur la vraie densité Minecraft puis après décoration et ticks de +fluide, avec une graine et des coordonnées reproductibles. Les essais du +prototype alpha.6 n’ont fourni aucun parcours retenu sur les graines de référence ; +ce prototype n’est pas distribué. Ne pas imposer de rivière si le relief ne s’y prête pas. + ### WG-07 — Introduire un premier biome distinct et une structure **Dépendance :** WG-06. @@ -156,6 +168,11 @@ reprise du catalogue TerraMix n’est encore fournie. La survie initiale doit permettre de produire et progresser au-delà du bois et de la pierre ; l’accès à toute la progression Minecraft n’est pas encore garanti pour chaque seed. +**Ticket palette — alpha.6 :** réserver les biomes très contrastés de la 26.3, +dont Dappled Forest, aux futurs continents. L’île combine prairies fleuries, +bosquets et zones rocheuses, avec des teintes cohérentes et une végétation +effective sur les corniches inférieures. Cela ne livre pas encore les continents. + ## Réserve de thèmes futurs Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable. diff --git a/docs/expansion.md b/docs/expansion.md index d4227e3..4b2d160 100644 --- a/docs/expansion.md +++ b/docs/expansion.md @@ -30,9 +30,11 @@ de sélection des biomes feront partie du ticket de génération des continents. Les ressources et cultures futures, notamment celles d'It's Alive !, pourront ainsi demander l'exploration de plusieurs climats. -À partir de l’alpha.5, l’île initiale utilise une mosaïque de cinq biomes -tempérés : forêt, plaine, boulaie, forêt fleurie et forêt diaprée (Dappled -Forest). La variation locale et l’humidité dépendent de la graine ; elles ne +L’alpha.5 proposait cinq biomes tempérés dont Dappled Forest. L’alpha.6 réserve +ce dernier aux futurs continents et donne à l’île une palette plus douce de +prairies, prairies fleuries, bosquets, corniches moussues et zones rocheuses. +La répartition tient compte de l’altitude, y compris dans les strates inférieures. +La variation locale et l’humidité dépendent de la graine ; elles ne constituent pas encore le champ de recherche directionnel des continents. ## Eau de surface et exploration @@ -40,7 +42,11 @@ constituent pas encore le champ de recherche directionnel des continents. L'île de départ doit pouvoir accueillir naturellement des étangs et lacs de surface, des berges propices à la canne à sucre et, lorsque le relief s'y prête, des rivières. L'eau s'inscrit dans les creux et les chemins du terrain existant. -Les bassins et cours d’eau calmes gardent un fond et des berges fermés. +Les bassins gardent un fond et des berges fermés. Une grande rivière reste +facultative : elle doit suivre le relief et former un parcours lisible avec +source, chute et bassin. Ses exutoires peuvent laisser couler l’eau dans le vide. +Sans parcours suffisamment long, l’île conserve seulement ses petits bassins. +L’alpha.6 livre cette variante à bassins seuls ; la grande rivière reste à développer. L’alpha.5 ajoute séparément quelques sources sortant d’une niche rocheuse naturelle : leurs cascades peuvent descendre dans le vide et servir à remonter verticalement. Cet écoulement est volontaire ; il ne doit pas entraîner des @@ -79,7 +85,7 @@ rapports de continents devront conserver leur propre graine, version, emprise et état de génération, afin d'additionner seulement des régions connues et disjointes lorsque le besoin économique sera défini. -## Ressources de départ — alpha.5 +## Ressources de départ — depuis l’alpha.5 L’île vise les premières étapes de survie, les outils, l’enchantement et la redstone. Les sept minerais (charbon, fer, cuivre, or, redstone, lapis et diamant) diff --git a/docs/packwiz.md b/docs/packwiz.md index 11ae3c5..96ccec5 100644 --- a/docs/packwiz.md +++ b/docs/packwiz.md @@ -19,7 +19,7 @@ Après les vérifications, le commit et le push de la branche du ticket, le scri de publication réalise les étapes de release et de canal ci-dessous : ```sh -python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.5.mrpack +python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.6.mrpack ``` Les options sont facultatives. Ce script **publie** sur le Git configuré dans @@ -38,7 +38,7 @@ La procédure complète, également utilisable manuellement : 3. Préparer les métadonnées de la livraison, avec l'URL exacte du futur JAR : ```sh - python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.5/sanctuary-0.1.0-alpha.5.jar + python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.6/sanctuary-0.1.0-alpha.6.jar ``` 4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce @@ -123,6 +123,10 @@ séparé : créer un nouveau monde Sanctuary pour les découvrir. Les détails d [Génération](worldgen.md). Les changements de version Minecraft ou de format de sauvegarde demandent leur propre ticket et contrat de migration. +L’alpha.6 utilise à son tour `sanctuary:sanctuary_layered` pour ses biomes par +altitude et sa nouvelle hydrologie. Créer un nouveau monde Sanctuary ; les +sauvegardes alpha.5 conservent notamment leur palette avec Dappled Forest. + Références officielles : [installation packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/), [commandes Prism](https://prismlauncher.org/wiki/help-pages/custom-commands/), [bootstrap v0.0.3](https://github.com/packwiz/packwiz-installer-bootstrap/releases/tag/v0.0.3). diff --git a/docs/testing.md b/docs/testing.md index dcca61a..fbfa6e7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -80,8 +80,8 @@ Les neuf tests Sanctuary vérifient : 5. Le nouveau monde et son bruit utilisent bien 384 blocs, à partir de Y=0, tandis que les anciens paramètres restent à 256 blocs. Les nuages sont au-dessus de l'île, à Y=352,33. -6. Aucun plancher solide n’apparaît sous l’île, à Y=0..15. Dans la génération - alpha.5, seules les eaux des cascades déclarées peuvent traverser cette marge. +6. Aucun plancher solide n’apparaît sous l’île, à Y=0..15. Dans les générations + alpha.5 et alpha.6, seules les eaux des cascades déclarées peuvent traverser cette marge. 7. Le relevé après génération compte exactement tous les états des blocs des chunks demandés, avec des totaux cohérents et une complétude explicite. Voir [Expansion et ressources](expansion.md) pour le mode complet facultatif. @@ -91,7 +91,7 @@ Les neuf tests Sanctuary vérifient : biomes et les minerais sont inspectés sur le terrain. Une réserve de lave présente conserve ses 18 sources, ses supports et son accès après simulation. 9. Les anciennes clés gardent leur traitement d’origine. Seuls les nouveaux - paramètres activent les rives, biomes et sources de l’alpha.5. + paramètres activent les biomes par altitude et les eaux de l’alpha.6. La recherche de spawn parcourt l'île finie, par anneaux de quatre blocs jusqu'à 288 blocs du centre. Elle examine d'abord la hauteur brute pour éviter de @@ -188,6 +188,88 @@ Pour cet essai visuel en monde de développement créatif, comparer la même vue Y=63 ni assombrissement d'altitude au passage de Y=32. Les brouillards de l'eau, de la météo et des effets d'aveuglement ou d'obscurité doivent rester présents. +## Contrôles alpha.6 + +`layeredHydrologySmoke` vérifie les bassins sans ruisseau de secours, les +supports des dépôts, leur déterminisme et les sources sur les faces des +strates inférieures. Les reliefs vides, trop minces ou percés ne sont pas comblés. +`layeredEcologySmoke` vérifie les sols et le dégagement des corniches, +l’exclusion du sommet et du vide, ainsi que la variation verticale des biomes. + +`LayeredHydrologyDiagnostics` inspecte les nouveaux paramètres et refuse +Dappled Forest dans leur palette. Il vérifie les couleurs communes, les biomes +des surfaces hautes et basses, la végétation réelle et les surfaces rocheuses. +Les plantes vanilla sur une corniche mince sont relevées séparément : elles +ne constituent pas une preuve des trois supports exigés par la nouvelle +décoration moussue. Leurs coordonnées et leurs supports sont exportés. + +Les tickets de toute l’emprise sont posés avant le chargement des chunks `FULL`. +Le test appelle ensuite `ServerLevel.waitForEntities(chunk, 0)` sur cette même +emprise : le serveur GameTest accélère ses ticks et un délai en ticks seul ne +prouve pas que les lectures asynchrones d’entités sont terminées. Le prédicat +`isPositionTickingWithEntitiesLoaded` et les témoins d’écoulement restent requis. + +Le diagnostic simule 1 800 ticks dans les chunks actifs. Les bassins doivent +retenir leur eau. Les écoulements extérieurs doivent être reliés à une source +rocheuse déclarée par des connexions d’eau réelles, sans eau orpheline ni voisin +horizontal en dehors de l’emprise inspectée. Les seuls sites de surface sont +des étangs et des lacs ; aucune rivière n’est activée dans cette livraison. +Les ouvertures des sources sont vérifiées après toute la décoration ; une +source bouchée par du lichen ne peut pas satisfaire le contrôle de descente. +Après ces mesures, un test de réentrée pose temporairement un lichen à une +bouche dont les marqueurs de génération ont été consommés. Un nouvel appel au +post-traitement doit le conserver ; le test restaure ensuite le bloc d’origine. +La preuve est dans `layered-hydrology-seed--postprocessing-reentry.json`. + +Les cartes `layered-hydrology-seed--*.png` montrent les surfaces, +les corniches et les profils de sources. Les JSON associés contiennent les +résultats et leur emprise. Ces lectures de blocs ne remplacent pas l’examen +du paysage dans le client. + +## Validation alpha.6 — 8 septembre 2026 + +Les dix tests serveur passent sur chacune des graines `0`, `42` et `8675309`, +dans des mondes neufs Minecraft 26.3-pre-2 avec Java 25, sur Apple M1 à 8 Go. +Chaque emprise est réellement active pendant 1 800 ticks mesurés. La commande +finale `check build assemblePack`, avec les exports de densité et le relevé +ressources général facultatifs désactivés, passe en **3 min 30 s** sur la graine 0. +Les six tests de forme, hydrologie, lave et écologie passent également. + +| Graine | Spawn naturel | Eau des bassins | Eau des cascades | Canne | Lave | Chunks FULL | Plantes sur corniches épaisses | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `0` | `(0, 252, 0)` | 1 533 | 414 | 20 | 18 | 151 | 11 021 | +| `42` | `(0, 249, 0)` | 1 376 | 913 | 4 | 18 | 149 | 6 334 | +| `8675309` | `(0, 247, 0)` | 1 590 | 2 350 | 10 | 18 | 160 | 9 532 | + +Ces nombres décrivent uniquement les chunks inspectés, après décoration et +simulation. Les plantes incluent la végétation vanilla et la nouvelle décoration, +sur des corniches avec trois supports naturels ; les plantes de corniches +plus minces sont comptées séparément. Les cinq variantes de biome sont observées +sur les surfaces inférieures, avec respectivement cinq, trois et deux changements +verticaux contrôlés. Les surfaces inspectées comportent 5 805, 4 765 et 10 045 +colonnes de pierre au sommet ; il ne s’agit pas d’un quota pour l’île entière. + +Sur la graine 0, les sources `(-112, 103, 0)` et `(-108, 227, -96)` atteignent +Y=0 ; celle de `(-110, 148, -46)` rejoint un palier à Y=130. La première traverse +une frontière de chunks. Sur la graine 42, la source `(-62, 151, -82)` dégage +correctement son ouverture autrefois obstruée par du lichen et descend de +49 blocs ; les deux autres descendent de 103 et 236 blocs. Les trois cascades +de la graine 8675309 atteignent Y=0. Tous les écoulements observés sont reliés +à leur source, sans eau orpheline ni dépassement horizontal de l’emprise. + +Les bassins conservent leur eau ; les sédiments et leurs supports résistent aux +ticks de gravité. Les trois niches de lave gardent leurs 18 sources, leur coque +et leur accès sec. Le test de réentrée après consommation des marqueurs réussit +sur les trois graines : une modification ultérieure de la bouche reste intacte. +Les 52 fichiers historiques de génération suivis pour cette livraison gardent +leur SHA-256 d’avant modification. + +Les cartes de surface, de corniches et les profils de cascade ont été examinés. +Ce sont des lectures de blocs ; le rendu dans le client reste à apprécier en +jeu. La grande rivière traversante n’est pas activée : cette livraison adopte +les petits bassins et les sources de paroi. Les ressources restent observées +après génération, sans garantie de toute la progression Minecraft pour chaque graine. + ## Validation alpha.5 — 8 septembre 2026 Les essais utilisent Minecraft 26.3-pre-2, Java 25 et le preset de production diff --git a/docs/vision.md b/docs/vision.md index f0040c7..47c780e 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -52,17 +52,27 @@ L'île de Sanctuary est le point de départ commun. Elle flotte dans un vide qui L'île initiale doit être **tempérée et légèrement humide**, avec quelques zones plus sèches, notamment de larges plages et dépôts irréguliers de sable, gravier, -pierre et terre enherbée. Une rivière et de petits étangs ou lacs de surface -peuvent accueillir de la canne à sucre. Le terrain est traité près de la surface, +pierre et terre enherbée. De petits étangs ou lacs de surface +peuvent accueillir de la canne à sucre. Lorsque le relief le permet, une grande +rivière suit la forme de l’île, depuis une source jusqu’à une cascade et un +bassin, avec des rives de sable, de gravier et d’argile. Ce parcours doit former +un lieu reconnaissable ; il reste facultatif. À défaut, l’île conserve ses +petits bassins, sans imposer de courts ruisseaux. Le terrain est traité près de la surface, avec des dépôts épais et des rives variées. De rares sources sortent de niches rocheuses naturelles et leurs cascades peuvent descendre jusque dans le vide ; -les plans d’eau restent retenus et ne doivent pas provoquer une inondation globale. Les grands bassins souterrains +les bassins restent retenus et ne doivent pas provoquer une inondation globale. +Une rivière peut avoir des échappements et des chutes déclarés. Les grands bassins souterrains remplis d'eau ne sont pas retenus pour cette île. Le terrain doit laisser voir un dessous sculpté et dégagé, dans un monde de **384 blocs de hauteur**, avec des nuages relevés et sans disque noir artificiel apparaissant sous un seuil d'altitude. -L’ambiance de départ doit rester principalement forestière, avec des clairières, -des bouleaux, des fleurs et Dappled Forest. Les plages s’inspirent des nappes de +L’ambiance de départ doit rester douce et tempérée, avec des bosquets, +des prairies fleuries et de larges zones rocheuses. Les biomes très contrastés +de Minecraft 26.3, dont Dappled Forest, sont réservés aux futurs continents. +Les biomes et la végétation varient aussi en hauteur : les corniches inférieures +peuvent porter des bosquets, de la mousse et des fleurs lorsque leur surface +s’y prête. Quelques sources de paroi et ruissellements relient ces étages. +Les plages s’inspirent des nappes de sable des générations Alpha/Beta : elles peuvent relier des poches d’eau, tandis que d’autres rives restent rocheuses ou enherbées. Les quelques cascades servent également d’ascenseurs d’eau pour circuler verticalement. diff --git a/docs/worldgen.md b/docs/worldgen.md index 171f1a4..e7473aa 100644 --- a/docs/worldgen.md +++ b/docs/worldgen.md @@ -35,7 +35,7 @@ de test et les sauvegardes personnelles doivent rester distincts. surplombs ou de se séparer en fragments. **Aucun noyau ellipsoïdal, cylindre ou plateforme de départ n'est ajouté.** Au-dessus de `Y=144`, le relief de la génération précédente est conservé dans la densité, simplement relevé de 64 - blocs. L’hydrologie v5 traite ensuite les lits et les rives près de la surface. + blocs. L’hydrologie traite ensuite les lits et les rives près de la surface. - Le pourtour est sculpté par retrait de matière à partir du rayon **128 blocs**. Le contour de référence de 256 blocs est déformé par les bruits, entre 204 et 288 blocs ; il indique la fin de l'érosion, pas un mur de roche. @@ -47,8 +47,9 @@ de test et les sauvegardes personnelles doivent rester distincts. Il n'y a ni plancher de bedrock, ni mer globale, ni reprise du terrain à distance. Le Nether et l'End restent ceux de Minecraft. -- Les nouveaux mondes utilisent cinq biomes tempérés distincts, à dominante - forestière, dont Dappled Forest. Leur végétation et leurs animaux proviennent +- Les nouveaux mondes utilisent une palette tempérée de prairies, de bosquets, + de corniches moussues et de zones rocheuses, répartie aussi en hauteur. + Dappled Forest est réservé aux futurs continents. Leur végétation et leurs animaux proviennent de Minecraft 26.3-pre-2. Les nouveaux identifiants ne reprennent pas les tags de structures vanilla, afin de ne pas placer leurs structures dans le vide. - Les carvers, géodes, donjons, grands lacs de lave et sources vanilla sont @@ -56,7 +57,7 @@ de test et les sauvegardes personnelles doivent rester distincts. rocheuses. Les arbres et les petits filons ont besoin du terrain existant. Aucun mod de biomes externe n’est nécessaire. -L’alpha.5 associe ces biomes aux plages, aux eaux et aux petits filons décrits +L’alpha.6 associe ces biomes aux plages, aux eaux et aux petits filons décrits ci-dessous. Les ressources de survie ne se limitent pas au bois et à la pierre, mais cet incrément ne garantit pas encore une partie Minecraft complète sur chaque seed. L’accès à chaque progression vanilla et les ressources renouvelables @@ -303,6 +304,63 @@ fluides dans les chunks des sites. Les cartes et profils PNG montrent des lectures de blocs, pas un rendu Minecraft. La descente des cascades est contrôlée après activation effective des chunks qui les simulent. +## Prairies et corniches — génération v6 + +L’alpha.6 utilise `sanctuary:sanctuary_layered` et le biome source +`sanctuary:layered_island`. Les formes et le ciel restent ceux de la génération +relevée. Les anciennes classes, biomes et règles de matériaux restent disponibles +pour les sauvegardes alpha.2 à alpha.5 ; seul le preset de création choisit v6. + +### Une palette qui varie aussi en hauteur + +Les cinq variantes partagent leurs teintes d’herbe et de feuillage. Les champs +de climat liés à la seed déforment de larges bandes d’altitude, autour de Y154 +et Y214 : prairies et prairies fleuries sur le dessus, bosquets et corniches +moussues plus bas. Des zones rocheuses traversent ces bandes et remplacent la +couverture de terre par de la pierre sur le relief existant. Les prairies gardent +quelques arbres épars et leur végétation vanilla. Dappled Forest n’appartient +plus à cette palette ; son utilisation sur de futurs continents reste à développer. + +| Identifiant | Ambiance | +| --- | --- | +| `sanctuary:layered_meadow` | Prairie tempérée | +| `sanctuary:layered_flower_meadow` | Prairie fleurie | +| `sanctuary:layered_lower_grove` | Bosquet inférieur | +| `sanctuary:layered_mossy_terraces` | Corniches moussues | +| `sanctuary:layered_rocky_terraces` | Affleurements rocheux | + +`LowerTerraceVegetationFeature` parcourt aussi les sols naturels sous les +surplombs, au moins huit blocs sous une surface supérieure. Elle demande trois +blocs pleins de support et deux blocs libres au-dessus. Elle peut remplacer la +surface existante par de la mousse et planter fougères, fleurs ou petits arbustes +lorsque leurs règles de survie le permettent. Les cellules hydrologiques, les +sources, les parcours d’eau prévus et les supports de lave sont protégés. +Il n’y a ni corniche construite dans l’air ni ajout d’éclairage artificiel. + +### Bassins et sources de paroi + +`LayeredHydrology` conserve les petits bassins et les dépôts épais, avec une +plus grande présence de pierre. Le court `STREAM` à niveau constant de l’alpha.5 +n’est pas repris. Cette version retient uniquement les étangs et les petits +lacs ; elle ne cherche pas de grande rivière. Le parcours traversant avec +source, cascade et bassin reste un ticket distinct, à démontrer sur le relief +Minecraft réel. Aucun chenal de secours n’est creusé. + +La profondeur reste limitée à trois blocs et l’incision à quatre. Le plan +vérifie le fond et les rives avant d’appliquer ses écritures, dans les nouveaux +chunks seulement. Le sable, le gravier, l’argile et la roche suivent les rives. +Des niches de source sont également recherchées dans les strates inférieures, +et pas seulement près de la surface la plus haute. Leurs chutes sont produites +par les ticks de Minecraft à partir d’un seul bloc source. Le post-traitement +des nouveaux chunks libère uniquement leur ouverture, déjà vide dans la densité +naturelle, pour qu’un lichen ou une plante ne bouche pas la source. Les positions +sont marquées dans la file vanilla pendant la génération, puis consommées une +seule fois. Un rechargement ne dégage pas une bouche modifiée par un joueur. +Aucune chute n’est préremplie et aucun nouveau passage n’est creusé dans la roche. + +Les parcours, les écoulements et la végétation réellement observés, ainsi que +les limites du modèle, sont consignés dans [Validation](testing.md). + ## Apparition commune Une injection limitée à la création initiale cherche du sol naturel depuis @@ -312,7 +370,8 @@ l'absence d'une telle surface, elle retient une colonne de sol praticable ; si aucun emplacement naturel sûr n'existe, la création signale la seed concernée plutôt que d'ajouter une plateforme. Elle ne s'applique qu'à l'Overworld utilisant les paramètres `sanctuary:sanctuary`, `sanctuary:sanctuary_384` ou -`sanctuary:sanctuary_hydrology` ou `sanctuary:sanctuary_natural`. +`sanctuary:sanctuary_hydrology`, `sanctuary:sanctuary_natural` ou +`sanctuary:sanctuary_layered`. Elle enregistre le spawn partagé dans la sauvegarde et ne le réinitialise pas au redémarrage : les modifications administratives de `/setworldspawn` restent conservées. Les lits et la dispersion habituelle du spawn suivent encore les diff --git a/gradle.properties b/gradle.properties index cc92e97..b2f246d 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.5 -pack_version=0.1.0-alpha.5 +mod_version=0.1.0-alpha.6 +pack_version=0.1.0-alpha.6 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index d212d79..78cc373 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -109,3 +109,24 @@ tasks.register('starterLavaSmoke', JavaExec) { } tasks.named('check') { dependsOn('starterLavaSmoke') } + +// Alpha.6 has separate contracts so saved alpha.5 worlds keep their generation. +tasks.register('layeredHydrologySmoke', JavaExec) { + group = 'verification' + description = 'Check retained basins, broad rock shores and lower wall springs.' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.LayeredHydrologySmoke' +} + +tasks.named('check') { dependsOn('layeredHydrologySmoke') } + +tasks.register('layeredEcologySmoke', JavaExec) { + group = 'verification' + description = 'Check altitude biome variation and naturally supported lower terraces.' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.LayeredEcologySmoke' +} + +tasks.named('check') { dependsOn('layeredEcologySmoke') } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/LayeredHydrologyDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/LayeredHydrologyDiagnostics.java new file mode 100644 index 0000000..9a1eb0a --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/LayeredHydrologyDiagnostics.java @@ -0,0 +1,1308 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.HydrologyRuntime; +import fr.koka.sanctuary.worldgen.SanctuarySpawn; +import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature; +import fr.koka.sanctuary.worldgen.LayeredHydrology; +import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; +import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime; +import fr.koka.sanctuary.worldgen.LayeredLavaDeposit; +import fr.koka.sanctuary.worldgen.LayeredIslandBiomeSource; +import fr.koka.sanctuary.worldgen.LowerTerraceVegetationFeature; +import net.minecraft.core.registries.BuiltInRegistries; +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.EnumSet; +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.block.Blocks; +import net.minecraft.world.level.block.MultifaceBlock; +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; + +/** Checks the actual decorated terrain and fluid simulation in a disposable world, far from test fixtures. */ +public final class LayeredHydrologyDiagnostics { + private static final List HORIZONTAL = List.of(Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST); + + private LayeredHydrologyDiagnostics() {} + + public static void start(GameTestHelper helper) throws IOException { + Context context = new Context(helper); + try { + context.prepare(); + context.verifyNaturalShellAndFinishedWater(); + context.verifyAllObservedWaterIsPlanned(); + context.verifyBiomesAndResources(); + 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(); + } 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(LayeredHydrologyRuntime.enabled(active), "The new preset must enable alpha.6 hydrology"); + helper.assertFalse(HydrologyRuntime.enabled(active) || NaturalHydrologyRuntime.enabled(active), + "Older hydrology applications must never run together with alpha.6"); + helper.assertTrue(active.generatorSettings().is(SanctuarySpawn.LAYERED_SETTINGS), + "The new generation needs its own settings key to preserve saved worlds"); + var registry = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); + for (var key : List.of(SanctuarySpawn.SETTINGS, SanctuarySpawn.RAISED_SETTINGS, SanctuarySpawn.HYDROLOGY_SETTINGS, SanctuarySpawn.NATURAL_SETTINGS)) { + var legacy = new NoiseBasedChunkGenerator(active.getBiomeSource(), registry.getOrThrow(key)); + helper.assertFalse(LayeredHydrologyRuntime.enabled(legacy), "Saved settings must never acquire alpha.6 terrain: " + key.identifier()); + helper.assertTrue(HydrologyRuntime.enabled(legacy) == key.equals(SanctuarySpawn.HYDROLOGY_SETTINGS), + "Only alpha.4 settings keep alpha.4 hydrology; alpha.2/3 remain dry"); + helper.assertTrue(NaturalHydrologyRuntime.enabled(legacy) == key.equals(SanctuarySpawn.NATURAL_SETTINGS), + "Only saved alpha.5 settings keep alpha.5 hydrology"); + helper.assertFalse(new LowerTerraceVegetationFeature().place(level, legacy, RandomSource.create(0), BlockPos.ZERO), + "Lower-terrace decoration must refuse every saved generation key"); + } + var biomes = level.registryAccess().lookupOrThrow(Registries.BIOME); + var shore = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE).getOrThrow( + ResourceKey.create(Registries.PLACED_FEATURE, SanctuaryMod.id("shore_sugar_cane"))).value(); + var starter = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest"))).value(); + var wet = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest_hydrology"))).value(); + helper.assertFalse(starter.getGenerationSettings().hasFeature(shore), "The legacy dry biome keeps its original features"); + helper.assertTrue(wet.getGenerationSettings().hasFeature(shore), "The saved alpha.4 biome keeps its shoreline decoration"); + helper.assertTrue(active.getBiomeSource() instanceof LayeredIslandBiomeSource, + "The active preset must use the seeded temperate mosaic"); + Set expected = Set.of("sanctuary:layered_meadow", "sanctuary:layered_flower_meadow", "sanctuary:layered_lower_grove", + "sanctuary:layered_mossy_terraces", "sanctuary:layered_rocky_terraces"); + helper.assertTrue(active.getBiomeSource().possibleBiomes().stream().map(holder -> holder.getRegisteredName()) + .collect(java.util.stream.Collectors.toSet()).equals(expected), + "The new island palette has five layered variants and excludes Dappled Forest"); + } + + /** 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(LayeredHydrology.Plan plan, int x, int z) { + int cx = x >> 4, cz = z >> 4; + return plan.springs().stream().map(LayeredHydrology.Spring::flowBounds).anyMatch(bounds -> { + return 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, LayeredHydrology.FlowBounds actualBounds, + int outsideDensityPredictionVoxelCount, LayeredHydrology.FlowBounds outsideDensityVoxelActualBounds, + int outsideDensityPredictionBoundingBoxCount, LayeredHydrology.FlowBounds outsideDensityBoundingBoxActualBounds, + int horizontalNeighboursOutsideInspection, boolean reachesDimensionFloor) {} + + 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); + } + + private static boolean isTerrain(BlockState state) { + return state.is(Blocks.STONE) || 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(BlockTags.BASE_STONE_OVERWORLD); + } + + private static final class Context { + final GameTestHelper helper; + final ServerLevel level; + final LayeredHydrology.Plan plan; + final LayeredLavaDeposit.Plan lavaPlan; + final Set expectedLava = new HashSet<>(); + final RandomState random; + final DensityFunction natural; + final Map cells = new HashMap<>(); + 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<>(); + 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(LayeredHydrologyRuntime.enabled(generator), "Hydrology test requires the production wet preset"); + plan = LayeredHydrologyRuntime.layeredPlan(level); + lavaPlan = LayeredHydrologyRuntime.lavaPlan(level); + random = level.getChunkSource().randomState(); + natural = generator.generatorSettings().value().noiseRouter().finalDensity(); + } + + void prepare() { + helper.assertTrue(!plan.features().isEmpty(), "Regression seed must exercise real surface water"); + for (var feature : plan.features()) { + SanctuaryMod.LOGGER.info("Hydrology planned site: seed {}, {}, Y{}, start {}, {} water columns, {} path points", + level.getSeed(), feature.kind(), feature.waterY(), feature.path().getFirst(), feature.waterCells(), feature.path().size()); + } + helper.assertTrue(plan.features().stream().allMatch(feature -> feature.kind() == LayeredHydrology.Kind.POND + || feature.kind() == LayeredHydrology.Kind.LAKE), + "The delivered generation may contain only surface ponds and lakes, without imposed short streams"); + 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(cells.put(ChunkPos.pack(cell.x(), cell.z()), cell) == null, + "A plan must own each horizontal column exactly once"); + if (cell.hasWater()) { + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + 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())); + 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); + 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); + captureNaturalLogObstructions(); + for (var spring : plan.springs()) flowRoots.add(new FlowRoot(spring.id(), "rock_spring", spring.id(), position(spring.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); + } + + 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.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() <= LayeredHydrology.MAX_DEPTH, + "A surface basin must keep the declared shallow water depth"); + helper.assertTrue(cell.carveTop() - cell.bedY() <= LayeredHydrology.MAX_CARVE, + "Hydrology must not excavate a deep artificial chamber beneath the surface"); + for (int depth = 0; depth < 3; depth++) { + int y = cell.bedY() - depth; + helper.assertTrue(sample(cell.x(), y, cell.z()) > 0, + "Water must retain three blocks of natural floor, without adding a liner at " + cell.x() + "," + y + "," + cell.z()); + BlockPos floor = new BlockPos(cell.x(), y, cell.z()); + helper.assertTrue(level.getBlockState(floor).isCollisionShapeFullBlock(level, floor), + "Generated water floor must remain solid after decoration and fluid ticks: " + floor); + } + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + BlockPos water = new BlockPos(cell.x(), y, cell.z()); + 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)) continue; + helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) > 0, + "Every external underwater side must be supported by natural terrain: " + side); + helper.assertTrue(level.getBlockState(side).isCollisionShapeFullBlock(level, side), + "A decorated shore must not leave a hole in the three-dimensional water shell: " + side); + } + } + // The original density must have no roof above the explicitly carved surface. + // Leaves and branches from neighbouring trees may shade the water afterwards. + for (int y = cell.carveTop() + 1; y <= level.getMaxY(); y++) { + helper.assertTrue(sample(cell.x(), y, cell.z()) <= 0, + "A surface basin must not be a flooded underground chamber: " + cell.x() + "," + y + "," + cell.z()); + } + } + } + + 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 LayeredHydrology.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); + } + } + + LayeredHydrology.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 LayeredHydrology.FlowBounds(minX, minY, minZ, maxX, maxY, maxZ); + } + + boolean isWithinDensityPredictionBounds(BlockPos p) { + return plan.springs().stream().map(LayeredHydrology.Spring::flowBounds).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(); + }); + } + + void verifySedimentProfiles() { + Set dryMaterials = EnumSet.noneOf(LayeredHydrology.ShoreMaterial.class); + Set depths = new TreeSet<>(); + List wet = plan.cells().stream().filter(LayeredHydrology.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() ? LayeredHydrology.MAX_CARVE : 2; + helper.assertTrue(cell.carveTop() - cell.bedY() <= allowedCut, + "Surface treatment must stay shallow rather than cutting a trench: " + 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; + boolean materialMatches = switch (cell.material()) { + case SAND -> state.is(Blocks.SAND); + case GRAVEL -> state.is(Blocks.GRAVEL); + case CLAY -> state.is(Blocks.CLAY); + case GRASS -> d == 0 ? state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.DIRT) + : state.is(Blocks.DIRT); + // Vanilla rock/ore features can subsequently replace painted stone. + case STONE -> true; + }; + helper.assertTrue(materialMatches, "The complete sediment profile must survive decoration: " + p + + " planned=" + cell.material() + " layer=" + d + " actual=" + state); + } + if (!cell.hasWater()) { + dryMaterials.add(cell.material()); + 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(dryMaterials.contains(LayeredHydrology.ShoreMaterial.SAND) + && dryMaterials.contains(LayeredHydrology.ShoreMaterial.GRAVEL) && dryMaterials.size() >= 3, + "Reference terrain must demonstrate sand, gravel and a third natural shore type"); + 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, + "Spring outlet must be naturally open, without tunnelling an artificial outlet: " + 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 { + if (plan.springs().isEmpty()) return; + var spring = plan.springs().getFirst(); + BlockPos outlet = position(spring.outlet()); + LevelChunk chunk = chunks.get(ChunkPos.pack(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 spring 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); + Path report = Path.of("diagnostics", "layered-hydrology-seed-" + level.getSeed() + "-postprocessing-reentry.json"); + Files.writeString(report, new GsonBuilder().setPrettyPrinting().create().toJson(Map.of( + "seed", level.getSeed(), "spring_id", spring.id(), "outlet", spring.outlet(), "passed", true, + "generation_markers_present", false, "temporary_state", obstruction.toString(), + "restored_state", original.toString(), "scope", "Re-entry after completed generation preserves later edits; test world only")) + "\n"); + SanctuaryMod.LOGGER.info("Spring post-processing re-entry preserved a later edit without markers, seed {}, mouth {}", level.getSeed(), outlet); + } + + 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(); + int predictedDrop = spring.source().y() - spring.flowPath().stream() + .mapToInt(LayeredHydrology.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<>(); + for (int radius = 0; radius <= 208 && (biomeTargets.size() < 5 || lowerTerraceTargets.size() < 6); 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, z); + if (surfaces.isEmpty()) continue; + for (int y : surfaces) { + String biome = resolver.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2).getRegisteredName(); + if (!biomeTargets.containsKey(biome)) { + biomeTargets.put(biome, new BlockPos(x, y + 1, z)); + requested.add(ChunkPos.pack(x >> 4, z >> 4)); + } + long chunk = ChunkPos.pack(x >> 4, z >> 4); + if (surfaces.getFirst() - y >= 8 && lowerTerraceTargets.size() < 6 && extraLowerChunks.add(chunk)) { + lowerTerraceTargets.add(new BlockPos(x, y + 1, z)); + requested.add(chunk); + } + } + } + } + } + } + + 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) { + int y = chunk.getHeight(Heightmap.Types.OCEAN_FLOOR, x, z); + if (y < level.getMinY()) continue; + String id = chunk.getNoiseBiome(x >> 2, y >> 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) || state.is(Blocks.STONE) + || state.is(Blocks.WATER) || state.is(Blocks.LAVA) || state.is(Blocks.SUGAR_CANE)) { + resourceCounts.merge(id, count, Long::sum); + } + }); + verifyLowerVegetationAndPalette(); + SanctuaryMod.LOGGER.info("Layered hydrology 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")) { + 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(LayeredHydrologyDiagnostics::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++) { + for (int y = chunk.getHeight(Heightmap.Types.OCEAN_FLOOR, x, z); y >= 24; y--) { + BlockPos p = new BlockPos(x, y, z); + BlockState state = chunk.getBlockState(p); + if (!isTerrain(state)) continue; + surfaceMaterials.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1, Integer::sum); + break; + } + } + } + } + SanctuaryMod.LOGGER.info("Layered 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(lowerPlantsOnMoss > 0, "Reference lower vegetation must demonstrate real plants rooted in a moss-covered natural ledge"); + helper.assertTrue(upperFlowers > 0, "Reference upper meadows must demonstrate actual flowers on the natural surface"); + 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"); + } + } + + 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(LayeredHydrology.Position p) { return new BlockPos(p.x(), p.y(), p.z()); } + + void verifyStarterLava() { + if (!expectedLava.isEmpty()) { + helper.assertTrue(expectedLava.size() == 18, "A selected starter niche contains eighteen source blocks, not a large lava lake"); + helper.assertTrue(lavaPlan.access().isPresent(), "Selected lava needs a reachable dry approach"); + } + for (BlockPos p : expectedLava) { + helper.assertTrue(level.getFluidState(p).is(FluidTags.LAVA) && level.getFluidState(p).isSource(), + "The small lava deposit must retain every planned source after real ticks: " + p); + for (Direction direction : Direction.values()) { + if (direction == Direction.UP) continue; + BlockPos side = p.relative(direction); + if (expectedLava.contains(side)) continue; + helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) > 0 + && level.getBlockState(side).isCollisionShapeFullBlock(level, side), + "Lava needs a complete natural floor and side shell, including chunk boundaries: " + side); + } + } + for (var support : lavaPlan.supports()) { + BlockPos p = new BlockPos(support.x(), support.y(), support.z()); + 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 = new BlockPos(access.x(), access.y(), access.z()); + helper.assertTrue(level.getBlockState(p).isAir() && level.getBlockState(p.above()).isAir() + && level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below()), + "The lava access requires a stable floor and two dry free blocks: " + p); + }); + int[] observed = {0}; + 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 surrounding decoration: " + p); + helper.assertTrue(expectedLava.contains(p), "No uncontrolled lava may leave the declared niche: " + p); + observed[0]++; + }); + helper.assertTrue(observed[0] == expectedLava.size(), "Observed lava must exactly match the small declared niche"); + } + + 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(); + 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(); + verifyLowerPlantsStillSurvive(); + 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); + } + for (BlockPos water : expectedWater) level.scheduleTick(water, Fluids.WATER, 1); + for (FlowRoot root : flowRoots) level.scheduleTick(root.position(), Fluids.WATER, 1); + for (BlockPos lava : expectedLava) level.scheduleTick(lava, Fluids.LAVA, 1); + 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(LayeredHydrology.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 = "layered-hydrology-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<>(); + 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 (state.is(Blocks.SUGAR_CANE)) visibleBlocks[3]++; + }); + analyseActualFlow(actualFlow); + 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("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_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", 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("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); + for (var spring : plan.springs()) exportSpringProfile(directory.resolve(stem + "-spring-" + spring.id() + ".png"), spring, phase); + } + + 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.isAir()) return 0x142738; + int value = 40 + Math.clamp(y, 0, level.getHeight()) * 180 / level.getHeight(); + return value << 16 | value << 8 | value; + } + + 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, LayeredHydrology.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 0f73b46..9bc9a00 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 @@ -2,6 +2,7 @@ package fr.koka.sanctuary.gametest; import fr.koka.sanctuary.worldgen.SanctuarySpawn; import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime; +import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; import net.minecraft.tags.FluidTags; import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; import fr.koka.sanctuary.worldgen.IslandShape; @@ -29,14 +30,16 @@ public final class SanctuaryWorldGameTests { @GameTest(maxTicks = 3000) public void generatedSurfaceWaterRemainsContained(GameTestHelper helper) throws java.io.IOException { var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); - if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.start(helper); + if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.start(helper); + else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.start(helper); else HydrologyDiagnostics.start(helper); } @GameTest(maxTicks = 200) public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) { var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); - if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.verifyLegacyIsolation(helper); + if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.verifyLegacyIsolation(helper); + else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.verifyLegacyIsolation(helper); else HydrologyDiagnostics.verifyLegacyIsolation(helper); helper.succeed(); } @@ -114,16 +117,17 @@ public final class SanctuaryWorldGameTests { helper.assertTrue(chunk != null, "The lower-margin test needs a fully generated chunk"); if (chunk.getSection(0).hasOnlyAir()) continue; var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); - helper.assertTrue(NaturalHydrologyRuntime.enabled(generator), + helper.assertTrue(NaturalHydrologyRuntime.enabled(generator) || LayeredHydrologyRuntime.enabled(generator), "The legacy island must leave its lower margin entirely empty"); - var plan = NaturalHydrologyRuntime.naturalPlan(helper.getLevel()); for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) { for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) { for (int y = 0; y < 16; y++) { BlockPos p = new BlockPos(x, y, z); var state = chunk.getBlockState(p); helper.assertTrue(state.isAir() || state.getFluidState().is(FluidTags.WATER) - && NaturalHydrologyDiagnostics.isWithinSpringInspectionFootprint(plan, x, z), + && (LayeredHydrologyRuntime.enabled(generator) + ? LayeredHydrologyDiagnostics.isWithinSpringInspectionFootprint(LayeredHydrologyRuntime.layeredPlan(helper.getLevel()), x, z) + : NaturalHydrologyDiagnostics.isWithinSpringInspectionFootprint(NaturalHydrologyRuntime.naturalPlan(helper.getLevel()), x, z)), "The lower margin has no artificial floor; water must lie in the finite spring inspection footprint, with provenance checked by the full fluid diagnostic: " + p); } } 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 8a18814..aec4cf4 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -3,6 +3,9 @@ package fr.koka.sanctuary; import fr.koka.sanctuary.worldgen.MainIslandDensity; import fr.koka.sanctuary.worldgen.NaturalShoreSugarCaneFeature; import fr.koka.sanctuary.worldgen.TemperateIslandBiomeSource; +import fr.koka.sanctuary.worldgen.LayeredIslandBiomeSource; +import fr.koka.sanctuary.worldgen.LayeredShoreSugarCaneFeature; +import fr.koka.sanctuary.worldgen.LowerTerraceVegetationFeature; import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature; import net.fabricmc.api.ModInitializer; import net.minecraft.core.Registry; @@ -25,6 +28,9 @@ public final class SanctuaryMod implements ModInitializer { Registry.register(BuiltInRegistries.FEATURE_TYPE, id("shore_sugar_cane"), ShoreSugarCaneFeature.CODEC); Registry.register(BuiltInRegistries.FEATURE_TYPE, id("natural_shore_sugar_cane"), NaturalShoreSugarCaneFeature.CODEC); Registry.register(BuiltInRegistries.BIOME_SOURCE, id("temperate_island"), TemperateIslandBiomeSource.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("layered_shore_sugar_cane"), LayeredShoreSugarCaneFeature.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("lower_terrace_vegetation"), LowerTerraceVegetationFeature.CODEC); + Registry.register(BuiltInRegistries.BIOME_SOURCE, id("layered_island"), LayeredIslandBiomeSource.CODEC); LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available."); } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/LayeredSpringOutletMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/LayeredSpringOutletMixin.java new file mode 100644 index 0000000..fba969f --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/LayeredSpringOutletMixin.java @@ -0,0 +1,29 @@ +package fr.koka.sanctuary.mixin; + +import fr.koka.sanctuary.worldgen.LayeredSpringOutlets; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.LevelChunk; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */ +@Mixin(LevelChunk.class) +public abstract class LayeredSpringOutletMixin { + @Unique + private LayeredSpringOutlets.Pending sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY; + + @Inject(method = "postProcessGeneration", at = @At("HEAD")) + private void sanctuary$captureSpringOutlets(ServerLevel level, CallbackInfo callback) { + sanctuary$pendingSpringOutlets = LayeredSpringOutlets.capturePending(level, (LevelChunk) (Object) this); + } + + @Inject(method = "postProcessGeneration", at = @At("TAIL")) + private void sanctuary$finishSpringOutlets(ServerLevel level, CallbackInfo callback) { + var pending = sanctuary$pendingSpringOutlets; + sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY; + LayeredSpringOutlets.finish(level, (LevelChunk) (Object) this, pending); + } +} 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 c1e42d3..0c3ddf4 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 @@ -2,6 +2,7 @@ package fr.koka.sanctuary.mixin; import fr.koka.sanctuary.worldgen.HydrologyRuntime; import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime; +import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; import java.util.Set; import java.util.concurrent.CompletableFuture; import net.minecraft.core.Holder; @@ -26,7 +27,12 @@ public abstract class SurfaceHydrologyMixin { StructureManager structures, BiomeManager biomeManager, WorldGenRegion region, Set> biomes, CallbackInfoReturnable> callback) { var generator = (NoiseBasedChunkGenerator) (Object) this; - if (NaturalHydrologyRuntime.enabled(generator)) { + if (LayeredHydrologyRuntime.enabled(generator)) { + callback.setReturnValue(callback.getReturnValue().thenApply(generated -> { + LayeredHydrologyRuntime.apply(generator, randomState, generated); + return generated; + })); + } else if (NaturalHydrologyRuntime.enabled(generator)) { callback.setReturnValue(callback.getReturnValue().thenApply(generated -> { NaturalHydrologyRuntime.apply(generator, randomState, generated); return generated; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredBiomePalette.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredBiomePalette.java new file mode 100644 index 0000000..518e98e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredBiomePalette.java @@ -0,0 +1,20 @@ +package fr.koka.sanctuary.worldgen; + +/** Broad, seed-warped vertical bands; none changes terrain density or guarantees a resource. */ +public final class LayeredBiomePalette { + public enum Kind { MEADOW, FLOWER_MEADOW, LOWER_GROVE, MOSSY_TERRACES, ROCKY_TERRACES } + + private LayeredBiomePalette() {} + + public static Kind select(int blockY, float variation, float moisture) { + double elevation = blockY - Math.clamp(variation, -1.0F, 1.0F) * 28 + - Math.clamp(moisture, -1.0F, 1.0F) * 12; + if (elevation < 154) return moisture < -0.30F ? Kind.ROCKY_TERRACES : Kind.MOSSY_TERRACES; + if (elevation < 214) { + if (variation > 0.34F) return Kind.ROCKY_TERRACES; + return moisture > 0.02F ? Kind.LOWER_GROVE : Kind.MOSSY_TERRACES; + } + if (variation > 0.22F) return Kind.ROCKY_TERRACES; + return moisture > -0.12F ? Kind.FLOWER_MEADOW : Kind.MEADOW; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredHydrology.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredHydrology.java new file mode 100644 index 0000000..22e7a2e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredHydrology.java @@ -0,0 +1,633 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; + +/** Alpha.6 surface basins, rocky shores and layered wall springs. + * This version is separate from NaturalHydrology so previously created worlds keep their original terrain. + * Results retain no chunks, sampler, random state or Minecraft objects. */ +public final class LayeredHydrology { + public static final int RADIUS = 192; + public static final int MAX_CARVE = 4; + public static final int MAX_DEPTH = 3; + private static final int GRID = 8; + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + + private LayeredHydrology() {} + + @FunctionalInterface + public interface Sampler { float sample(int x, int y, int z); } + + public enum Kind { POND, LAKE } + public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS } + public record Point(int x, int z) {} + public record Bounds(int minX, int minZ, int maxX, int maxZ) {} + + public record Position(int x, int y, int z) {} + public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {} + public record Spring(long id, Position source, Position outlet, List flowPath, FlowBounds flowBounds) { + public Spring { flowPath = List.copyOf(flowPath); } + } + + /** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below. + * Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry + * terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */ + public record Cell(int x, int z, int waterY, int bedY, int carveTop, + ShoreMaterial material, long featureId, int sedimentDepth) { + public boolean hasWater() { return waterY >= 0; } + } + + public record Feature(long id, Kind kind, int waterY, List path, int waterCells, Bounds bounds) { + public Feature { path = List.copyOf(path); } + } + + public static final class Plan { + private final Map cells; + 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 Plan(Map source, List features, List springs, Set springFlow, int columns, long samples) { + List sorted = source.values().stream() + .sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList(); + Map ordered = new LinkedHashMap<>(); + Map> byChunk = new HashMap<>(); + for (Cell cell : sorted) { + ordered.put(key(cell.x(), cell.z()), cell); + byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell); + } + byChunk.replaceAll((ignored, values) -> List.copyOf(values)); + cells = Collections.unmodifiableMap(ordered); + chunks = Map.copyOf(byChunk); + this.features = List.copyOf(features); + this.springs = List.copyOf(springs); + this.springFlow = Set.copyOf(springFlow); + Map> groupedSprings = new HashMap<>(); + for (Spring spring : springs) groupedSprings.computeIfAbsent( + key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring); + groupedSprings.replaceAll((ignored, values) -> List.copyOf(values)); + springChunks = Map.copyOf(groupedSprings); + sampledColumns = columns; + densitySamples = samples; + } + + public Cell cellAt(int x, int z) { return cells.get(key(x, z)); } + public Collection cells() { return cells.values(); } + public List cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); } + public List features() { return features; } + 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(); + int attempts = 0; + for (Candidate candidate : candidates) { + if (features.size() >= 5 || attempts++ >= 96) break; + Point center = new Point(candidate.x(), candidate.z()); + if (nearWater(center.x(), center.z(), 38)) continue; + Column column = column(center.x(), center.z()); + if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue; + Kind kind = features.size() < 2 ? Kind.LAKE : Kind.POND; + int target = kind == Kind.LAKE ? 190 + (int) (random(seed, center.x(), center.z()) * 130) + : 55 + (int) (random(seed + 1, center.x(), center.z()) * 55); + // Prefer an existing low spot; a shallow cut is the fallback on level terrain. + for (int level : new int[]{column.top() + 1, column.top(), column.top() - 1}) { + Set footprint = growBasin(center, level, target); + if (footprint == null) continue; + long id = featureId(features.size()); + Map wet = waterCells(footprint, level, id); + if (wet == null || !contained(wet)) continue; + addFeature(id, kind, level, List.of(center), wet); + break; + } + } + addSedimentSheets(); + addLowerWallSprings(); + addSprings(); + return new Plan(cells, features, springs, springFlow, columns.size(), samples); + } + + private List 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 - 16) && solid(x, bottom - 1, z)) bottom--; + } + Column created = new Column(top, bottom); + columns.put(key, created); + return created; + } + + private boolean eligible(int x, int z, int level) { + if (level < 32 || level >= 383 || !inside(x, z, RADIUS - 2)) return false; + Cell old = cells.get(key(x, z)); + if (old != null && old.hasWater() && old.waterY() != level) return false; + Column column = column(x, z); + return column.top() >= level - 3 && column.top() < level + MAX_CARVE + && column.solidBottom() <= Math.min(column.top(), level - 1) - 4; + } + + private Set 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()) > 28 || wet.contains(key(x, z))) continue; + if (!eligible(x, z, level)) continue; + double step = Math.hypot(d[0], d[1]); + double cost = current.cost() + step * (1 + Math.max(0, column(x, z).top() - level) * 0.8 + + terrainNoise(x, z) * 4.5 + field(x + 71, z - 93, 21, 107) * 3.5); + if (cost >= costs.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue; + costs.put(key(x, z), cost); + queue.add(new Node(x, z, cost)); + } + } + wet = connectedTo(wet, key(center.x(), center.z())); + if (wet.size() < Math.min(target, 30)) return null; + // Filling a hollow requires every lower connected outlet to be included. Abort rather + // than place a dam if this natural basin runs to a cliff, cave or an oversized catchment. + List work = new ArrayList<>(wet); + for (int i = 0; i < work.size(); i++) { + Point point = point(work.get(i)); + for (int[] d : CARDINALS) { + int x = point.x() + d[0], z = point.z() + d[1]; + long neighbor = key(x, z); + if (wet.contains(neighbor) || column(x, z).top() >= level) continue; + if (wet.size() >= 420 || Math.hypot(x - center.x(), z - center.z()) > 32 + || !eligible(x, z, level)) return null; + wet.add(neighbor); + work.add(neighbor); + } + } + return wet; + } + + private Map 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, MAX_CARVE - Math.max(0, column.top() - level)); + if (depth < 1 || depth > MAX_DEPTH) return null; + int bed = level - depth; + if (column.top() < bed || column.solidBottom() > bed - 2) return null; + ShoreMaterial material = wetMaterial(p.x(), p.z(), edge); + int sedimentDepth = sedimentDepth(p.x(), p.z()); + if (column.solidBottom() > bed - sedimentDepth - 1) return null; + result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()), + material, id, sedimentDepth)); + } + return result; + } + + private boolean contained(Map 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); + 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; + Column column = column(node.x(), node.z()); + Cell existing = cells.get(key(node.x(), node.z())); + if (existing == null) { + double extent = 9 + field(node.x(), node.z(), 25, 131) * 10; + if (node.cost() > extent) continue; + int delta = column.top() - node.level(); + if (delta < 0 || delta > 5) continue; + // Low shore: waterline, then one-block steps. Cut at most two existing blocks; + // far deposits keep the original landform and feather back into grass/stone. + int terrace = node.level() + (int) Math.floor(node.cost() / 4.5); + int bed = Math.max(node.level(), Math.max(column.top() - 2, Math.min(column.top(), terrace))); + int depth = sedimentDepth(node.x(), node.z()); + if (column.solidBottom() > bed - depth - 1) continue; + boolean safe = true; + for (int[] d : CARDINALS) { + Cell next = cells.get(key(node.x() + d[0], node.z() + d[1])); + if (next != null && next.hasWater() && bed < next.waterY()) safe = false; + } + if (!safe) continue; + ShoreMaterial material = dryMaterial(node.x(), node.z(), node.cost() / extent); + cells.put(key(node.x(), node.z()), new Cell(node.x(), node.z(), -1, bed, + column.top(), material, node.feature(), depth)); + } + for (int[] d : NEIGHBORS) { + int x = node.x() + d[0], z = node.z() + d[1]; + if (!inside(x, z, RADIUS - 2)) continue; + Cell water = cells.get(key(x, z)); + if (water != null && water.hasWater()) continue; + Column next = column(x, z); + if (next.top() < node.level() || next.top() > node.level() + 5) continue; + double cost = node.cost() + Math.hypot(d[0], d[1]) + * (0.7 + Math.abs(next.top() - column.top()) * 0.32 + field(x, z, 12, 211) * 0.6); + if (cost > 19 || cost >= best.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue; + best.put(key(x, z), cost); + queue.add(new ShoreNode(x, z, node.level(), node.feature(), cost)); + } + } + } + + private int sedimentDepth(int x, int z) { return 3 + (int) (field(x, z, 15, 319) * 2.999); } + + private ShoreMaterial wetMaterial(int x, int z, boolean edge) { + double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25; + if (geology < 0.46) return ShoreMaterial.STONE; + if (geology < 0.65) return ShoreMaterial.GRAVEL; + if (!edge && geology > 0.78) return ShoreMaterial.CLAY; + return ShoreMaterial.SAND; + } + + private ShoreMaterial dryMaterial(int x, int z, double distance) { + double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25; + double grass = field(x + 103, z - 137, 19, 521); + if (grass > 0.62 || distance > 0.60 + grass * 0.65) return ShoreMaterial.GRASS; + if (geology < 0.46) return ShoreMaterial.STONE; + if (geology < 0.65) return ShoreMaterial.GRAVEL; + return ShoreMaterial.SAND; + } + + private record Cliff(int x, int z, int dx, int dz, double score) {} + private record FlowNode(Position position, int reach) {} + private record Flow(Set 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 (nearModifiedGround(source, 32) || !separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz())) continue; + Flow flow = traceSpring(source, outlet); + if (flow == null || flow.bounds().minY() > y - 12) continue; + long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L)); + springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds())); + springFlow.addAll(flow.volume()); + placed = true; + break; + } + if (placed) break; + } + if (placed) break; + } + } + } + + private boolean nearModifiedGround(Position source, int distance) { + for (Cell cell : cells.values()) { + int dy = Math.max(Math.max(cell.bedY() - cell.sedimentDepth() - source.y(), 0), + source.y() - cell.carveTop()); + double horizontal = Math.hypot(source.x() - cell.x(), source.z() - cell.z()); + if (Math.hypot(horizontal, dy) < distance) return true; + } + return false; + } + + private boolean separatedSpring(Position source) { + for (Spring spring : springs) { + double horizontal = Math.hypot(source.x() - spring.source().x(), source.z() - spring.source().z()); + int dy = Math.abs(source.y() - spring.source().y()); + if (Math.hypot(horizontal, dy) < 64 || dy < 12) return false; + } + return true; + } + + private record Wall(int x, int y, int z, int dx, int dz, double score) {} + + /** Probe a few exposed faces below the highest surface as well. This finds overhangs and + * lower strata that a highest-column heightmap cannot represent, without inventing a ledge. */ + private void addLowerWallSprings() { + List candidates = new ArrayList<>(); + for (int x = -240; x <= 240; x += 16) for (int z = -240; z <= 240; z += 16) { + if (!inside(x, z, 240)) continue; + int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z())); + for (int y = top - 32; y >= 64; y -= 16) { + if (!solid(x, y, z)) continue; + for (int[] direction : CARDINALS) { + if (solid(x + direction[0] * 4, y, z + direction[1] * 4)) continue; + candidates.add(new Wall(x, y, z, direction[0], direction[1], + random(seed + 1217 + y, x + direction[0], z + direction[1]) * 3 + + Math.min(100, top - y) * 0.015)); + } + } + } + candidates.sort(Comparator.comparingDouble(Wall::score).reversed() + .thenComparingInt(Wall::x).thenComparingInt(Wall::z).thenComparingInt(Wall::y) + .thenComparingInt(Wall::dx).thenComparingInt(Wall::dz)); + int attempts = 0; + for (Wall candidate : candidates) { + if (springs.size() >= 2 || attempts++ >= 96) break; + boolean placed = false; + for (int tangent : new int[]{0, -2, 2}) { + for (int along = 0; along < 4; along++) { + int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent; + int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent; + for (int dy : new int[]{0, 3, -3, 6, -6}) { + int y = candidate.y() + dy; + if (y < 56 || column(x, z).top() - y < 24) continue; + Position source = new Position(x, y, z); + if (nearModifiedGround(source, 32) || !separatedSpring(source) + || !springNiche(source, candidate.dx(), candidate.dz())) continue; + Position outlet = new Position(x + candidate.dx(), y, z + candidate.dz()); + Flow flow = traceSpring(source, outlet); + if (flow == null || flow.bounds().minY() > y - 12) continue; + long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L)); + springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds())); + springFlow.addAll(flow.volume()); + placed = true; + break; + } + if (placed) break; + } + if (placed) break; + } + } + } + + private boolean springNiche(Position source, int dx, int dz) { + for (int y = source.y() - 3; y <= source.y() + 1; y++) { + if (!solid(source.x(), y, source.z())) return false; + } + for (int[] d : CARDINALS) { + if (d[0] == dx && d[1] == dz) continue; + if (!solid(source.x() + d[0], source.y(), source.z() + d[1])) return false; + } + for (int y = source.y(); y >= source.y() - 8; y--) { + if (solid(source.x() + dx, y, source.z() + dz)) return false; + } + return true; + } + + /** Predict drainage through undecorated rock to select springs and inspection bounds. + * Descend through air first, then explore seven horizontal steps on a shelf. Later trees + * can divert the actual fluid: this prediction is not a bound on decorated-world physics. + * Integration tests trace actual water back to its source within the inspected chunk halo. */ + private Flow traceSpring(Position source, Position outlet) { + Map 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/LayeredHydrologyRuntime.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredHydrologyRuntime.java new file mode 100644 index 0000000..27f6292 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredHydrologyRuntime.java @@ -0,0 +1,148 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.SanctuaryMod; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Map; +import java.util.WeakHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.RandomState; + +/** Alpha.6 only: immutable plans cached by world state, applied to fresh terrain before decoration. */ +public final class LayeredHydrologyRuntime { + private static final Map PLANS = + Collections.synchronizedMap(new WeakHashMap<>()); + private static final Map LAVA_PLANS = + Collections.synchronizedMap(new WeakHashMap<>()); + + private LayeredHydrologyRuntime() {} + + public static boolean enabled(NoiseBasedChunkGenerator generator) { + return generator.generatorSettings().is(SanctuarySpawn.LAYERED_SETTINGS); + } + + public static LayeredHydrology.Plan layeredPlan(ServerLevel level) { + return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), + level.getChunkSource().randomState()); + } + + public static LayeredHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) { + if (!enabled(generator)) throw new IllegalArgumentException("Layered hydrology is not enabled for this generator"); + return PLANS.computeIfAbsent(randomState, state -> { + long start = System.nanoTime(); + var density = generator.generatorSettings().value().noiseRouter().finalDensity(); + var plan = LayeredHydrology.create(state.seed(), + (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z)); + SanctuaryMod.LOGGER.info("Sanctuary layered hydrology: seed {}, {} features, {} columns, {} springs, {} ms", + state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(), + (System.nanoTime() - start) / 1_000_000); + return plan; + }); + } + + public static LayeredLavaDeposit.Plan lavaPlan(ServerLevel level) { + return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), + level.getChunkSource().randomState()); + } + + public static LayeredLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) { + // Acquire the water plan before the lava cache lock; the water planner never acquires it. + var water = plan(generator, randomState); + return LAVA_PLANS.computeIfAbsent(randomState, state -> { + long start = System.nanoTime(); + var density = generator.generatorSettings().value().noiseRouter().finalDensity(); + var lava = LayeredLavaDeposit.create(state.seed(), + (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water); + SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms", + state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000); + return lava; + }); + } + + public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) { + if (!enabled(generator)) return; + var chunkPos = chunk.getPos(); + if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L + || Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return; + var plan = plan(generator, randomState); + var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z()); + var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z()); + var outlets = plan.springs().stream().map(LayeredHydrology.Spring::outlet) + .filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList(); + var lava = lavaPlan(generator, randomState); + var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z()); + var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z()); + if (cells.isEmpty() && springs.isEmpty() && outlets.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + // Validate all replacements and two intact support layers before modifying this chunk. + for (var cell : cells) { + for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) { + requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed()); + } + } + for (var spring : springs) { + var source = spring.source(); + requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed()); + } + for (var support : lavaSupports) { + requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed()); + if (chunk.getBlockState(pos).ignitedByLava()) { + throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos); + } + } + for (var cell : lavaCells) { + requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed()); + } + for (var cell : cells) { + for (int depth = 0; depth < cell.sedimentDepth(); depth++) { + pos.set(cell.x(), cell.bedY() - depth, cell.z()); + BlockState material = switch (cell.material()) { + case SAND -> Blocks.SAND.defaultBlockState(); + case GRAVEL -> Blocks.GRAVEL.defaultBlockState(); + case CLAY -> Blocks.CLAY.defaultBlockState(); + case STONE -> Blocks.STONE.defaultBlockState(); + case GRASS -> (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState(); + }; + chunk.setBlockState(pos, material, 0); + } + for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) { + pos.set(cell.x(), y, cell.z()); + chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY() + ? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0); + if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos); + } + } + for (var spring : springs) { + var source = spring.source(); + pos.set(source.x(), source.y(), source.z()); + chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0); + // Only the source is generated. Vanilla fluid ticks create the descending waterfall. + chunk.markPosForPostProcessing(pos); + } + for (var cell : lavaCells) { + pos.set(cell.x(), cell.y(), cell.z()); + chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0); + if (cell.lava()) chunk.markPosForPostProcessing(pos); + } + // This existing vanilla queue is consumed after neighbouring chunks finish decoration. + // Mark the outlet in its own chunk, including when its source belongs to a neighbour. + for (var outlet : outlets) { + pos.set(outlet.x(), outlet.y(), outlet.z()); + chunk.markPosForPostProcessing(pos); + } + Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG, + Heightmap.Types.OCEAN_FLOOR_WG)); + } + + private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) { + if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) { + throw new IllegalStateException("Layered hydrology differs from terrain at " + pos + " for seed " + seed); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredIslandBiomeSource.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredIslandBiomeSource.java new file mode 100644 index 0000000..1f2d47a --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredIslandBiomeSource.java @@ -0,0 +1,73 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import java.util.stream.Stream; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeResolver; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.biome.Climate; + +/** Alpha.6 palette: flowered upper meadows and distinct humid lower terraces. */ +public final class LayeredIslandBiomeSource extends BiomeSource { + public static final ResourceKey MEADOW = key("layered_meadow"); + public static final ResourceKey FLOWER_MEADOW = key("layered_flower_meadow"); + public static final ResourceKey LOWER_GROVE = key("layered_lower_grove"); + public static final ResourceKey MOSSY_TERRACES = key("layered_mossy_terraces"); + public static final ResourceKey ROCKY_TERRACES = key("layered_rocky_terraces"); + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + Biome.CODEC.fieldOf("meadow").forGetter(source -> source.meadow), + Biome.CODEC.fieldOf("flower_meadow").forGetter(source -> source.flowerMeadow), + Biome.CODEC.fieldOf("lower_grove").forGetter(source -> source.lowerGrove), + Biome.CODEC.fieldOf("mossy_terraces").forGetter(source -> source.mossyTerraces), + Biome.CODEC.fieldOf("rocky_terraces").forGetter(source -> source.rockyTerraces) + ).apply(instance, LayeredIslandBiomeSource::new)); + + private final Holder meadow; + private final Holder flowerMeadow; + private final Holder lowerGrove; + private final Holder mossyTerraces; + private final Holder rockyTerraces; + + public LayeredIslandBiomeSource(Holder meadow, Holder flowerMeadow, + Holder lowerGrove, Holder mossyTerraces, Holder rockyTerraces) { + this.meadow = meadow; + this.flowerMeadow = flowerMeadow; + this.lowerGrove = lowerGrove; + this.mossyTerraces = mossyTerraces; + this.rockyTerraces = rockyTerraces; + } + + @Override + protected MapCodec codec() { return CODEC; } + + @Override + protected Stream> collectPossibleBiomes() { + return Stream.of(meadow, flowerMeadow, lowerGrove, mossyTerraces, rockyTerraces); + } + + @Override + public BiomeResolver createResolver(Climate.Sampler sampler) { + return (quartX, quartY, quartZ) -> { + int x = quartX * 4, y = quartY * 4, z = quartZ * 4; + float variation = sampler.temperature().sampleValue(x, 0, z); + float moisture = sampler.humidity().sampleValue(x, 0, z); + return switch (LayeredBiomePalette.select(y, variation, moisture)) { + case MEADOW -> meadow; + case FLOWER_MEADOW -> flowerMeadow; + case LOWER_GROVE -> lowerGrove; + case MOSSY_TERRACES -> mossyTerraces; + case ROCKY_TERRACES -> rockyTerraces; + }; + }; + } + + private static ResourceKey key(String path) { + return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path)); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredLavaDeposit.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredLavaDeposit.java new file mode 100644 index 0000000..7d6c811 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredLavaDeposit.java @@ -0,0 +1,223 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** One small, covered lava pocket in existing rock, reached from an existing outdoor ledge. + * No solid blocks are added, and unsuitable seeds are allowed to produce no deposit. */ +public final class LayeredLavaDeposit { + public static final int RADIUS = 192; + public static final int WATER_CLEARANCE = 32; + public static final int LAVA_VOLUME = 18; + private static final int ENTRY_DISTANCE = 8; + private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final Comparator POSITION_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 LayeredLavaDeposit() {} + + public record Position(int x, int y, int z) {} + /** lava=false removes existing rock to open the two-block-high chamber or approach. */ + public record Cell(int x, int y, int z, boolean lava) {} + + public static final class Plan { + private final List cells; + private final List lavaCells; + private final List supports; + private final Optional access; + private final Map> chunks; + private final Map> supportChunks; + + private Plan(List cells, Set supports, Position access) { + this.cells = cells.stream().sorted(CELL_ORDER).toList(); + lavaCells = this.cells.stream().filter(Cell::lava).toList(); + this.supports = supports.stream().sorted(POSITION_ORDER).toList(); + this.access = Optional.ofNullable(access); + Map> 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; } + /** Player feet on the existing ledge; this column was naturally open to the 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, LayeredHydrology.Sampler sampler, LayeredHydrology.Plan hydrology) { + return new Search(seed, sampler, hydrology).find(); + } + + private record Candidate(int x, int z, int dx, int dz, double score) {} + + private static final class Search { + private final long seed; + private final LayeredHydrology.Sampler sampler; + private final LayeredHydrology.Plan hydrology; + private final Map heights = new HashMap<>(); + + Search(long seed, LayeredHydrology.Sampler sampler, LayeredHydrology.Plan hydrology) { + this.seed = seed; + this.sampler = sampler; + this.hydrology = hydrology; + } + + Plan find() { + 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); + } + + private Plan tryNiche(int entryX, int entryZ, int dx, int dz) { + int cx = entryX - dx * ENTRY_DISTANCE, cz = entryZ - dz * ENTRY_DISTANCE; + if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) return null; + int level = top(entryX, entryZ); + if (level < 64 || level > 300 || top(cx, cz) < level + 12) return null; + // The player can discover and enter from an actual outdoor ledge, not a sealed cave. + for (int y = level + 1; y < 384; y++) if (solid(entryX, y, entryZ)) return null; + for (int y = level - 2; y <= level; y++) if (!solid(entryX, y, entryZ)) return null; + + List 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; + } + return true; + } + + private int top(int x, int z) { + return heights.computeIfAbsent(key(x, z), ignored -> { + for (int y = 383; y >= 32; y -= 4) { + if (!solid(x, y, z)) continue; + for (int exact = Math.min(383, y + 3); exact > y; exact--) if (solid(x, exact, z)) return exact; + return y; + } + return -1; + }); + } + + private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; } + } + + private static void add(List 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/LayeredShoreSugarCaneFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredShoreSugarCaneFeature.java new file mode 100644 index 0000000..68ae7a3 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredShoreSugarCaneFeature.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 LayeredShoreSugarCaneFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(LayeredShoreSugarCaneFeature::new); + + @Override + public MapCodec codec() { + return CODEC; + } + + @Override + public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !LayeredHydrologyRuntime.enabled(noise)) return false; + var plan = LayeredHydrologyRuntime.plan(noise, level.getLevel().getChunkSource().randomState()); + var cane = Blocks.SUGAR_CANE.defaultBlockState(); + boolean placed = false; + for (var cell : plan.cellsInChunk(origin.getX() >> 4, origin.getZ() >> 4)) { + if (cell.hasWater() || random.nextInt(7) != 0) continue; + BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z()); + if (!level.getBlockState(base).isAir() || !cane.canSurvive(level, base)) continue; + int height = 1 + random.nextInt(3); + for (int y = 0; y < height; y++) { + BlockPos pos = base.above(y); + if (!level.getBlockState(pos).isAir() || !cane.canSurvive(level, pos)) break; + setBlock(level, pos, cane); + placed = true; + } + } + return placed; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredSpringOutlets.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredSpringOutlets.java new file mode 100644 index 0000000..9e09f5e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LayeredSpringOutlets.java @@ -0,0 +1,94 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.VegetationBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.material.Fluids; + +/** One-time alpha.6 outlet finishing, using vanilla's persisted and consumed generation queue. */ +public final class LayeredSpringOutlets { + public record Pending(List 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 LayeredSpringOutlets() {} + + /** Called before vanilla consumes the queue; a fully processed/reloaded chunk has no work. */ + public static Pending capturePending(ServerLevel level, LevelChunk chunk) { + if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise) + || !LayeredHydrologyRuntime.enabled(noise)) return Pending.EMPTY; + boolean queued = false; + for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) { + queued = true; + break; + } + if (!queued) return Pending.EMPTY; + var plan = LayeredHydrologyRuntime.plan(noise, level.getChunkSource().randomState()); + List sources = new ArrayList<>(), outlets = new ArrayList<>(); + for (var spring : plan.springs()) { + if (markedHere(chunk, spring.source())) sources.add(spring.source()); + if (markedHere(chunk, spring.outlet())) outlets.add(spring.outlet()); + } + return new Pending(sources, outlets); + } + + /** All neighbouring FEATURES have finished before a chunk reaches this stage. */ + public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) { + if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise) + || !LayeredHydrologyRuntime.enabled(noise)) return; + var random = level.getChunkSource().randomState(); + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + for (var outlet : pending.outlets()) { + if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet); + if (random.sampleBlockValueUncached(density, outlet.x(), outlet.y(), outlet.z()) > 0) { + throw new IllegalStateException("A spring outlet must already be natural air: " + outlet); + } + BlockPos pos = blockPos(outlet); + BlockState current = chunk.getBlockState(pos); + if (removableDecoration(current)) { + // Only this declared air cell is reopened. Neighbour notification wakes the + // adjacent source even if its own chunk was post-processed first. + level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + } + for (var source : pending.sources()) { + if (!owns(chunk, source)) throw new IllegalArgumentException("A source belongs to another chunk: " + source); + BlockPos pos = blockPos(source); + BlockState current = chunk.getBlockState(pos); + if (current.is(Blocks.WATER) && current.getFluidState().isSource()) { + level.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level)); + } + } + } + + private static boolean markedHere(LevelChunk chunk, LayeredHydrology.Position position) { + if (!owns(chunk, position)) return false; + var queue = chunk.getPostProcessing()[chunk.getSectionIndex(position.y())]; + return queue != null && queue.contains(ProtoChunk.packOffsetCoordinates(blockPos(position))); + } + + private static boolean owns(LevelChunk chunk, LayeredHydrology.Position position) { + return (position.x() >> 4) == chunk.getPos().x() && (position.z() >> 4) == chunk.getPos().z(); + } + + private static BlockPos blockPos(LayeredHydrology.Position position) { + return new BlockPos(position.x(), position.y(), position.z()); + } + + private static boolean removableDecoration(BlockState state) { + if (state.hasBlockEntity()) return false; + return state.is(Blocks.GLOW_LICHEN) || state.is(Blocks.VINE) || state.is(Blocks.MOSS_CARPET) + || state.getBlock() instanceof VegetationBlock || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LowerTerraceEcology.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LowerTerraceEcology.java new file mode 100644 index 0000000..b3eb619 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LowerTerraceEcology.java @@ -0,0 +1,21 @@ +package fr.koka.sanctuary.worldgen; + +/** Geometry checks shared by lower-ledge decoration and density diagnostics. */ +public final class LowerTerraceEcology { + @FunctionalInterface + public interface Sampler { float sample(int x, int y, int z); } + + private LowerTerraceEcology() {} + + public static int highestNaturalSurface(Sampler sampler, int x, int z) { + for (int y = 383; y >= 24; y--) if (sampler.sample(x, y, z) > 0) return y; + return -1; + } + + /** A lower natural floor: three solid blocks, headroom, and real terrain at least eight blocks above. */ + public static boolean isLowerTerrace(Sampler sampler, int x, int bedY, int z, int highestNaturalSurface) { + if (bedY < 32 || bedY > 312 || highestNaturalSurface < bedY + 8) return false; + for (int y = bedY - 2; y <= bedY; y++) if (sampler.sample(x, y, z) <= 0) return false; + return sampler.sample(x, bedY + 1, z) <= 0 && sampler.sample(x, bedY + 2, z) <= 0; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LowerTerraceVegetationFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LowerTerraceVegetationFeature.java new file mode 100644 index 0000000..f1bc763 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/LowerTerraceVegetationFeature.java @@ -0,0 +1,126 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import java.util.HashSet; +import java.util.Set; +import net.minecraft.core.BlockPos; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.feature.Feature; + +/** Decorates actual lower natural floors after the ordinary highest-surface vegetation. + * Each chunk owns its columns; planned water, sediments, lava and their supports are excluded. */ +public final class LowerTerraceVegetationFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(LowerTerraceVegetationFeature::new); + + @Override + public MapCodec codec() { return CODEC; } + + @Override + public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !LayeredHydrologyRuntime.enabled(noise)) return false; + int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16; + if (Math.abs((long) minX) > 288 || Math.abs((long) minZ) > 288) return false; + var state = level.getLevel().getChunkSource().randomState(); + var water = LayeredHydrologyRuntime.plan(noise, state); + var lava = LayeredHydrologyRuntime.lavaPlan(noise, state); + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + LowerTerraceEcology.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + var climate = state.createClimateSampler(SamplerContext.EMPTY_UNCACHED); + Set lavaColumns = new HashSet<>(); + for (var cell : lava.cells()) protectColumn(lavaColumns, cell.x(), cell.z()); + for (var support : lava.supports()) protectColumn(lavaColumns, support.x(), support.z()); + boolean changed = false; + BlockPos.MutableBlockPos floor = new BlockPos.MutableBlockPos(); + for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) { + if (blockedColumn(water, lavaColumns, x, z)) continue; + // A coherent moisture patch controls coverage, rather than an alternating block grid. + float growth = climate.humidity().sampleValue(x + 317, 0, z - 191); + if (growth < -0.30F) continue; + int highest = -1, terraces = 0; + for (int y = 319; y >= 32; y--) { + floor.set(x, y, z); + BlockState ground = level.getBlockState(floor); + if (!replaceableGround(ground)) continue; + if (highest < 0) { + if (sampler.sample(x, y, z) > 0) highest = y; + continue; + } + if (y > highest - 8) continue; + BlockPos plantPos = floor.above(); + if (!level.getBlockState(plantPos).isAir() || !level.getBlockState(plantPos.above()).isAir()) continue; + if (!LowerTerraceEcology.isLowerTerrace(sampler, x, y, z, highest)) continue; + if (!ground.isCollisionShapeFullBlock(level, floor) + || !level.getBlockState(floor.below()).isCollisionShapeFullBlock(level, floor.below()) + || !level.getBlockState(floor.below(2)).isCollisionShapeFullBlock(level, floor.below(2))) continue; + var biome = level.getBiome(plantPos); + if (biome.is(LayeredIslandBiomeSource.ROCKY_TERRACES)) continue; + boolean mossy = biome.is(LayeredIslandBiomeSource.MOSSY_TERRACES); + // Moss survives shaded ledges without needing artificial lighting. Existing grass + // is retained outside the moss biome; no supporting terrain is added to air. + if (mossy || !ground.is(Blocks.GRASS_BLOCK)) { + setBlock(level, floor, Blocks.MOSS_BLOCK.defaultBlockState()); + changed = true; + } + if (random.nextInt(5) < 2) { + BlockState plant = plant(mossy, random.nextInt(10)); + if (plant.canSurvive(level, plantPos)) { + setBlock(level, plantPos, plant); + changed = true; + } + } + if (++terraces >= 4) break; + } + } + return changed; + } + + private static BlockState plant(boolean mossy, int choice) { + if (mossy) return switch (choice) { + case 0 -> Blocks.FLOWERING_AZALEA.defaultBlockState(); + case 1 -> Blocks.AZALEA.defaultBlockState(); + case 2, 3 -> Blocks.FERN.defaultBlockState(); + default -> Blocks.MOSS_CARPET.defaultBlockState(); + }; + return switch (choice) { + case 0 -> Blocks.OXEYE_DAISY.defaultBlockState(); + case 1 -> Blocks.CORNFLOWER.defaultBlockState(); + case 2 -> Blocks.AZURE_BLUET.defaultBlockState(); + case 3 -> Blocks.DANDELION.defaultBlockState(); + case 4 -> Blocks.BUSH.defaultBlockState(); + default -> Blocks.SHORT_GRASS.defaultBlockState(); + }; + } + + private static boolean replaceableGround(BlockState state) { + return state.is(Blocks.STONE) || state.is(Blocks.GRANITE) || state.is(Blocks.DIORITE) + || state.is(Blocks.ANDESITE) || state.is(Blocks.TUFF) || state.is(Blocks.DIRT) + || state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.ROOTED_DIRT) || state.is(Blocks.COARSE_DIRT) + || state.is(Blocks.MOSS_BLOCK); + } + + private static boolean blockedColumn(LayeredHydrology.Plan water, Set lavaColumns, int x, int z) { + if (lavaColumns.contains(key(x, z))) return true; + for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) { + if (water.cellAt(x + dx, z + dz) != null) return true; + } + for (var spring : water.springs()) if (inside(spring.flowBounds(), x, z)) return true; + return false; + } + + private static boolean inside(LayeredHydrology.FlowBounds bounds, int x, int z) { + return x >= bounds.minX() - 2 && x <= bounds.maxX() + 2 + && z >= bounds.minZ() - 2 && z <= bounds.maxZ() + 2; + } + + private static void protectColumn(Set columns, int x, int z) { + for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) columns.add(key(x + dx, z + dz)); + } + + private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } +} 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 01df402..e614009 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 @@ -25,6 +25,9 @@ public final class SanctuarySpawn { public static final ResourceKey NATURAL_SETTINGS = ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_natural")); + public static final ResourceKey LAYERED_SETTINGS = + ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_layered")); + private SanctuarySpawn() {} public static boolean usesSanctuaryGenerator(ServerLevel level) { @@ -32,7 +35,8 @@ public final class SanctuarySpawn { && level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator generator && (generator.generatorSettings().is(SETTINGS) || generator.generatorSettings().is(RAISED_SETTINGS) || generator.generatorSettings().is(HYDROLOGY_SETTINGS) - || generator.generatorSettings().is(NATURAL_SETTINGS)); + || generator.generatorSettings().is(NATURAL_SETTINGS) + || generator.generatorSettings().is(LAYERED_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 9299497..bd7cebf 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 @@ -6,5 +6,10 @@ "biome.sanctuary.temperate_plains": "Sanctuary Plains", "biome.sanctuary.temperate_birch_forest": "Sanctuary Birch Forest", "biome.sanctuary.temperate_flower_forest": "Sanctuary Flower Forest", - "biome.sanctuary.temperate_dappled_forest": "Sanctuary Dappled Forest" + "biome.sanctuary.temperate_dappled_forest": "Sanctuary Dappled Forest", + "biome.sanctuary.layered_meadow": "Highland Meadow", + "biome.sanctuary.layered_flower_meadow": "Flowered Highland Meadow", + "biome.sanctuary.layered_lower_grove": "Ledge Woodland", + "biome.sanctuary.layered_mossy_terraces": "Mossy Ledges", + "biome.sanctuary.layered_rocky_terraces": "Rocky Terraces" } 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 7e78bfc..66ede61 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 @@ -6,5 +6,10 @@ "biome.sanctuary.temperate_plains": "Prairies de Sanctuary", "biome.sanctuary.temperate_birch_forest": "Boulaie de Sanctuary", "biome.sanctuary.temperate_flower_forest": "Forêt fleurie de Sanctuary", - "biome.sanctuary.temperate_dappled_forest": "Forêt diaprée de Sanctuary" + "biome.sanctuary.temperate_dappled_forest": "Forêt diaprée de Sanctuary", + "biome.sanctuary.layered_meadow": "Prairie des hauteurs", + "biome.sanctuary.layered_flower_meadow": "Prairie fleurie des hauteurs", + "biome.sanctuary.layered_lower_grove": "Sous-bois des corniches", + "biome.sanctuary.layered_mossy_terraces": "Corniches moussues", + "biome.sanctuary.layered_rocky_terraces": "Terrasses rocheuses" } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_flower_meadow.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_flower_meadow.json new file mode 100644 index 0000000..673d39a --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_flower_meadow.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.8, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "minecraft:ore_dirt", + "minecraft:ore_granite_upper", + "minecraft:ore_granite_lower", + "minecraft:ore_diorite_upper", + "minecraft:ore_diorite_lower", + "minecraft:ore_andesite_upper", + "minecraft:ore_andesite_lower", + "minecraft:ore_tuff", + "sanctuary:island_ore_coal", + "sanctuary:island_ore_iron", + "sanctuary:island_ore_copper", + "sanctuary:island_ore_gold", + "sanctuary:island_ore_redstone", + "sanctuary:island_ore_lapis", + "sanctuary:island_ore_diamond" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:patch_tall_grass_2", + "minecraft:patch_grass_meadow", + "minecraft:flower_meadow", + "minecraft:trees_meadow", + "minecraft:wildflowers_meadow", + "minecraft:flower_flower_forest", + "sanctuary:layered_shore_sugar_cane", + "sanctuary:lower_terrace_vegetation" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_lower_grove.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_lower_grove.json new file mode 100644 index 0000000..50df8a1 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_lower_grove.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.8, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "minecraft:ore_dirt", + "minecraft:ore_granite_upper", + "minecraft:ore_granite_lower", + "minecraft:ore_diorite_upper", + "minecraft:ore_diorite_lower", + "minecraft:ore_andesite_upper", + "minecraft:ore_andesite_lower", + "minecraft:ore_tuff", + "sanctuary:island_ore_coal", + "sanctuary:island_ore_iron", + "sanctuary:island_ore_copper", + "sanctuary:island_ore_gold", + "sanctuary:island_ore_redstone", + "sanctuary:island_ore_lapis", + "sanctuary:island_ore_diamond" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:forest_flowers", + "minecraft:wildflowers_birch_forest", + "minecraft:trees_birch", + "minecraft:patch_bush", + "minecraft:flower_default", + "minecraft:patch_grass_forest", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "minecraft:patch_pumpkin", + "minecraft:patch_sugar_cane", + "minecraft:patch_firefly_bush_near_water", + "sanctuary:layered_shore_sugar_cane", + "sanctuary:lower_terrace_vegetation" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_meadow.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_meadow.json new file mode 100644 index 0000000..a379dff --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_meadow.json @@ -0,0 +1,175 @@ +{ + "attributes": { + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + }, + { + "type": "minecraft:horse", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 2 + }, + "weight": 5 + }, + { + "type": "minecraft:donkey", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 3, + "min_inclusive": 1 + }, + "weight": 1 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 90 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:zombie_horse", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.8, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "minecraft:ore_dirt", + "minecraft:ore_granite_upper", + "minecraft:ore_granite_lower", + "minecraft:ore_diorite_upper", + "minecraft:ore_diorite_lower", + "minecraft:ore_andesite_upper", + "minecraft:ore_andesite_lower", + "minecraft:ore_tuff", + "sanctuary:island_ore_coal", + "sanctuary:island_ore_iron", + "sanctuary:island_ore_copper", + "sanctuary:island_ore_gold", + "sanctuary:island_ore_redstone", + "sanctuary:island_ore_lapis", + "sanctuary:island_ore_diamond" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:patch_tall_grass_2", + "minecraft:patch_grass_meadow", + "minecraft:flower_meadow", + "minecraft:trees_meadow", + "minecraft:wildflowers_meadow", + "minecraft:patch_pumpkin", + "minecraft:patch_sugar_cane", + "minecraft:patch_firefly_bush_near_water", + "sanctuary:layered_shore_sugar_cane", + "sanctuary:lower_terrace_vegetation" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_mossy_terraces.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_mossy_terraces.json new file mode 100644 index 0000000..e602254 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_mossy_terraces.json @@ -0,0 +1,147 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.lush_caves" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "axolotls": [ + { + "type": "minecraft:axolotl", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ], + "water_ambient": [ + { + "type": "minecraft:tropical_fish", + "count": 8, + "weight": 25 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.8, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "minecraft:ore_dirt", + "minecraft:ore_granite_upper", + "minecraft:ore_granite_lower", + "minecraft:ore_diorite_upper", + "minecraft:ore_diorite_lower", + "minecraft:ore_andesite_upper", + "minecraft:ore_andesite_lower", + "minecraft:ore_tuff", + "sanctuary:island_ore_coal", + "sanctuary:island_ore_iron", + "sanctuary:island_ore_copper", + "sanctuary:island_ore_gold", + "sanctuary:island_ore_redstone", + "sanctuary:island_ore_lapis", + "sanctuary:island_ore_diamond" + ], + [], + [], + [ + "minecraft:glow_lichen", + "sanctuary:layered_shore_sugar_cane", + "sanctuary:lower_terrace_vegetation" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_rocky_terraces.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_rocky_terraces.json new file mode 100644 index 0000000..51f6ebd --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/layered_rocky_terraces.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.8, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "minecraft:ore_dirt", + "minecraft:ore_granite_upper", + "minecraft:ore_granite_lower", + "minecraft:ore_diorite_upper", + "minecraft:ore_diorite_lower", + "minecraft:ore_andesite_upper", + "minecraft:ore_andesite_lower", + "minecraft:ore_tuff", + "sanctuary:island_ore_coal", + "sanctuary:island_ore_iron", + "sanctuary:island_ore_copper", + "sanctuary:island_ore_gold", + "sanctuary:island_ore_redstone", + "sanctuary:island_ore_lapis", + "sanctuary:island_ore_diamond" + ], + [], + [], + [ + "minecraft:glow_lichen", + "sanctuary:layered_shore_sugar_cane", + "sanctuary:lower_terrace_vegetation" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/layered_island.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/layered_island.json new file mode 100644 index 0000000..119fc55 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/layered_island.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:layered_rocky_terraces" + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:stone" + } + }, + "sanctuary:starter_island" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary_layered.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary_layered.json new file mode 100644 index 0000000..424796e --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary_layered.json @@ -0,0 +1,23 @@ +{ + "default_block": "minecraft:stone", + "default_fluid": "minecraft:air", + "disable_mob_generation": false, + "legacy_random_source": true, + "material_rule": "sanctuary:layered_island", + "noise": { + "height": 384, + "min_y": 0 + }, + "noise_router": { + "chunk_surface_level": 0.0, + "continents": 0.0, + "depth": 0.0, + "erosion": 0.0, + "final_density": "sanctuary:final_density_384", + "ridges": 0.0, + "temperature": "sanctuary:temperate_variation", + "vegetation": "sanctuary:temperate_moisture" + }, + "sea_level": -64, + "spawn_target": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/layered_shore_sugar_cane.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/layered_shore_sugar_cane.json new file mode 100644 index 0000000..71e4828 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/layered_shore_sugar_cane.json @@ -0,0 +1,4 @@ +{ + "feature": { "type": "sanctuary:layered_shore_sugar_cane" }, + "placement": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/lower_terrace_vegetation.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/lower_terrace_vegetation.json new file mode 100644 index 0000000..4781be0 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/lower_terrace_vegetation.json @@ -0,0 +1,6 @@ +{ + "feature": { + "type": "sanctuary:lower_terrace_vegetation" + }, + "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 5a732bb..f8833be 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,14 +5,14 @@ "generator": { "type": "minecraft:noise", "biome_source": { - "type": "sanctuary:temperate_island", - "forest": "sanctuary:temperate_forest", - "plains": "sanctuary:temperate_plains", - "birch_forest": "sanctuary:temperate_birch_forest", - "flower_forest": "sanctuary:temperate_flower_forest", - "dappled_forest": "sanctuary:temperate_dappled_forest" + "type": "sanctuary:layered_island", + "meadow": "sanctuary:layered_meadow", + "flower_meadow": "sanctuary:layered_flower_meadow", + "lower_grove": "sanctuary:layered_lower_grove", + "mossy_terraces": "sanctuary:layered_mossy_terraces", + "rocky_terraces": "sanctuary:layered_rocky_terraces" }, - "settings": "sanctuary:sanctuary_natural" + "settings": "sanctuary:sanctuary_layered" } }, "minecraft:the_end": { diff --git a/mods/sanctuary/src/main/resources/sanctuary.mixins.json b/mods/sanctuary/src/main/resources/sanctuary.mixins.json index 39c8f63..8053e7c 100644 --- a/mods/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/mods/sanctuary/src/main/resources/sanctuary.mixins.json @@ -4,7 +4,8 @@ "compatibilityLevel": "JAVA_25", "mixins": [ "InitialSpawnMixin", - "SurfaceHydrologyMixin" + "SurfaceHydrologyMixin", + "LayeredSpringOutletMixin" ], "client": [ "client.SanctuarySkyMixin", diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/LayeredEcologySmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/LayeredEcologySmoke.java new file mode 100644 index 0000000..7f932d2 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/LayeredEcologySmoke.java @@ -0,0 +1,56 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.EnumSet; + +/** Lower-layer decoration must find an existing substantial ledge, never manufacture its floor. */ +public final class LayeredEcologySmoke { + public static void main(String[] args) { + LowerTerraceEcology.Sampler layered = (x, y, z) -> + (y >= 240 && y <= 248) || (y >= 170 && y <= 190) ? 1 : -1; + int highest = LowerTerraceEcology.highestNaturalSurface(layered, 0, 0); + require(highest == 248, "The highest natural layer must be identified independently of plants"); + require(LowerTerraceEcology.isLowerTerrace(layered, 0, 190, 0, highest), + "A thick lower floor with headroom and an upper layer must qualify"); + require(!LowerTerraceEcology.isLowerTerrace(layered, 0, highest, 0, highest), + "Ordinary highest-surface vegetation must not be decorated again as a lower terrace"); + require(!LowerTerraceEcology.isLowerTerrace(layered, 0, 189, 0, highest), + "A buried block is not an exposed floor"); + LowerTerraceEcology.Sampler thin = (x, y, z) -> y < 189 ? -1 : layered.sample(x, y, z); + require(!LowerTerraceEcology.isLowerTerrace(thin, 0, 190, 0, highest), + "A floor thinner than three natural blocks must remain untouched"); + LowerTerraceEcology.Sampler perforated = (x, y, z) -> y == 189 ? 0 : layered.sample(x, y, z); + require(!LowerTerraceEcology.isLowerTerrace(perforated, 0, 190, 0, highest), + "Zero-density holes in a floor must not become supports"); + LowerTerraceEcology.Sampler lowRoof = (x, y, z) -> y == 192 ? 1 : layered.sample(x, y, z); + require(!LowerTerraceEcology.isLowerTerrace(lowRoof, 0, 190, 0, highest), + "A terrace without two blocks of natural headroom must remain untouched"); + LowerTerraceEcology.Sampler single = (x, y, z) -> y >= 170 && y <= 190 ? 1 : -1; + require(!LowerTerraceEcology.isLowerTerrace(single, 0, 190, 0, + LowerTerraceEcology.highestNaturalSurface(single, 0, 0)), "An isolated highest surface is not a lower layer"); + require(LowerTerraceEcology.highestNaturalSurface((x, y, z) -> -1, 0, 0) == -1, + "The void must contain no invented surface"); + require(!LowerTerraceEcology.isLowerTerrace((x, y, z) -> -1, 0, 100, 0, -1), + "The void must receive no lower-terrace decoration"); + + var lower = LayeredBiomePalette.select(96, 0, 0.2F); + var middle = LayeredBiomePalette.select(184, 0, 0.2F); + var upper = LayeredBiomePalette.select(260, 0, 0.2F); + require(lower != middle && lower != upper && middle != upper, + "The same horizontal climate must expose distinct biomes on separate layers"); + require(upper == LayeredBiomePalette.Kind.FLOWER_MEADOW, + "A moist temperate upper layer must retain a flowered meadow"); + require(LayeredBiomePalette.select(220, -0.2F, 0.2F) + != LayeredBiomePalette.select(220, 0.2F, 0.2F), "Layer boundaries must follow horizontal climate variation"); + EnumSet palette = EnumSet.noneOf(LayeredBiomePalette.Kind.class); + for (int y = 32; y <= 312; y += 4) for (int v = -10; v <= 10; v++) for (int m = -10; m <= 10; m++) { + palette.add(LayeredBiomePalette.select(y, v / 10.0F, m / 10.0F)); + } + require(palette.equals(EnumSet.allOf(LayeredBiomePalette.Kind.class)), + "Every declared starter biome must be reachable within the island's vertical bounds"); + System.out.println("LayeredEcologySmoke: supported lower floors, headroom, void exclusion, distinct vertical biomes and warped boundaries passed."); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/LayeredHydrologySmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/LayeredHydrologySmoke.java new file mode 100644 index 0000000..98a5200 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/LayeredHydrologySmoke.java @@ -0,0 +1,150 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Determinism and containment checks independent of Minecraft, using controlled floating terrain. */ +public final class LayeredHydrologySmoke { + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + public static void main(String[] args) { + LayeredHydrology.Sampler terrain = (x, y, z) -> { + int top = 200 + (int) Math.round(2 * Math.sin(x / 24.0) + 2 * Math.cos(z / 31.0)); + return Math.hypot(x, z) < 220 && y <= top && y >= 145 ? 0.45F : -0.45F; + }; + var first = LayeredHydrology.create(0, terrain); + var replay = LayeredHydrology.create(0, terrain); + var changed = LayeredHydrology.create(42, terrain); + require(!first.features().isEmpty(), "The safe rolling surface should contain surface water"); + require(List.copyOf(first.cells()).equals(List.copyOf(replay.cells())) && first.features().equals(replay.features()) + && first.springs().equals(replay.springs()), + "The same seed and density must replay exactly"); + require(!List.copyOf(first.cells()).equals(List.copyOf(changed.cells())), "The world seed must influence water placement"); + verify(first, terrain); + verify(changed, terrain); + LayeredHydrology.Sampler pierced = (x, y, z) -> + Math.floorMod(x, 37) < 2 && Math.floorMod(z, 41) < 2 ? -0.45F : terrain.sample(x, y, z); + var aroundShafts = LayeredHydrology.create(0, pierced); + require(!aroundShafts.features().isEmpty(), "Safe areas around natural shafts should remain usable"); + verify(aroundShafts, pierced); + require(LayeredHydrology.create(0, (x, y, z) -> -1).cells().isEmpty(), "An empty world must not acquire a guaranteed basin"); + var roof = LayeredHydrology.create(0, (x, y, z) -> + Math.hypot(x, z) < 220 && (y == 240 || y >= 145 && y <= 200) ? 0.45F : -0.45F); + require(roof.cells().isEmpty(), "Do not create underground lakes below a thin higher roof"); + var slab = LayeredHydrology.create(0, (x, y, z) -> + Math.hypot(x, z) < 220 && y >= 199 && y <= 200 ? 0.45F : -0.45F); + require(slab.cells().isEmpty(), "A thin floating slab cannot safely support water"); + var flat = LayeredHydrology.create(0, (x, y, z) -> + Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F); + verify(flat, (x, y, z) -> Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F); + require(!flat.springs().isEmpty(), "A natural cliff over the void must exercise rock spring placement"); + long wet = flat.cells().stream().filter(LayeredHydrology.Cell::hasWater).count(); + require(flat.cells().size() > wet * 2, "Sediment sheets must extend beyond a narrow one-block ring"); + require(flat.cells().stream().anyMatch(cell -> cell.material() == LayeredHydrology.ShoreMaterial.STONE), + "Some natural shores should be rocky"); + require(flat.cells().stream().anyMatch(cell -> cell.material() == LayeredHydrology.ShoreMaterial.GRASS), + "Some natural shores should retain grassy terrain"); + require(flat.cells().stream().anyMatch(cell -> cell.sedimentDepth() == 5), "Some deposits should be five blocks thick"); + require(flat.features().stream().allMatch(feature -> feature.path().size() == 1), + "Surface features should be basins rather than forced short channels"); + LayeredHydrology.Sampler longSlope = (x, y, z) -> { + int top = 230 - Math.floorDiv(x + 192, 16); + return Math.hypot(x, z) < 235 && y >= 80 && y <= top ? 0.45F : -0.45F; + }; + var layered = LayeredHydrology.create(0, longSlope); + verify(layered, longSlope); + require(layered.springs().stream().anyMatch(spring -> spring.source().y() + <= 230 - Math.floorDiv(spring.source().x() + 192, 16) - 24), + "The layered terrain must exercise a spring well below the highest surface"); + System.out.println("LayeredHydrologySmoke: closed surface basins, layered rock springs and safe deposits passed."); + } + + private static void verify(LayeredHydrology.Plan plan, LayeredHydrology.Sampler terrain) { + Map byChunks = new HashMap<>(); + // Reverse chunk traversal must reconstruct the same cells without generating neighbours. + for (int cx = 18; cx >= -18; cx--) for (int cz = 18; cz >= -18; cz--) { + var chunk = plan.cellsInChunk(cx, cz); + LayeredHydrology.Cell previous = null; + for (var cell : chunk) { + require(cell.x() >> 4 == cx && cell.z() >> 4 == cz, "A chunk slice writes outside its chunk"); + require(previous == null || previous.x() < cell.x() || previous.x() == cell.x() && previous.z() < cell.z(), + "Cell order must be stable for seeded shore vegetation"); + previous = cell; + require(byChunks.put(cell.x() + "," + cell.z(), cell) == null, "A cell belongs to multiple chunk slices"); + } + } + require(byChunks.size() == plan.cells().size(), "Chunk slices omitted planned cells"); + for (var cell : plan.cells()) { + require(cell.equals(byChunks.get(cell.x() + "," + cell.z())), "Chunk traversal changed the plan"); + for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) { + require(terrain.sample(cell.x(), y, cell.z()) > 0, "The entire sediment layer and two supporting blocks must already exist"); + } + require(cell.sedimentDepth() >= 3 && cell.sedimentDepth() <= 5, "Sediment thickness must vary between 3 and 5"); + if (!cell.hasWater()) { + require(cell.carveTop() - cell.bedY() >= 0 && cell.carveTop() - cell.bedY() <= 2, + "Dry terraces must remove at most two blocks"); + continue; + } + require(cell.waterY() - cell.bedY() >= 1 && cell.waterY() - cell.bedY() <= LayeredHydrology.MAX_DEPTH, "Invalid shallow-water depth"); + require(cell.carveTop() - cell.bedY() <= LayeredHydrology.MAX_CARVE, "Excavation exceeds four blocks"); + for (int y = cell.carveTop() + 1; y < 384; y++) { + require(terrain.sample(cell.x(), y, cell.z()) <= 0, "Water placed below a natural roof"); + } + for (int[] d : CARDINALS) { + int x = cell.x() + d[0], z = cell.z() + d[1]; + var neighbor = plan.cellAt(x, z); + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + boolean plannedWater = neighbor != null && neighbor.hasWater() + && y > neighbor.bedY() && y <= neighbor.waterY(); + boolean carvedAir = neighbor != null && y > neighbor.bedY() && y <= neighbor.carveTop() + && !plannedWater; + require(plannedWater || !carvedAir && terrain.sample(x, y, z) > 0, + "A water cell has an open horizontal outlet, including excavated banks: " + cell + " toward " + x + "," + y + "," + z + " neighbor=" + neighbor); + } + } + } + for (var spring : plan.springs()) { + var source = spring.source(); + var outlet = spring.outlet(); + require(terrain.sample(source.x(), source.y(), source.z()) > 0, "Spring must replace existing rock"); + require(terrain.sample(source.x(), source.y() + 1, source.z()) > 0, "Rock spring must have a natural roof"); + require(Math.abs(source.x() - outlet.x()) + Math.abs(source.z() - outlet.z()) == 1 + && source.y() == outlet.y(), "Spring outlet must be adjacent to its source"); + for (int[] direction : CARDINALS) { + int x = source.x() + direction[0], z = source.z() + direction[1]; + if (x == outlet.x() && z == outlet.z()) continue; + require(terrain.sample(x, source.y(), z) > 0, "A spring niche must have three natural lateral walls"); + } + for (int y = source.y() - 3; y < source.y(); y++) { + require(terrain.sample(source.x(), y, source.z()) > 0, "Source needs an intact natural rock floor"); + } + for (int y = source.y(); y >= source.y() - 8; y--) { + require(terrain.sample(outlet.x(), y, outlet.z()) <= 0, "Waterfall needs an exposed natural drop"); + } + for (var cell : plan.cells()) { + int dy = Math.max(Math.max(cell.bedY() - cell.sedimentDepth() - source.y(), 0), source.y() - cell.carveTop()); + require(Math.hypot(Math.hypot(source.x() - cell.x(), source.z() - cell.z()), dy) >= 32, + "Rock springs must stay away from modified terrain in three dimensions"); + } + require(spring.flowBounds().minY() <= source.y() - 12, "Spring requires a real descending route"); + require(spring.flowPath().getFirst().equals(source), "Flow path starts at the single carved source"); + LayeredHydrology.Position previous = null; + for (var position : spring.flowPath()) { + require(plan.allowsSpringFlow(position.x(), position.y(), position.z()), "Path must lie inside the flow envelope"); + require(position.equals(source) || terrain.sample(position.x(), position.y(), position.z()) <= 0, + "Waterfall path must use existing air; no cliff excavation or rock filling"); + if (previous != null) require(position.y() <= previous.y() + && Math.abs(position.x() - previous.x()) + Math.abs(position.y() - previous.y()) + + Math.abs(position.z() - previous.z()) == 1, "Flow path must be contiguous and never ascend"); + previous = position; + } + require(plan.springsInChunk(source.x() >> 4, source.z() >> 4).contains(spring), "Spring belongs to its source chunk"); + require(!plan.allowsSpringFlow(10000, source.y(), 10000), "Bare-terrain prediction must exclude unrelated void water"); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/packwiz/README.md b/packwiz/README.md index 159df3c..e9ec3bf 100644 --- a/packwiz/README.md +++ b/packwiz/README.md @@ -51,7 +51,7 @@ Pour produire un fichier importable par un lanceur compatible Modrinth : ```sh cd build/packwiz # depuis la racine du dépôt -packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.5.mrpack +packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.6.mrpack ``` L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API. diff --git a/packwiz/pack.toml b/packwiz/pack.toml index f6a92e3..103bea5 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.5" +version = "0.1.0-alpha.6" pack-format = "packwiz:1.1.0" [index]