diff --git a/CHANGELOG.md b/CHANGELOG.md index 13526f5..199dbbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.0-alpha.10 — 2026-09-09 + +- Trois tailles de départ : **Sanctuary (20 joueurs)** par défaut, plus les + variantes 5 et 100 joueurs. Diamètres nominaux de 512, 1 024 et environ + 2 290 blocs, hauteur de 384 blocs ; aucune limite de connexions modifiée. +- Nouvelles générations `sanctuary:population_5`, `population_20` et + `population_100`. Forêts, cavités, décorations et plans d’eau/lave couvrent + la nouvelle emprise par régions locales ; une structure hydraulique qui + dépasse son domaine est écartée en entier. +- Capacité persistante sur disque, ancien réglage alpha.9 conservé ; + 212 anciens fichiers de production inchangés et huit fichiers de liaison + ou d’interface adaptés. Aucune ancienne île agrandie ou régénérée. + +Validation : `check build assemblePack` réussi, puis onze tests moteur +réussis pour chacun des profils 5/20/100 sur la graine `0`, avec 1 800 ticks +réels. Le MRpack et le ZIP d’amorçage sont vérifiés localement. Les mesures +sont limitées aux chunks inspectés ; elles ne constituent ni un inventaire +global ni une simulation de cent joueurs. Le premier plan local reste coûteux +et la génération en masse reste à optimiser. Publication et synchronisations +du canal sont consignées séparément dans [Distribution](docs/packwiz.md). + ## 0.1.0-alpha.9 — 2026-09-09 - Cavités luxuriantes éclairées par leur végétation, secteurs à spéléothèmes diff --git a/README.md b/README.md index d92971d..9b89b55 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ décrits dans la vision ne sont pas encore implémentés. profonde, puis plus haut si nécessaire. Une à deux sources profondes supplémentaires peuvent former des coulées, vérifiées par les ticks du moteur. -L'île a une emprise nominale de 512 blocs de diamètre, avec une bordure sculptée +Jusqu’à l’alpha.9, 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 océans, les ruines et @@ -86,6 +86,38 @@ anciennes sauvegardes alpha.5. Aucun lac, groupe de terrasses, geyser ou stock de ressources n’est garanti sur chaque graine. Voir le ticket local [WG-10](docs/backlog.md). +## Alpha.10 : îles pour 5, 20 ou 100 joueurs + +Trois tailles de départ sont disponibles, avec **20 joueurs par défaut**. +Leur aire nominale est proportionnelle à la capacité choisie, sur la base +de l’île de 512 blocs pour cinq joueurs. La hauteur reste de 384 blocs. + +| Choix à la création | Identifiant du preset | Capacité | Diamètre nominal | +| --- | --- | --- | --- | +| Sanctuary 5 joueurs | `sanctuary:sanctuary_5` | 5 | 512 blocs | +| Sanctuary, choix par défaut | `sanctuary:sanctuary` | 20 | 1 024 blocs | +| Sanctuary 100 joueurs | `sanctuary:sanctuary_100` | 100 | Environ 2 290 blocs | + +Ces capacités dimensionnent l’île ; elles ne fixent pas la limite de +connexions du serveur. Le contour réel dépend du relief et de la graine, +et le stock de ressources reste celui de la génération. La taille se choisit +pour un **nouveau monde** : aucune ancienne île n’est agrandie et aucune +sauvegarde n’est régénérée lors de la mise à jour. +Les paramètres sauvegardés sont distincts : `sanctuary:population_5`, +`sanctuary:population_20` et `sanctuary:population_100`. + +**Les trois profils passent les onze tests moteur sur la graine `0`, avec +1 800 ticks réels de fluides.** Le choix de capacité persiste après écriture +et relecture sur disque. La vérification conserve 212 anciens fichiers de +production strictement identiques ; huit fichiers d’intégration ou d’interface +accueillent les nouveaux choix. La première planification locale reste coûteuse, +environ 83–88 secondes pour les premiers essais 5/20 sur cette configuration. +La génération en masse reste à optimiser. Ces essais ne simulent pas +100 connexions et ne donnent aucune garantie de performances multijoueurs. +Les diamètres nominaux ne permettent pas d’extrapoler les stocks ou l’aire +réellement habitable. Voir [WG-11](docs/backlog.md), [Validation](docs/testing.md) +et l’état de [Distribution](docs/packwiz.md). + Le premier champ de recherche climatique est défini : nord froid, sud chaud, ouest sec et est humide, avec leurs combinaisons diagonales. Un outil de développement relève les ressources après génération ; le système d'expansion @@ -108,7 +140,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.9 | +| Sanctuary / pack | 0.1.0-alpha.10 | 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 @@ -135,7 +167,7 @@ décrits dans [Validation](docs/testing.md). Résultats : -- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.9.jar` : mod à installer avec +- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.10.jar` : mod à installer avec Fabric API sur la version Minecraft indiquée. - `build/packwiz/` : pack de développement complet, avec le mod construit et l'index vérifié. Voir [Installation packwiz](packwiz/README.md). diff --git a/docs/backlog.md b/docs/backlog.md index c25d038..fdc06b8 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -14,6 +14,7 @@ faire avant de clôturer tous leurs critères. WG-04 à WG-07 restent ouverts, avec les incréments de l’île initiale décrits ci-dessous. WG-08 est validé ; WG-09 est livré en alpha.8, avec mesures moteur et mise à jour Prism vérifiées. WG-10 est livré en alpha.9, avec mesures moteur et synchronisation Prism vérifiées. +WG-11 ajoute les tailles alpha.10 pour 5, 20 ou 100 joueurs, validées sur la graine 0. Résultat visé : un client et un serveur Fabric compatibles peuvent ouvrir un monde Sanctuary, plusieurs joueurs y arrivent sur une même île sûre et les chunks extérieurs restent vides. La génération est reproductible et ses limites sont documentées. @@ -310,6 +311,51 @@ réussissent, avec 260 fichiers personnels et réglages conservés. Ce ticket local n’est pas une issue distante publiée ; l’essai visuel et l’équilibrage restent ouverts. +### WG-11 — Tailles d’île selon la capacité — alpha.10 validée + +**Branche :** `codex/player-capacity-presets`. + +**But :** choisir une île initiale adaptée à un groupe de 5, 20 ou 100 joueurs, +avec 20 joueurs par défaut, tout en conservant l’ambiance et les anciennes parties. + +**Critères d’acceptation :** + +- La création d’un nouveau monde propose trois choix identifiables en FR/EN : + Sanctuary par défaut pour 20 joueurs et des variantes pour 5 et 100 joueurs. + Les presets `sanctuary:sanctuary_5`, `sanctuary:sanctuary` et + `sanctuary:sanctuary_100` sélectionnent respectivement les paramètres + `sanctuary:population_5`, `sanctuary:population_20` et + `sanctuary:population_100`, qui doivent persister dans le monde sauvegardé. +- L’aire nominale suit le rapport capacité/5. Les diamètres de référence sont + 512, 1 024 et environ 2 290 blocs ; la hauteur reste de 384 blocs. Le contour + sculpté dépend de la graine et n’est pas remplacé par un cercle ou un cylindre. +- Le choix dimensionne la génération, sans modifier `max-players`, imposer + un quota de ressources ni redimensionner l’île lorsque des joueurs arrivent. +- Les tailles conservent un extérieur vide, un spawn naturel, les forêts et + l’écologie des cavités. Les eaux et les décors doivent être contrôlés dans + les vrais chunks de la taille testée, pas seulement extrapolés depuis l’alpha.9. +- Les réglages et identifiants des générations précédentes restent séparés. + Une mise à jour du pack n’agrandit pas un ancien monde, ne convertit pas son + générateur et ne régénère aucun chunk. +- La validation progresse de 5 à 20 puis 100 joueurs dans des mondes de + développement neufs. Chaque résultat précise capacité, graine, emprise + réellement inspectée et durée observée. Un petit échantillon ne valide pas + l’intégralité d’une grande île. +- Les tests de génération ne sont pas présentés comme des connexions de + joueurs simulées ni comme une promesse de performances multijoueurs. +- Après validation : `check build assemblePack`, artefacts immuables vérifiés, + deux synchronisations isolées puis deux dans la même instance Prism, + avec sauvegardes et réglages conservés. + +**État au 9 septembre 2026 :** implémentation et validation moteur terminées +sur la graine `0` pour les profils 5, 20 et 100. Les onze tests requis passent +dans chaque monde, avec les fluides pendant 1 800 ticks et la persistance réelle +des réglages sur disque. Le build et les artefacts locaux sont vérifiés ; +la publication et les synchronisations sont suivies dans [Distribution](packwiz.md). +Les anciens mondes restent inchangés. Les observations ne couvrent pas toute +l’île de 100 joueurs, et le coût initial d’un plan régional reste notable. +Ce ticket local n’est pas une issue distante publiée. + ## 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 443ed08..371b5cc 100644 --- a/docs/expansion.md +++ b/docs/expansion.md @@ -5,6 +5,21 @@ registre persistant des continents, leur déblocage et leur génération dans le huit directions restent à implémenter. Le relevé de ressources décrit plus bas est un outil de développement séparé, exécuté dans le monde jetable des GameTests. +## Capacité de l’île initiale — alpha.10 + +Le choix de départ propose 5, 20 ou 100 joueurs, avec 20 par défaut. L’aire +nominale est proportionnelle à la capacité : base de 512 blocs de diamètre +pour cinq joueurs, puis 1 024 pour vingt et environ 2 290 pour cent. +La hauteur reste de 384 blocs. Le contour réel et les stocks dépendent de +la génération ; l’aire nominale ne garantit pas une surface constructible +ou une quantité de minerai par joueur. + +Cette taille est choisie pour un nouveau monde et ne remplace pas le futur +déblocage collectif de continents. Elle ne redimensionne aucune sauvegarde +existante et ne change pas la limite de connexions du serveur. Les contrôles +de génération des trois tailles passent sur la graine `0`, dans les emprises +consignées dans [Validation](testing.md), sans simuler cent joueurs simultanés. + ## Les huit directions climatiques Sanctuary Island constitue le point de départ **tempéré, légèrement humide**. @@ -135,16 +150,30 @@ n'est inclus dans ces comptes de blocs. ``` Les modes sont transmis à la propriété JVM de test -`sanctuary.test.resourceSurvey`. Le mode `island` couvre **1 444 chunks** : -`X` et `Z` de **−304 inclus à 304 exclu**, correspondant à l'enveloppe de terrain -de rayon 288 et une marge de décoration de 16 blocs. Un halo d'un chunk porte -la préparation à **1 600 chunks FULL** ; seuls les 1 444 chunks demandés sont -comptés. Ce relevé complet est plus coûteux et ne s'exécute pas par défaut. -La marge devra être revue si une future génération peut produire des structures -ou décorations au-delà de cette enveloppe. +`sanctuary.test.resourceSurvey`. La propriété Gradle `-PsanctuaryTestPlayers=5`, +`20` ou `100` choisit le nouveau profil ; sa valeur par défaut est `20`. +Le mode `island` calcule l’enveloppe de la capacité active, avec 16 blocs de +marge de décoration et un halo de préparation d’un chunk. Les tailles ci-dessous +sont les bornes calculées du travail demandé, pas des relevés déjà exécutés : + +| Profil | Chunks comptés par `island` | Chunks FULL préparés avec halo | +| --- | --- | --- | +| 5 | 1 444 | 1 600 | +| 20 | 4 900 | 5 184 | +| 100 | 22 500 | 23 104 | + +Un relevé de toute l’emprise peut être très coûteux et ne s’exécute pas par +défaut. La marge devra être revue si une future génération produit des +structures ou décorations qui la dépassent. Les diagnostics alpha.10 courants +restent des inspections ciblées ; leurs nombres de minerais ne sont pas ceux +d’un relevé `island` complet. + +Les nouveaux fichiers sont nommés +`mods/sanctuary/build/run/gameTest/diagnostics/resource-survey-players--seed--.json`. +Sur un générateur historique, le nom émis par l’outil actuel porte `legacy`. +Les anciens chemins `resource-survey-seed-…` cités plus bas désignent uniquement +les relevés archivés de leurs versions respectives. -Le JSON est écrit dans -`mods/sanctuary/build/run/gameTest/diagnostics/resource-survey-seed--.json`. Il contient : - La graine, la version Minecraft, le preset et les réglages de génération, diff --git a/docs/packwiz.md b/docs/packwiz.md index 0f05ed6..b7e1796 100644 --- a/docs/packwiz.md +++ b/docs/packwiz.md @@ -15,6 +15,11 @@ de l'ancien pack 26.2. ## Livrer une mise à jour +L’alpha.10 est validée localement sur les trois capacités et ses artefacts +sont vérifiés. Les commandes ci-dessous ciblent cette version ; elles ne +constituent pas une preuve de publication. Le canal stable reste sur la +dernière livraison vérifiée jusqu’à la publication et aux synchronisations. + Dernière livraison vérifiée le 9 septembre 2026 : [Sanctuary 0.1.0-alpha.9](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.9), depuis le commit source `40b6555`. Le JAR, le `.mrpack` et le ZIP d’amorçage @@ -36,7 +41,7 @@ Après les vérifications, le commit et le push de la branche du ticket, le scri de publication réalise les étapes de release et de canal ci-dessous : ```sh -python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.9.mrpack +python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.10.mrpack ``` Les options sont facultatives. Ce script **publie** sur le Git configuré dans @@ -55,7 +60,7 @@ La procédure complète, également utilisable manuellement : 3. Préparer les métadonnées de la livraison, avec l'URL exacte du futur JAR : ```sh - python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.9/sanctuary-0.1.0-alpha.9.jar + python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.10/sanctuary-0.1.0-alpha.10.jar ``` 4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce @@ -161,6 +166,15 @@ alpha.8 conservent `sanctuary:sanctuary_rift` ; leur contenu n’est pas retouch par une mise à jour de la même instance Prism. Les anciennes sauvegardes et les réglages ont été conservés lors des deux synchronisations de livraison. +L’alpha.10 propose des choix de taille pour 5, 20 et 100 joueurs, avec +20 par défaut. Ils s’appliquent à un **nouveau monde**. Les profils sauvegardés `sanctuary:population_5`, +`sanctuary:population_20` et `sanctuary:population_100` sont séparés des +anciens réglages, notamment `sanctuary:sanctuary_cavern` de l’alpha.9. +La mise à jour de la même instance Prism n’agrandit aucune île +existante, ne modifie pas les paramètres de génération enregistrés et ne +nécessite pas de réimporter le ZIP d’amorçage. Les sauvegardes, captures, +packs graphiques et réglages personnels restent à comparer lors de la livraison. + Références officielles : [installation packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/), [commandes Prism](https://prismlauncher.org/wiki/help-pages/custom-commands/), [bootstrap v0.0.3](https://github.com/packwiz/packwiz-installer-bootstrap/releases/tag/v0.0.3). diff --git a/docs/testing.md b/docs/testing.md index 3732e32..e9592c0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -54,7 +54,9 @@ Deux adaptations y sont nécessaires, vérifiées contre les classes Minecraft - `GameTestServer` sélectionne normalement `minecraft:flat_all_dimensions`. Le mixin de test sélectionne directement le preset de production - `sanctuary:sanctuary`, sans recopier ses JSON. + `sanctuary:sanctuary` pour 20 joueurs par défaut. + `-PsanctuaryTestPlayers=5` ou `100` sélectionne sa variante de production, + sans recopier ses JSON. - Le framework désactive normalement les structures. Le test les active dans `WorldOptions` pour examiner les chunks après toutes les étapes de génération. La seed du serveur de test est `0` par défaut ; `-PsanctuaryTestSeed` est transmis @@ -65,8 +67,8 @@ Le test capture donc le spawn juste avant ce déplacement, après l'initialisati normale du monde, et examine les coordonnées absolues de l'île. Les scénarios suivants couvrent le socle et les générations historiques ; -les contrôles validés pour l’alpha.9 et les résultats historiques -propres à l’alpha.8 figurent plus bas : +les contrôles validés pour l’alpha.10 et les résultats historiques +des générations précédentes figurent plus bas : 1. Le vrai générateur Sanctuary est chargé et le spawn collectif repose sur une surface déjà présente dans la densité naturelle, avec un sol plein de 3×3 @@ -95,8 +97,9 @@ propres à l’alpha.8 figurent plus bas : 9. Les anciennes clés gardent leur traitement d’origine. Seuls les nouveaux paramètres activent le traitement ajouté pour leur génération. -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 +La recherche de spawn parcourt l'île finie par anneaux de quatre blocs, +jusqu’à la limite de terrain du profil choisi. Les anciens réglages conservent +leur borne de 288 blocs. Elle examine d'abord la hauteur brute pour éviter de générer entièrement les colonnes vides, puis vérifie le sol et les dégagements après décoration. Elle préfère une zone naturelle de 3×3 ; si aucune n'est trouvée, elle utilise le premier emplacement naturel sûr d'une colonne avec deux blocs @@ -190,6 +193,74 @@ Pour cet essai visuel en monde de développement créatif, comparer la même vue Y=63 ni assombrissement d'altitude au passage de Y=32. Les brouillards de l'eau, de la météo et des effets d'aveuglement ou d'obscurité doivent rester présents. +## Contrôles et validation alpha.10 — 9 septembre 2026 + +Les profils `sanctuary:population_5`, `population_20` et `population_100` +passent chacun les onze tests requis sur la graine `0`, avec **1 800 ticks de +fluides de production**. Aucun réveil manuel des sources naturelles n’est +ajouté par le diagnostic. `check build assemblePack` passe sur le profil 5 ; +les profils 20 puis 100 sont ensuite exécutés dans des mondes de test neufs. + +| Capacité | Diamètre nominal | Régions inspectées | Chunks FULL | Régions en cache au spawn → après inspection | Durée de la commande | +| --- | --- | --- | --- | --- | --- | +| 5 | 512 | (0, 0) | 110 | 1 → 1 | 3m 46s | +| 20 | 1 024 | (0, 0), (1, 0) | 185 | 1 → 2 | 6m 31s | +| 100 | environ 2 290 | (0, 0), (1, 1), (2, 0) | 290 | 1 → 4 | 11m 37s | + +La durée du profil 5 inclut les tâches de construction ; celles des profils +20/100 concernent `runGameTest`. Ces temps ne mesurent ni un tick en régime +établi ni une charge multijoueur. Le calcul du premier plan central reste +coûteux sur cette configuration : 5 joueurs : 87.710 s; 20 joueurs : 82.834 s; 100 joueurs : 85.761 s. +La génération en masse reste à optimiser. Le cache commence localement et +ne construit pas d’emblée toutes les régions de l’île. + +| Capacité | Colonnes de sédiments sélectionnées | Blocs d’eau retenus | Sources de lave retenues | Sources d’eau / débordements / coulées de lave testés | Témoins de réentrée sans marqueur | +| --- | --- | --- | --- | --- | --- | +| 5 | 3196 | 5656 | 19 | 1 / 1 / 1 | 3 | +| 20 | 3367 | 6319 | 20 | 2 / 3 / 2 | 3 | +| 100 | 4325 | 8106 | 57 | 3 / 4 / 3 | 3 | + +Les témoins vérifient les fonds et sédiments après décoration, la conservation +des sources, les composantes d’écoulement réellement descendantes et les +supports de lave naturels. Les débordements sélectionnés rejoignent leur bassin +cible ou leur chute terminale. Le rappel du post-traitement sans marqueur +préserve une modification ultérieure de l’exutoire. Les nombres décrivent +uniquement les éléments sélectionnés dans les chunks indiqués. + +| Capacité | Pas horizontal du relevé de densité | Colonnes avec roche | Colonnes vides | Colonnes avec roche au-delà de l’ancienne emprise | +| --- | --- | --- | --- | --- | +| 5 | 9 | 1015 | 3210 | 0 | +| 20 | 17 | 1753 | 2472 | 790 | +| 100 | 37 | 2375 | 1850 | 2170 | + +Ces échantillons de densité attestent des masses naturelles, du vide et de +l’extension réelle des grandes tailles. Ils ne mesurent pas toute l’aire +habitable. Les comptes de blocs, troncs et minerais des régions inspectées +sont des constats locaux avant l’intervalle fluide ; ils ne doivent être +extrapolés ni en stocks de l’île entière ni en ressources par joueur. + +Le test de persistance utilise l’écriture et la lecture officielles de +Minecraft 26.3-pre-2 dans des dossiers temporaires. Il vérifie les trois clés +de paramètres enregistrées, la graine, **Sanctuary = 20 par défaut**, et un +témoin alpha.9 qui conserve `sanctuary:sanctuary_cavern`. Le smoke pur régional +vérifie le rejet atomique des bassins et groupes, les dépendances de sources, +les limites, les traductions signées et les index de cellules multihauteurs. +Le gel porte sur 267 fichiers de production ; 212 anciens fichiers restent +strictement identiques, huit fichiers de liaison/interface sont modifiés. + +Preuves locales ignorées : + +- `build/alpha10-check-build-final.log`, puis `build/alpha10-players20-seed0.log` + et `build/alpha10-players100-seed0.log`. +- `mods/sanctuary/build/run/gameTest/diagnostics/population--seed-0-engine.json` + et `population--seed-0-density.json` avec leurs CSV. +- `mods/sanctuary/build/run/gameTest/diagnostics/population-persistence-seed-0/round-trip.json`. + +Le JAR, le MRpack et le ZIP d’amorçage sont vérifiés localement. La publication +et les synchronisations de la même instance Prism sont suivies séparément dans +[Distribution](packwiz.md). Aucun test de cent joueurs simultanés ni garantie +de performances n’est annoncé. Les preuves alpha.9 suivantes restent historiques. + ## Contrôles et validation alpha.9 — 9 septembre 2026 Les mondes neufs des graines `0`, `42` et `8675309` utilisent les paramètres diff --git a/docs/vision.md b/docs/vision.md index 82998b6..2329051 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -50,6 +50,16 @@ Les intégrations communautaires envisagées comprennent Fabric API, Sodium, Iri L'île de Sanctuary est le point de départ commun. Elle flotte dans un vide qui ne doit pas être rempli par la génération habituelle du monde. Les continents suivants sont de vastes terres suspendues, créées progressivement selon une taille et une direction maîtrisées. +La capacité prévue du groupe choisit la taille initiale : **5, 20 ou +100 joueurs**, avec **20 par défaut**. L’aire nominale doit évoluer +proportionnellement à cette capacité, en prenant l’île de 512 blocs de +diamètre comme référence pour cinq joueurs. Cela donne 1 024 blocs pour +vingt joueurs et environ 2 290 pour cent joueurs, sur une hauteur conservée +de 384 blocs. Ce choix appartient à la création du monde ; il n’agrandit pas +une sauvegarde existante ni ne suit automatiquement les connexions. +Il s’agit d’un dimensionnement du terrain, sans quota de ressources imposé +ou garantie de performances. L’alpha.10 met ces trois tailles à disposition ; leurs contrôles moteur portent sur la graine `0` et des emprises explicites. + 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. De petits étangs ou lacs de surface diff --git a/docs/worldgen.md b/docs/worldgen.md index 7217fb3..f13645a 100644 --- a/docs/worldgen.md +++ b/docs/worldgen.md @@ -5,16 +5,20 @@ entourée de vide sans répétition d'îles à grande distance. C'est une premi tranche de génération pour Minecraft **26.3-pre-2 / Fabric**, pas encore le système d'expansion collective. -L’alpha.9 utilise les paramètres `sanctuary:sanctuary_cavern`, pour de nouveaux -mondes uniquement. Sa validation moteur passe sur les trois graines de référence. L’alpha.8 conserve -`sanctuary:sanctuary_rift` et ses résultats sur trois graines avec 1 800 ticks -de fluides ; ils ne valident pas la génération v9. Les observations de chaque -version restent séparées dans [Validation](testing.md). +L’alpha.10 propose trois tailles d’île pour 5, 20 ou 100 joueurs, avec +20 joueurs par défaut. Les trois profils passent les contrôles moteur sur +la graine `0`, dans des emprises explicitement inspectées. Les générations +historiques conservent leurs réglages, notamment `sanctuary:sanctuary_cavern` +pour l’alpha.9 et `sanctuary:sanctuary_rift` pour l’alpha.8. Les observations +de chaque version restent séparées dans [Validation](testing.md). ## Créer un monde En solo : créer un **nouveau** monde et choisir le type de monde **Sanctuary** dans les options de génération. Les types de monde vanilla restent disponibles. +L’alpha.10 utilise ce choix pour 20 joueurs par défaut, avec des variantes +5 et 100 joueurs. Les identifiants sont `sanctuary:sanctuary` (20), +`sanctuary:sanctuary_5` (5) et `sanctuary:sanctuary_100` (100). Sur un nouveau serveur Fabric disposant du mod et de Fabric API : @@ -24,6 +28,11 @@ level-name=sanctuary level-seed=0 ``` +Pour créer une variante alpha.10, remplacer seulement +`level-type` par `sanctuary:sanctuary_5` ou `sanctuary:sanctuary_100`, avec un +nouveau `level-name`. Ces exemples concernent une création de monde, pas le +changement de taille d’une sauvegarde existante. + La seed est libre. Modifier `level-type` ne convertit pas un monde existant : utiliser un nouveau `level-name` pour tester une autre génération. Les fichiers de test et les sauvegardes personnelles doivent rester distincts. @@ -31,7 +40,7 @@ de test et les sauvegardes personnelles doivent rester distincts. ## Socle de génération conservé Les points suivants décrivent le socle livré jusqu’à l’alpha.8. Les évolutions -alpha.9 et leur état de validation sont précisés dans la section v9 ci-dessous. +alpha.9 et alpha.10 sont précisées dans les sections v9 et v10 ci-dessous. - Le générateur reste celui de Minecraft, `minecraft:noise`. - La forme reprend le champ de bruit flottant actif en 26.2 : `old_blended_noise` @@ -77,6 +86,63 @@ chaque seed. L’accès à chaque progression vanilla et les ressources renouvel nécessitent des vérifications concrètes. Les stocks réels sont relevés après la génération, sans corriger une île pour atteindre un quota. +## Capacité de l’île — génération v10 + +L’alpha.10 propose trois tailles accessibles à la création d’un monde. +Le choix **Sanctuary** vise 20 joueurs par défaut, accompagné de variantes +pour 5 et 100 joueurs. Les presets et paramètres de bruit sont associés ainsi : + +| Preset de création | Paramètres enregistrés dans le monde | +| --- | --- | +| `sanctuary:sanctuary_5` | `sanctuary:population_5` | +| `sanctuary:sanctuary` | `sanctuary:population_20` | +| `sanctuary:sanctuary_100` | `sanctuary:population_100` | + +Les nouveaux réglages restent distincts des identifiants enregistrés dans +les anciennes sauvegardes. Modifier le preset +sélectionné pour une nouvelle partie ne convertit pas un monde existant. + +| Capacité de référence | Facteur horizontal | Aire nominale relative | Diamètre nominal | Hauteur | +| --- | --- | --- | --- | --- | +| 5 joueurs | 1 | 1 | 512 blocs | 384 blocs | +| 20 joueurs, défaut | 2 | 4 | 1 024 blocs | 384 blocs | +| 100 joueurs | √20, environ 4,472 | 20 | Environ 2 290 blocs | 384 blocs | + +Le facteur horizontal vaut `√(capacité / 5)` : doubler le diamètre multiplie +l’aire nominale par quatre. Cette relation décrit l’enveloppe de référence, +pas la surface réellement constructible après les trous, les falaises et +la sculpture de la bordure. Aucun cylindre central ni remplissage de secours +ne doit être ajouté pour obtenir l’aire théorique. La hauteur de dimension +reste `Y=0..383`. + +La capacité choisie reste un paramètre de génération. Elle ne modifie pas +`max-players`, n’agrandit pas l’île à chaque connexion et ne fixe aucun quota +de ressources. Les anciens mondes jusqu’à l’alpha.9 gardent leur génération +et leur taille ; leurs chunks ne sont ni remplacés ni régénérés. Le projet +de continents déverrouillables reste séparé de ces tailles de départ. + +Le bruit naturel reste échantillonné aux coordonnées du monde ; seule +l’enveloppe extérieure change de taille. Les plans hydrauliques sont construits +à la demande dans des régions de 512 blocs, avec une graine régionale distincte. +Les marges d’admission sont de 16 blocs pour l’eau et 32 pour la lave. Bassins, +berges et groupes de terrasses sont admis entiers ; les sources dépendant d’une +coupe écartée sont retirées aussi. Aucun trajet n’est tronqué à la frontière. +Les couches superposées et leurs sources sont ensuite traduites ensemble. + +Les forêts, minerais et décorations utilisent toute l’emprise du profil. +La recherche facultative d’un arbre remarquable reste unique et centrale. +Les sources et exutoires n’agissent que pendant la génération, avec les +marqueurs vanilla consommés ; recharger un ancien chunk ne rouvre pas une bouche. + +Les trois tailles passent onze tests requis sur la graine `0`, avec 1 800 ticks +de fluides et un aller-retour réel des paramètres sur disque. Le fichier +`data/minecraft/world_gen_settings.dat` conserve la clé du profil choisi. +212 anciens fichiers de production sont strictement identiques, tandis que +huit fichiers d’intégration ou d’interface exposent les nouvelles générations. +Le premier calcul d’une région reste coûteux ; les résultats ne sont pas un +test de cent connexions ni une garantie de génération rapide de toute l’île. +Voir les emprises et durées dans [Validation](testing.md). + ## Cavités et terrasses d’eau — génération v9 Le nouveau réglage est `sanctuary:sanctuary_cavern`, avec le biome source diff --git a/gradle.properties b/gradle.properties index 725ed57..14f3c3d 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.9 -pack_version=0.1.0-alpha.9 +mod_version=0.1.0-alpha.10 +pack_version=0.1.0-alpha.10 maven_group=fr.koka.sanctuary diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 9d1735c..4bf216d 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -33,6 +33,7 @@ tasks.register('cleanGameTestWorld', Delete) { tasks.named('runGameTest') { dependsOn('cleanGameTestWorld') systemProperty('sanctuary.test.seed', providers.gradleProperty('sanctuaryTestSeed').getOrElse('0')) + systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('20')) systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample')) systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true')) } @@ -203,3 +204,21 @@ tasks.register('cavernLavaSmoke', JavaExec) { mainClass = 'fr.koka.sanctuary.worldgen.CavernLavaSmoke' } tasks.named('check') { dependsOn('cavernLavaSmoke') } + +tasks.register('populationCapacitySmoke', JavaExec) { + group = 'verification' + description = 'Check supported capacities, area scaling, finite boundaries and regional ownership.' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.PopulationCapacitySmoke' +} +tasks.named('check') { dependsOn('populationCapacitySmoke') } + +tasks.register('populationRegionPlanSmoke', JavaExec) { + group = 'verification' + description = 'Check atomic regional admission, hydraulic dependencies and translated chunk indices.' + dependsOn('testClasses') + classpath = sourceSets.test.runtimeClasspath + mainClass = 'fr.koka.sanctuary.worldgen.PopulationRegionPlanSmoke' +} +tasks.named('check') { dependsOn('populationRegionPlanSmoke') } diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java new file mode 100644 index 0000000..31a2531 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java @@ -0,0 +1,598 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.*; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +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.properties.BlockStateProperties; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.block.state.BlockState; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** Bounded engine probes at the centre AND in newly available terrain. No whole-island scan. */ +public final class PopulationDiagnostics { + public static int regionsAtInitialSpawn; + private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST}; + private PopulationDiagnostics() {} + + static void verifyLegacyIsolation(GameTestHelper helper) { + var level = helper.getLevel(); + var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + var capacity = IslandCapacity.forGenerator(generator); + helper.assertTrue(capacity != null && capacity.players() == Integer.getInteger("sanctuary.test.players", 20), + "The engine must create the requested production world preset"); + helper.assertFalse(HydrologyRuntime.enabled(generator) || NaturalHydrologyRuntime.enabled(generator) + || LayeredHydrologyRuntime.enabled(generator) || WoodlandHydrologyRuntime.enabled(generator) + || RiftHydrologyRuntime.enabled(generator) || CavernHydrologyRuntime.enabled(generator), + "Only the population hydrology pipeline may run with the new settings"); + var settings = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); + for (var key : List.of(SanctuarySpawn.SETTINGS, SanctuarySpawn.RAISED_SETTINGS, SanctuarySpawn.HYDROLOGY_SETTINGS, + SanctuarySpawn.NATURAL_SETTINGS, SanctuarySpawn.LAYERED_SETTINGS, SanctuarySpawn.WOODLAND_SETTINGS, + SanctuarySpawn.RIFT_SETTINGS, SanctuarySpawn.CAVERN_SETTINGS)) { + var old = new NoiseBasedChunkGenerator(generator.getBiomeSource(), settings.getOrThrow(key)); + helper.assertFalse(PopulationHydrologyRuntime.enabled(old), "A saved settings key must stay isolated: " + key); + helper.assertFalse(new PopulationGrovesFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO), + "New trees must refuse older saved worlds"); + helper.assertFalse(new PopulationDecorationsFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO), + "New cave decorations must refuse older saved worlds"); + } + helper.assertTrue(generator.getBiomeSource() instanceof PopulationIslandBiomeSource, + "The biome palette must use the new versioned resource family"); + helper.assertTrue(generator.getBiomeSource().possibleBiomes().size() == 10 + && generator.getBiomeSource().possibleBiomes().stream().allMatch(b -> b.getRegisteredName().startsWith("sanctuary:population_")), + "All ten layers of the ecology must remain available at each capacity"); + } + + static void verifyDensity(GameTestHelper helper) throws IOException { + var level = helper.getLevel(); + var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + var capacity = IslandCapacity.forGenerator(generator); + var density = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow( + ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_" + capacity.players()))).value(); + helper.assertTrue(density instanceof PopulationIslandDensity, "Inspect the actual compiled population density"); + var island = (PopulationIslandDensity) density; + helper.assertTrue(island.capacity() == capacity, "Noise settings and density must agree on saved capacity"); + var random = level.getChunkSource().randomState(); + var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), level.getSeed(), generator.generatorSettings().value()); + int step = Math.max(8, (int) Math.ceil(capacity.terrainLimit() / 32.0)); + int extent = (int) Math.ceil(capacity.terrainLimit() / (double) step) * step; + int solidColumns = 0, outerColumns = 0, emptyColumns = 0; + Set bottomHeights = new TreeSet<>(); + StringBuilder map = new StringBuilder("x,z,bottom_y,top_y\n"); + for (int x = -extent; x <= extent; x += step) for (int z = -extent; z <= extent; z += step) { + int bottom = -1, top = -1; + for (int y = 8; y < 384; y += 8) { + float value = random.sampleBlockValueUncached(density, x, y, z); + float raw = random.sampleBlockValueUncached(island.terrain(), x, y - 64, z); + helper.assertTrue(Float.isFinite(value), "Density remains finite at every sampled position"); + helper.assertTrue(value <= 0 || raw > 0, "Enlarging the envelope must never invent terrain inside natural holes"); + if (value > 0) { if (bottom < 0) bottom = y; top = y; } + if (x % (step * 8) == 0 && z % (step * 8) == 0) + helper.assertTrue(Float.floatToIntBits(value) == Float.floatToIntBits(replay.sampleBlockValueUncached(density, x, y, z)), + "Seed and capacity must replay identically without depending on region request order"); + } + if (top >= 0) { + solidColumns++; + bottomHeights.add(bottom); + if (Math.hypot(x, z) > IslandShape.TERRAIN_LIMIT + 16) outerColumns++; + helper.assertTrue(Math.hypot(x, z) < capacity.terrainLimit(), "Terrain stays inside its finite declared boundary"); + } else emptyColumns++; + map.append(x).append(',').append(z).append(',').append(bottom).append(',').append(top).append('\n'); + } + helper.assertTrue(solidColumns > 100 && emptyColumns > 100 && bottomHeights.size() >= 4, + "The sampled island must contain natural masses, holes and a varied underside"); + helper.assertTrue(capacity.players() == 5 ? outerColumns == 0 : outerColumns > 100, + "The larger capacities must actually contain land beyond the old island boundary"); + Path directory = Path.of("diagnostics"); + Files.createDirectories(directory); + String prefix = "population-" + capacity.players() + "-seed-" + level.getSeed(); + Files.writeString(directory.resolve(prefix + "-density.csv"), map.toString()); + Files.writeString(directory.resolve(prefix + "-density.json"), json(Map.of("players", capacity.players(), + "seed", level.getSeed(), "step", step, "solid_columns", solidColumns, "empty_columns", emptyColumns, + "solid_columns_beyond_old_boundary", outerColumns, "underside_heights", bottomHeights, + "scope", "sparse actual density samples; no resource extrapolation", "passed", true))); + SanctuaryMod.LOGGER.info("Capacity {} density seed {}: {} land columns, {} beyond the old envelope", + capacity.players(), level.getSeed(), solidColumns, outerColumns); + } + + static void verifyExterior(GameTestHelper helper) { + var level = helper.getLevel(); + var capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator()); + int edge = Math.floorDiv(capacity.terrainLimit() + 48, 16) + 1; + for (int[] point : new int[][]{{edge,0},{-edge-1,0},{0,edge},{0,-edge-1},{256,0},{-257,0}}) { + var chunk = full(helper, level, point[0], point[1]); + for (var section : chunk.getSections()) helper.assertTrue(section.hasOnlyAir(), + "Completed terrain and decoration must remain void beyond this capacity: " + chunk.getPos()); + } + } + + static void verifyLowerMargin(GameTestHelper helper) { + var level = helper.getLevel(); + for (int[] point : new int[][]{{0,0},{-8,0},{8,0},{0,-8},{0,8}}) { + var chunk = full(helper, level, point[0], point[1]); + for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) for (int y = 0; y < 16; y++) { + var pos = new BlockPos(point[0]*16+x, y, point[1]*16+z); + var block = chunk.getBlockState(pos); + helper.assertTrue(block.isAir() || block.getFluidState().is(FluidTags.WATER) || block.getFluidState().is(FluidTags.LAVA), + "The increased horizontal size must not add a lower floor: " + pos); + } + } + } + + static void start(GameTestHelper helper) throws IOException { + var context = new Context(helper); + try { context.prepare(); } + catch (RuntimeException | Error | IOException e) { context.cleanup(); throw e; } + helper.onEachTick(() -> { + try { context.tick(); } + catch (RuntimeException | Error e) { context.cleanup(); throw e; } + }); + } + + private static final class Context { + final GameTestHelper helper; + final ServerLevel level; + final NoiseBasedChunkGenerator generator; + final IslandCapacity capacity; + final List regions = new ArrayList<>(); + final Map chunks = new TreeMap<>(); + final Set forced = new HashSet<>(); + final Set expectedWater = new HashSet<>(); + final Map selectedCells = new LinkedHashMap<>(); + final Map naturalLogs = new HashMap<>(); + final Set declaredWater = new HashSet<>(); + final Set expectedLava = new HashSet<>(); + final Set lavaSupports = new HashSet<>(); + final List pocketCells = new ArrayList<>(); + final List lavaFalls = new ArrayList<>(); + final List> flowReports = new ArrayList<>(); + int reentryWitnesses; + final List springs = new ArrayList<>(); + final List spills = new ArrayList<>(); + final List> regionReports = new ArrayList<>(); + long startTime; + boolean running, closed; + + Context(GameTestHelper helper) { + this.helper = helper; level = helper.getLevel(); + generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + capacity = IslandCapacity.forGenerator(generator); + } + + void prepare() throws IOException { + helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= 4, + "Initial spawn must plan only nearby regions, not materialize the large island: " + regionsAtInitialSpawn); + List targets = new ArrayList<>(); + targets.add(new IslandCapacity.Region(0,0)); + if (capacity.players() == 20) targets.add(new IslandCapacity.Region(1,0)); + if (capacity.players() == 100) { + targets.add(new IslandCapacity.Region(1,1)); + targets.add(new IslandCapacity.Region(2,0)); + } + Set requested = new TreeSet<>(); + for (var target : targets) { + SanctuaryMod.LOGGER.info("Capacity {}: inspecting hydrology in region {}", capacity.players(), target); + var region = PopulationHydrologyRuntime.regionPlan(generator, level.getChunkSource().randomState(), target); + regions.add(region); + var plan = region.water(); + checkOwnership(region); + var ids = new HashSet(); + plan.features().stream().filter(f -> f.kind() == PopulationHydrology.Kind.LAKE || f.kind() == PopulationHydrology.Kind.POND) + .max(Comparator.comparingInt(PopulationHydrology.Feature::waterCells)).ifPresent(f -> ids.add(f.id())); + if (!plan.terraces().isEmpty()) ids.addAll(plan.terraces().getFirst().basinIds()); + if (ids.isEmpty()) plan.features().stream().findFirst().ifPresent(f -> ids.add(f.id())); + for (var cell : plan.cells()) if (ids.contains(cell.featureId())) { + selectedCells.put(new BlockPos(cell.x(), cell.bedY(), cell.z()), cell); + request(requested, cell.x() >> 4, cell.z() >> 4, 1); + if (cell.hasWater()) for (int y = cell.bedY()+1; y <= cell.waterY(); y++) expectedWater.add(new BlockPos(cell.x(),y,cell.z())); + } + for (var spill : plan.spills()) if (ids.contains(spill.fromFeatureId())) { + spills.add(spill); request(requested, spill.flowBounds()); + } + if (!plan.springs().isEmpty()) { + var spring = plan.springs().getFirst(); springs.add(spring); + request(requested, spring.flowBounds()); expectedWater.add(pos(spring.source())); + } + // Inspect the finite pocket and at most one volcanic fall per selected region. + var lava = region.lava(); + Set allFallSources = new HashSet<>(), allFallSupports = new HashSet<>(); + for (var fall : lava.falls()) { + allFallSources.add(lavaPos(fall.source())); + fall.supports().forEach(p -> allFallSupports.add(lavaPos(p))); + } + for (var cell : lava.cells()) { + BlockPos p = new BlockPos(cell.x(), cell.y(), cell.z()); + if (allFallSources.contains(p)) continue; + pocketCells.add(cell); + request(requested, cell.x() >> 4, cell.z() >> 4, 1); + if (cell.lava()) expectedLava.add(p); + } + for (var support : lava.supports()) if (!allFallSupports.contains(lavaPos(support))) { + lavaSupports.add(lavaPos(support)); + request(requested, support.x() >> 4, support.z() >> 4, 1); + } + if (!lava.falls().isEmpty()) { + var fall = lava.falls().getFirst(); lavaFalls.add(fall); + request(requested, fall.flowBounds()); expectedLava.add(lavaPos(fall.source())); + fall.supports().forEach(p -> lavaSupports.add(lavaPos(p))); + } + // A small, independently selected patch proves full ecology beyond the former radius. + int ox = target.originX(), oz = target.originZ(); + int bestX = ox, bestZ = oz; int highest = -1; + for (int dx = -144; dx <= 144; dx += 48) for (int dz = -144; dz <= 144; dz += 48) { + int x = ox+dx, z=oz+dz; + int top = generator.getBaseHeight(x,z,net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE, + level,level.getChunkSource().randomState()); + if (top > highest) { highest=top; bestX=x; bestZ=z; } + } + helper.assertTrue(highest > 48, "Every inspected region must contain real natural terrain"); + request(requested, bestX >> 4, bestZ >> 4, 2); + regionReports.add(new LinkedHashMap<>(Map.of("region_x",target.x(),"region_z",target.z(), + "planned_features",plan.features().size(),"planned_cells",plan.cells().size(), + "planned_springs",plan.springs().size(),"planned_terraces",plan.terraces().size(), + "ecology_patch_x",bestX,"ecology_patch_z",bestZ))); + } + helper.assertTrue(!expectedWater.isEmpty(), "The test must inspect retained production water"); + if (capacity.players() > 5) helper.assertTrue(regions.stream().skip(1).anyMatch(r -> !r.water().cells().isEmpty()), + "Hydrology must occur on newly available terrain, not only around the old central island"); + SanctuaryMod.LOGGER.info("Capacity {}: preparing {} bounded FULL chunks",capacity.players(),requested.size()); + for (long packed : requested) { + var p=ChunkPos.unpack(packed); + if (level.setChunkForced(p.x(),p.z(),true)) forced.add(packed); + } + for (long packed : requested) { + var p=ChunkPos.unpack(packed); chunks.put(packed,full(helper,level,p.x(),p.z())); + } + for (long packed : requested) level.waitForEntities(ChunkPos.unpack(packed),0); + declaredWater.addAll(expectedWater); + captureNaturalLogs(); + captureEcology(); + export(false,0); + } + + void checkOwnership(PopulationHydrologyRuntime.RegionPlan region) { + var owner=region.region(); var plan=region.water(); + Set ids=new HashSet<>(); + for(var feature:plan.features()) helper.assertTrue(ids.add(feature.id()),"Feature IDs must be unique within a region"); + for(var cell:plan.cells()) { + helper.assertTrue(cell.x()>=owner.minX() && cell.x()<=owner.maxX() && cell.z()>=owner.minZ() && cell.z()<=owner.maxZ(), + "Hydrology must never be truncated at or written across region ownership: "+cell); + helper.assertTrue(ids.contains(cell.featureId()),"Each retained cell needs its complete owning feature"); + for(int y=cell.bedY()-cell.sedimentDepth()-1;y<=cell.bedY();y++) + helper.assertTrue(level.getChunkSource().randomState().sampleBlockValueUncached( + generator.generatorSettings().value().noiseRouter().finalDensity(),cell.x(),y,cell.z())>0, + "Regional translation must retain the real natural support below sediment: "+cell); + } + for(var terrace:plan.terraces()) helper.assertTrue(ids.containsAll(terrace.basinIds()), + "Admission must retain or reject a complete group of water terraces"); + for(var spill:plan.spills()) helper.assertTrue(ids.contains(spill.fromFeatureId()) + && (spill.toFeatureId()==-1 || ids.contains(spill.toFeatureId())), + "No cascade may refer to a basin removed by region admission"); + for(var bounds:region.lava().flowBounds()) helper.assertTrue(bounds.minX()>=owner.minX() && bounds.maxX()<=owner.maxX() + && bounds.minZ()>=owner.minZ() && bounds.maxZ()<=owner.maxZ(),"Volcanic flows must respect the same ownership"); + } + + void captureEcology() { + for (int i=0;i(); + long logs=0,ores=0,nonAir=0; + for(var chunk:chunks.values()) { + int x=chunk.getPos().getMinBlockX(),z=chunk.getPos().getMinBlockZ(); + if (!capacity.regionAt(x,z).equals(region)) continue; + for(var section:chunk.getSections()) section.getStates().count((state,count)->{ + if(!state.isAir()) counts.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(),(long)count,Long::sum); + }); + } + for(var entry:counts.entrySet()) { + nonAir+=entry.getValue(); + if(entry.getKey().endsWith("_log")) logs+=entry.getValue(); + if(entry.getKey().endsWith("_ore")) ores+=entry.getValue(); + } + helper.assertTrue(nonAir>1000,"Distant FULL chunks must contain the new island"); + helper.assertTrue(logs>0,"Trees must decorate each inspected region, including outside the old envelope"); + helper.assertTrue(ores>0,"Ore features must also decorate the new distant terrain"); + regionReports.get(i).put("observed_blocks",counts); + regionReports.get(i).put("observed_logs",logs); + regionReports.get(i).put("observed_ores",ores); + } + } + + void tick() { + if(closed || running || helper.getTick()<20) return; + if(!chunks.keySet().stream().allMatch(level::isPositionTickingWithEntitiesLoaded)) { + helper.assertTrue(helper.getTick()<800,"All forced inspection chunks must become ready for real fluid ticks"); + return; + } + running=true; startTime=level.getGameTime(); + verifyWater(); + verifyFinishedShell(); + verifyLava(); + helper.runAfterDelay(1800,()->{ + try { + helper.assertTrue(chunks.keySet().stream().allMatch(level::isPositionTickingWithEntitiesLoaded), + "Chunks must remain scheduler-ready throughout the measured fluid interval"); + verifyWater(); + verifyFinishedShell(); + verifyLava(); + for (var spring : springs) verifySpring(spring); + for(var spill:spills) { + Set destination=null; + if(spill.toFeatureId()!=-1) { + destination=new HashSet<>(); + for(var region:regions) for(var cell:region.water().cells()) if(cell.featureId()==spill.toFeatureId() && cell.hasWater()) + destination.add(new BlockPos(cell.x(),cell.waterY(),cell.z())); + } + int drop=connectedDescent(pos(spill.source()),pos(spill.outlet()),destination); + helper.assertTrue(drop >= (destination==null?12:4),"Each retained cascade must connect downhill to its basin or fall: "+spill); + } + for (var fall : lavaFalls) { + int descent = connectedDescent(lavaPos(fall.source()), lavaPos(fall.outlet()), null, true); + helper.assertTrue(descent >= 8, "A translated volcanic fall must descend through real lava ticks: " + fall.source()); + } + verifyPostProcessingReentry(); + export(true,level.getGameTime()-startTime); + helper.succeed(); + } catch (RuntimeException | Error failure) { + try { export(false, level.getGameTime() - startTime); } + catch (IOException e) { failure.addSuppressed(e); } + throw failure; + } catch(IOException e) {throw new IllegalStateException(e);} + finally {cleanup();} + }); + } + + PopulationHydrology.Plan waterPlanAt(int x, int z) { + var owner = capacity.regionAt(x, z); + return regions.stream().filter(r -> r.region().equals(owner)).map(PopulationHydrologyRuntime.RegionPlan::water) + .findFirst().orElse(null); + } + + PopulationHydrology.Cell cellAt(BlockPos p) { + var plan = waterPlanAt(p.getX(), p.getZ()); + return plan == null ? null : plan.cellAt(p.getX(), p.getY(), p.getZ()); + } + + float natural(BlockPos p) { + return level.getChunkSource().randomState().sampleBlockValueUncached( + generator.generatorSettings().value().noiseRouter().finalDensity(), p.getX(), p.getY(), p.getZ()); + } + + void captureNaturalLogs() { + for (BlockPos p : declaredWater) { + BlockState state = level.getBlockState(p); + if (state.getFluidState().is(FluidTags.WATER) && state.getFluidState().isSource()) continue; + var cell = cellAt(p); + boolean horizontal = state.is(BlockTags.LOGS) && state.hasProperty(BlockStateProperties.AXIS) + && state.getValue(BlockStateProperties.AXIS) != Direction.Axis.Y; + boolean lowerWater = declaredWater.contains(p.below()) + && level.getFluidState(p.below()).is(FluidTags.WATER) && level.getFluidState(p.below()).isSource(); + boolean bed = cell != null && cell.bedY() == p.getY() - 1 + && level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below()); + boolean beside = Arrays.stream(HORIZONTAL).map(p::relative).anyMatch(side -> declaredWater.contains(side) + && level.getFluidState(side).is(FluidTags.WATER) && level.getFluidState(side).isSource()); + helper.assertTrue(cell != null && horizontal && p.getY() == cell.waterY() && (lowerWater || bed) && beside, + "Only a supported horizontal surface log beside real retained source water may occupy a planned water voxel: " + p + "=" + state); + naturalLogs.put(p.immutable(), state); expectedWater.remove(p); + } + helper.assertTrue(expectedWater.size() + naturalLogs.size() == declaredWater.size(), + "Every selected water voxel must have an exact retained source or recorded log"); + } + + void verifyFinishedShell() { + for (var cell : selectedCells.values()) { + var plan = waterPlanAt(cell.x(), cell.z()); + var kind = plan.features().stream().filter(f -> f.id() == cell.featureId()).findFirst().orElseThrow().kind(); + int cut = !cell.hasWater() ? kind == PopulationHydrology.Kind.RIVER ? PopulationHydrology.MAX_RIVER_BANK_CARVE : 2 + : kind == PopulationHydrology.Kind.TERRACE ? PopulationTerraces.MAX_CARVE + : kind == PopulationHydrology.Kind.LAKE ? PopulationHydrology.MAX_LAKE_CARVE : PopulationHydrology.MAX_CARVE; + helper.assertTrue(cell.sedimentDepth() >= 3 && cell.sedimentDepth() <= 5 && cell.carveTop() - cell.bedY() <= cut, + "Regional sediment and carving must keep their feature-specific budgets: " + cell); + for (int d = 0; d <= cell.sedimentDepth() + 1; d++) { + BlockPos p = new BlockPos(cell.x(), cell.bedY() - d, cell.z()); + BlockState actual = level.getBlockState(p); + helper.assertTrue(natural(p) > 0 && actual.isCollisionShapeFullBlock(level, p), + "Every sediment layer and its two natural supports must remain full after generation and ticks: " + p + "=" + actual); + if (d >= cell.sedimentDepth()) continue; + BlockState expected = PopulationMaterials.sedimentBlock(cell, level.getSeed(), d); + boolean ore = actual.is(Blocks.COAL_ORE) || actual.is(Blocks.DEEPSLATE_COAL_ORE) + || actual.is(Blocks.IRON_ORE) || actual.is(Blocks.DEEPSLATE_IRON_ORE) + || actual.is(Blocks.COPPER_ORE) || actual.is(Blocks.DEEPSLATE_COPPER_ORE) + || actual.is(Blocks.GOLD_ORE) || actual.is(Blocks.DEEPSLATE_GOLD_ORE) + || actual.is(Blocks.REDSTONE_ORE) || actual.is(Blocks.DEEPSLATE_REDSTONE_ORE) + || actual.is(Blocks.LAPIS_ORE) || actual.is(Blocks.DEEPSLATE_LAPIS_ORE) + || actual.is(Blocks.DIAMOND_ORE) || actual.is(Blocks.DEEPSLATE_DIAMOND_ORE) + || actual.is(Blocks.EMERALD_ORE) || actual.is(Blocks.DEEPSLATE_EMERALD_ORE); + helper.assertTrue(actual.equals(expected) || expected.is(BlockTags.BASE_STONE_OVERWORLD) && ore + || expected.is(Blocks.GRASS_BLOCK) && actual.is(Blocks.DIRT), + "The selected regional sediment profile must survive decoration: " + p + " expected=" + expected + " actual=" + actual); + } + if (!cell.hasWater()) continue; + int maxDepth = kind == PopulationHydrology.Kind.TERRACE ? PopulationTerraces.MAX_DEPTH : PopulationHydrology.MAX_DEPTH; + helper.assertTrue(cell.waterY() > cell.bedY() && cell.waterY() - cell.bedY() <= maxDepth, + "Retained water must respect its feature depth limit"); + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) for (Direction direction : HORIZONTAL) { + BlockPos wall = new BlockPos(cell.x(), y, cell.z()).relative(direction); + var next = cellAt(wall); + boolean plannedWater = next != null && next.hasWater() && y > next.bedY() && y <= next.waterY(); + if (plannedWater || plan.isSpillOpening(wall.getX(), wall.getY(), wall.getZ())) continue; + helper.assertTrue(natural(wall) > 0 && level.getBlockState(wall).isCollisionShapeFullBlock(level, wall), + "A retained basin must preserve its actual natural underwater shell, except its declared outlet: " + wall); + } + if (kind == PopulationHydrology.Kind.TERRACE) { + for (int y = cell.carveTop() + 1; y <= cell.carveTop() + 4; y++) + helper.assertTrue(natural(new BlockPos(cell.x(), y, cell.z())) <= 0, "A cave terrace needs four natural headroom blocks"); + boolean roof = false; + for (int y = cell.carveTop() + 5; y < 384 && !roof; y++) roof = natural(new BlockPos(cell.x(), y, cell.z())) > 0; + helper.assertTrue(roof, "A lower terrace must remain below an existing natural roof"); + } else for (int y = cell.carveTop() + 1; y < 384; y++) + helper.assertTrue(natural(new BlockPos(cell.x(), y, cell.z())) <= 0, "A surface basin must be exposed to the natural sky"); + } + } + + void verifyLava() { + for (BlockPos p : expectedLava) helper.assertTrue(natural(p) > 0 + && level.getFluidState(p).is(FluidTags.LAVA) && level.getFluidState(p).isSource(), + "Every inspected lava source must replace natural rock and survive actual fluid ticks: " + p); + for (BlockPos p : lavaSupports) { + BlockState block = level.getBlockState(p); + helper.assertTrue(natural(p) > 0 && block.isCollisionShapeFullBlock(level, p) && !block.ignitedByLava(), + "A selected volcanic niche must keep its actual nonflammable natural support: " + p); + } + for (var cell : pocketCells) if (!cell.lava()) { + BlockPos p = new BlockPos(cell.x(), cell.y(), cell.z()); + BlockState block = level.getBlockState(p); + // Wall lichen can light the excavated chamber without obstructing its dry access. + helper.assertTrue(natural(p) > 0 && (block.isAir() || block.is(Blocks.GLOW_LICHEN)) + && block.getCollisionShape(level, p).isEmpty() && block.getFluidState().isEmpty(), + "The lava pocket must keep its excavated two-block approach and chamber accessible: " + p + " = " + block); + } + } + + void verifySpring(PopulationHydrology.Spring spring) { + var plan = waterPlanAt(spring.outlet().x(), spring.outlet().z()); + var channel = plan == null ? null : plan.cellAt(spring.outlet().x(), spring.outlet().z()); + if (channel != null && plan.isRiverCell(channel) && spring.outlet().y() > channel.waterY() + && spring.outlet().y() <= channel.carveTop()) { + int expectedDrop = spring.source().y() - channel.waterY(); + helper.assertTrue(expectedDrop >= 5, "A river inlet must start above its retained channel"); + Set destination = new HashSet<>(); + for (var cell : plan.cells()) if (cell.featureId() == channel.featureId() && cell.hasWater()) + destination.add(new BlockPos(cell.x(), cell.waterY(), cell.z())); + helper.assertTrue(connectedDescent(pos(spring.source()), pos(spring.outlet()), destination) >= expectedDrop, + "The translated inlet must actually connect downhill into its river"); + } else { + int predicted = spring.source().y() - spring.flowPath().stream().mapToInt(PopulationHydrology.Position::y).min().orElse(spring.source().y()); + helper.assertTrue(connectedDescent(pos(spring.source()), pos(spring.outlet()), null) >= Math.max(8, Math.min(16, predicted)), + "An independent spring must actually descend through vanilla ticks: " + spring.source()); + } + } + + void verifyPostProcessingReentry() { + var outlets = new LinkedHashSet(); + if (!springs.isEmpty()) outlets.add(pos(springs.getFirst().outlet())); + if (!spills.isEmpty()) outlets.add(pos(spills.getFirst().outlet())); + if (!lavaFalls.isEmpty()) outlets.add(lavaPos(lavaFalls.getFirst().outlet())); + for (BlockPos outlet : outlets) { + LevelChunk chunk = chunks.get(ChunkPos.pack(outlet)); + helper.assertTrue(chunk != null && Arrays.stream(chunk.getPostProcessing()).allMatch(m -> m == null || m.isEmpty()), + "Re-entry uses a finished inspected chunk with consumed generation markers"); + helper.assertTrue(PopulationSpringOutlets.capturePending(level, chunk).isEmpty(), + "An already generated chunk has no pending population outlet work"); + BlockState original = level.getBlockState(outlet); + BlockState witness = Blocks.GLOW_LICHEN.defaultBlockState().setValue(MultifaceBlock.getFaceProperty(Direction.UP), true); + try { + level.setBlock(outlet, witness, 2); + chunk.postProcessGeneration(level); + helper.assertTrue(level.getBlockState(outlet).equals(witness), + "Post-processing re-entry must preserve a later construction at a completed outlet: " + outlet); + } finally { level.setBlock(outlet, original, 2); } + helper.assertTrue(level.getBlockState(outlet).equals(original), "Restore the development-world witness exactly"); + reentryWitnesses++; + } + } + + boolean isFluid(BlockPos p, boolean lava) { + return lava ? level.getFluidState(p).is(FluidTags.LAVA) : level.getFluidState(p).is(FluidTags.WATER); + } + + void verifyWater() { + for (var entry : naturalLogs.entrySet()) { + BlockPos p = entry.getKey(); + helper.assertTrue(level.getBlockState(p).equals(entry.getValue()), "A recorded natural log must retain its exact state"); + helper.assertTrue(level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below()) + || level.getFluidState(p.below()).is(FluidTags.WATER) && level.getFluidState(p.below()).isSource(), + "A horizontal log must retain its original bed or source water underneath"); + helper.assertTrue(Arrays.stream(HORIZONTAL).map(p::relative).anyMatch(side -> expectedWater.contains(side) + && level.getFluidState(side).is(FluidTags.WATER) && level.getFluidState(side).isSource()), + "A horizontal log must remain beside actual retained water"); + } + for(var p:expectedWater) helper.assertTrue(level.getFluidState(p).is(FluidTags.WATER) + && level.getFluidState(p).isSource(),"Retained water must survive generation and vanilla fluid ticks: "+p+"="+level.getBlockState(p)); + } + + int connectedDescent(BlockPos source,BlockPos outlet,Set destination) { + return connectedDescent(source, outlet, destination, false); + } + + int connectedDescent(BlockPos source,BlockPos outlet,Set destination, boolean lava) { + helper.assertTrue(source.distManhattan(outlet)==1,"A natural outlet must adjoin its source"); + helper.assertTrue(isFluid(outlet, lava),"The production post-processing must leave a flowing outlet: "+outlet); + var seen=new HashSet(); var queue=new ArrayDeque(); + seen.add(outlet);queue.add(outlet);int drop=0;boolean reached=destination==null; + while(!queue.isEmpty() && seen.size()<150000) { + var at=queue.removeFirst(); drop=Math.max(drop,source.getY()-at.getY()); + if(destination!=null && destination.contains(at)) reached=true; + for(var direction:new Direction[]{Direction.DOWN,Direction.NORTH,Direction.SOUTH,Direction.EAST,Direction.WEST}) { + var next=at.relative(direction); + if(next.getY()<0 || !chunks.containsKey(ChunkPos.pack(next)) || seen.contains(next))continue; + if(isFluid(next, lava)) {seen.add(next);queue.addLast(next);} + } + } + helper.assertTrue(queue.isEmpty(), "The finite flow inspection must exhaust its search budget"); + helper.assertTrue(reached,"The actual downhill water component must reach its declared receiving basin"); + flowReports.add(Map.of("source", source, "outlet", outlet, "fluid", lava ? "lava" : "water", + "actual_descending_blocks", seen.size(), "actual_drop", drop, "reaches_destination", reached)); + return drop; + } + + void export(boolean passed,long elapsed) throws IOException { + Path output=Path.of("diagnostics","population-"+capacity.players()+"-seed-"+level.getSeed()+"-engine.json"); + Files.createDirectories(output.getParent()); + var report=new LinkedHashMap(); + report.put("players",capacity.players());report.put("seed",level.getSeed()); + report.put("mod_version",FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString()); + report.put("settings",generator.generatorSettings().getRegisteredName()); + report.put("regions_at_initial_spawn",regionsAtInitialSpawn); + report.put("regions_cached_after_inspection",PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState())); + report.put("regions",regionReports);report.put("inspected_full_chunks",chunks.size()); + report.put("retained_water_blocks",expectedWater.size());report.put("tested_springs",springs.size()); + report.put("tested_spills",spills.size());report.put("fluid_ticks",elapsed); + report.put("selected_sediment_columns", selectedCells.size()); + report.put("natural_horizontal_logs", naturalLogs.size()); + report.put("retained_lava_sources", expectedLava.size()); report.put("tested_lava_falls", lavaFalls.size()); + report.put("flows", flowReports); report.put("postprocessing_reentry_witnesses", reentryWitnesses); + report.put("scope","Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test"); + report.put("passed",passed);Files.writeString(output,json(report)); + if(passed) SanctuaryMod.LOGGER.info("Capacity {} seed {} passed: {} FULL chunks, {} retained water blocks, {} fluid ticks", + capacity.players(),level.getSeed(),chunks.size(),expectedWater.size(),elapsed); + } + void cleanup() { + if(closed)return;closed=true; + for(long key:forced) {var p=ChunkPos.unpack(key);level.setChunkForced(p.x(),p.z(),false);} + } + } + + private static String json(Object value) {return new GsonBuilder().setPrettyPrinting().create().toJson(value)+"\n";} + private static BlockPos pos(PopulationHydrology.Position p) {return new BlockPos(p.x(),p.y(),p.z());} + private static BlockPos lavaPos(PopulationLavaDeposit.Position p) {return new BlockPos(p.x(),p.y(),p.z());} + private static LevelChunk full(GameTestHelper helper,ServerLevel level,int x,int z) { + var chunk=level.getChunkSource().getChunk(x,z,ChunkStatus.FULL,true); + helper.assertTrue(chunk instanceof LevelChunk,"The inspected chunk must be fully generated");return(LevelChunk)chunk; + } + private static void request(Set requested,int x,int z,int halo) { + for(int dx=-halo;dx<=halo;dx++)for(int dz=-halo;dz<=halo;dz++)requested.add(ChunkPos.pack(x+dx,z+dz)); + } + private static void request(Set requested,PopulationHydrology.FlowBounds b) { + for(int x=(b.minX()>>4)-1;x<=(b.maxX()>>4)+1;x++)for(int z=(b.minZ()>>4)-1;z<=(b.maxZ()>>4)+1;z++)requested.add(ChunkPos.pack(x,z)); + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationPersistenceDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationPersistenceDiagnostics.java new file mode 100644 index 0000000..9e22344 --- /dev/null +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationPersistenceDiagnostics.java @@ -0,0 +1,74 @@ +package fr.koka.sanctuary.gametest; + +import com.google.gson.GsonBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import fr.koka.sanctuary.worldgen.IslandCapacity; +import fr.koka.sanctuary.worldgen.SanctuarySpawn; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.WorldDimensions; +import net.minecraft.world.level.levelgen.WorldGenSettings; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.storage.LevelStorageSource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Uses the real 26.3 saved-data writer and reader; never opens the user's saves. */ +final class PopulationPersistenceDiagnostics { + static void verify(GameTestHelper helper) throws IOException { + var level = helper.getLevel(); + var access = level.registryAccess(); + var presets = access.lookupOrThrow(Registries.WORLD_PRESET); + var standard = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, + SanctuaryMod.id("sanctuary"))).value(); + helper.assertTrue(IslandCapacity.forGenerator(standard.createWorldDimensions().overworld()) == IslandCapacity.TWENTY, + "The public Sanctuary preset defaults to 20 planned players"); + List> results = new ArrayList<>(); + Path testRoot = Path.of("diagnostics", "population-persistence-seed-" + level.getSeed()).toAbsolutePath(); + Files.createDirectories(testRoot); + LevelStorageSource storage = LevelStorageSource.createDefault(testRoot); + for (int players : new int[]{5, 20, 100}) { + String presetId = players == 20 ? "sanctuary" : "sanctuary_" + players; + WorldDimensions dimensions = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, + SanctuaryMod.id(presetId))).value().createWorldDimensions(); + helper.assertTrue(IslandCapacity.forGenerator(dimensions.overworld()) == IslandCapacity.fromPlayers(players), + "The selectable world type must resolve its declared capacity"); + var original = new WorldGenSettings(new WorldOptions(level.getSeed(), true, false), dimensions); + String name = "players-" + players; + try (var save = storage.createAccess(name)) { + LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath(name), original); + var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow(); + var generator = (NoiseBasedChunkGenerator) restored.dimensions().overworld(); + helper.assertTrue(IslandCapacity.forGenerator(generator) == IslandCapacity.fromPlayers(players), + "Saved generator settings must preserve the chosen capacity after reading the world from disk"); + helper.assertTrue(generator.generatorSettings().getRegisteredName().equals("sanctuary:population_" + players), + "The saved world must retain its explicit versioned settings key"); + helper.assertTrue(restored.options().seed() == level.getSeed(), "The seed must survive storage unchanged"); + results.add(Map.of("players", players, "preset", "sanctuary:" + presetId, + "restored_settings", generator.generatorSettings().getRegisteredName(), "disk_round_trip", true)); + } + } + var old = new NoiseBasedChunkGenerator(standard.createWorldDimensions().overworld().getBiomeSource(), + access.lookupOrThrow(Registries.NOISE_SETTINGS).getOrThrow(SanctuarySpawn.CAVERN_SETTINGS)); + var savedOld = new WorldGenSettings(new WorldOptions(level.getSeed(), true, false), + standard.createWorldDimensions().replaceOverworldGenerator(access, old)); + try (var save = storage.createAccess("legacy-alpha9")) { + LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath("legacy-alpha9"), savedOld); + var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow(); + var generator = (NoiseBasedChunkGenerator) restored.dimensions().overworld(); + helper.assertTrue(generator.generatorSettings().is(SanctuarySpawn.CAVERN_SETTINGS) + && IslandCapacity.forGenerator(generator) == null, + "An existing alpha.9 settings reference must not acquire the new default capacity"); + } + Files.writeString(testRoot.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of( + "seed", level.getSeed(), "default_players", 20, "profiles", results, + "legacy_settings_preserved", "sanctuary:sanctuary_cavern", "passed", true)) + "\n"); + } +} diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ResourceSurveyDiagnostics.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ResourceSurveyDiagnostics.java index 7740e02..4d5189a 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ResourceSurveyDiagnostics.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/ResourceSurveyDiagnostics.java @@ -4,6 +4,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import fr.koka.sanctuary.SanctuaryMod; import fr.koka.sanctuary.worldgen.IslandShape; +import fr.koka.sanctuary.worldgen.IslandCapacity; import fr.koka.sanctuary.worldgen.SanctuarySpawn; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.SharedConstants; @@ -58,7 +59,9 @@ public final class ResourceSurveyDiagnostics { long initialGameTime = level.getGameTime(); int centreX = initialSpawn.pos().getX() >> 4; int centreZ = initialSpawn.pos().getZ() >> 4; - int envelopeRadius = IslandShape.TERRAIN_LIMIT + DECORATION_MARGIN; + IslandCapacity capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator()); + int terrainRadius = capacity == null ? IslandShape.TERRAIN_LIMIT : capacity.terrainLimit(); + int envelopeRadius = terrainRadius + DECORATION_MARGIN; int minimumIslandChunk = Math.floorDiv(-envelopeRadius, 16); int maximumIslandChunk = Math.floorDiv(envelopeRadius - 1, 16); int minimumX = mode.equals("island") ? minimumIslandChunk : centreX - 1; @@ -135,7 +138,7 @@ public final class ResourceSurveyDiagnostics { report.put("mode", mode); report.put("seed", level.getSeed()); report.put("minecraft_version", SharedConstants.getCurrentVersion().name()); - report.put("generator", Map.of("preset", "sanctuary:sanctuary", "noise_settings", settingsHolder.getRegisteredName(), + report.put("generator", Map.of("preset", capacity == null || capacity.players() == 20 ? "sanctuary:sanctuary" : "sanctuary:sanctuary_" + capacity.players(), "noise_settings", settingsHolder.getRegisteredName(), "mod_version", modVersion, "version", "sanctuary@" + modVersion, "noise_min_y", settings.noiseSettings().minY(), "noise_max_y_exclusive", settings.noiseSettings().minY() + settings.noiseSettings().height())); @@ -149,7 +152,7 @@ public final class ResourceSurveyDiagnostics { "min_y_inclusive", level.getMinY(), "max_y_exclusive", level.getMinY() + level.getHeight(), "min_chunk_x_inclusive", minimumX, "max_chunk_x_inclusive", maximumX, "min_chunk_z_inclusive", minimumZ, "max_chunk_z_inclusive", maximumZ)); - report.put("declared_island_envelope", Map.of("terrain_radius_blocks", IslandShape.TERRAIN_LIMIT, + report.put("declared_island_envelope", Map.of("terrain_radius_blocks", terrainRadius, "decoration_margin_blocks", DECORATION_MARGIN, "bounding_rectangle_radius_blocks", envelopeRadius)); report.put("completeness", Map.of("requested_chunks", expectedChunks, "inspected_full_chunks", chunks.size(), "complete_within_requested_bounds", true, "covers_declared_island_envelope", mode.equals("island"), @@ -172,7 +175,7 @@ public final class ResourceSurveyDiagnostics { "scope", "snapshot after FULL generation; no resource quotas or terrain correction applied")); Path directory = Path.of("diagnostics"); Files.createDirectories(directory); - Path output = directory.resolve("resource-survey-seed-" + level.getSeed() + "-" + mode + ".json"); + Path output = directory.resolve("resource-survey-" + (capacity == null ? "legacy" : "players-" + capacity.players()) + "-seed-" + level.getSeed() + "-" + mode + ".json"); Files.writeString(output, JSON.toJson(report) + "\n"); SanctuaryMod.LOGGER.info("Resource survey {} complete: {} FULL chunks, {} non-air blocks; {}", mode, chunks.size(), totalCells - air, output.toAbsolutePath()); 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 124776d..f9d0a33 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 @@ -1,6 +1,8 @@ package fr.koka.sanctuary.gametest; import fr.koka.sanctuary.worldgen.SanctuarySpawn; +import fr.koka.sanctuary.worldgen.IslandCapacity; +import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime; import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime; import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime; @@ -30,10 +32,17 @@ import net.minecraft.world.level.storage.LevelData; public final class SanctuaryWorldGameTests { public static LevelData.RespawnData initialSpawn; + @GameTest(maxTicks = 200) + public void populationChoiceSurvivesSavingTheWorld(GameTestHelper helper) throws java.io.IOException { + PopulationPersistenceDiagnostics.verify(helper); + helper.succeed(); + } + @GameTest(maxTicks = 3000) public void generatedSurfaceWaterRemainsContained(GameTestHelper helper) throws java.io.IOException { var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); - if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.start(helper); + if (PopulationHydrologyRuntime.enabled(generator)) PopulationDiagnostics.start(helper); + else if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.start(helper); else if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.start(helper); else if (WoodlandHydrologyRuntime.enabled(generator)) WoodlandDiagnostics.start(helper); else if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.start(helper); @@ -44,7 +53,8 @@ public final class SanctuaryWorldGameTests { @GameTest(maxTicks = 200) public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) { var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator(); - if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.verifyLegacyIsolation(helper); + if (PopulationHydrologyRuntime.enabled(generator)) PopulationDiagnostics.verifyLegacyIsolation(helper); + else if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.verifyLegacyIsolation(helper); else if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.verifyLegacyIsolation(helper); else if (WoodlandHydrologyRuntime.enabled(generator)) WoodlandDiagnostics.verifyLegacyIsolation(helper); else if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.verifyLegacyIsolation(helper); @@ -61,12 +71,14 @@ public final class SanctuaryWorldGameTests { helper.assertTrue(initialSpawn != null, "Capture the world's initial spawn before the test grid moves it"); BlockPos spawn = initialSpawn.pos(); - helper.assertTrue(Math.hypot((double) spawn.getX(), (double) spawn.getZ()) < IslandShape.TERRAIN_LIMIT, + var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + IslandCapacity capacity = IslandCapacity.forGenerator(generator); + int terrainLimit = capacity == null ? IslandShape.TERRAIN_LIMIT : capacity.terrainLimit(); + helper.assertTrue(Math.hypot((double) spawn.getX(), (double) spawn.getZ()) < terrainLimit, "Shared spawn must lie on the finite natural island: " + spawn); helper.assertTrue(spawn.getY() > 1 && spawn.getY() < level.getMaxY() - 1, "Shared spawn must be inside the dimension height"); - var settings = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS) - .getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value(); + var settings = generator.generatorSettings().value(); float spawnFloorDensity = level.getChunkSource().randomState().sampleBlockValueUncached( settings.noiseRouter().finalDensity(), spawn.getX(), spawn.getY() - 1, spawn.getZ()); helper.assertTrue(spawnFloorDensity > 0.0F, @@ -92,7 +104,9 @@ public final class SanctuaryWorldGameTests { @GameTest(maxTicks = 200) public void sculptOnlyRemovesTerrain(GameTestHelper helper) throws java.io.IOException { - DensityDiagnostics.verifyAndExport(helper, initialSpawn); + if (IslandCapacity.forGenerator(helper.getLevel().getChunkSource().getGenerator()) != null) + PopulationDiagnostics.verifyDensity(helper); + else DensityDiagnostics.verifyAndExport(helper, initialSpawn); helper.succeed(); } @@ -120,6 +134,11 @@ public final class SanctuaryWorldGameTests { @GameTest(maxTicks = 200) public void completedLowerMarginIsVoid(GameTestHelper helper) { + if (IslandCapacity.forGenerator(helper.getLevel().getChunkSource().getGenerator()) != null) { + PopulationDiagnostics.verifyLowerMargin(helper); + helper.succeed(); + return; + } for (int[] coordinate : new int[][]{{0, 0}, {-8, 0}, {8, 0}, {0, -8}, {0, 8}}) { ChunkAccess chunk = helper.getLevel().getChunkSource().getChunk( coordinate[0], coordinate[1], ChunkStatus.FULL, true); @@ -178,6 +197,11 @@ public final class SanctuaryWorldGameTests { @GameTest(maxTicks = 200) public void completedExteriorChunksAreVoid(GameTestHelper helper) { + if (IslandCapacity.forGenerator(helper.getLevel().getChunkSource().getGenerator()) != null) { + PopulationDiagnostics.verifyExterior(helper); + helper.succeed(); + return; + } ServerLevel level = helper.getLevel(); // First row is just beyond the declared terrain/decorations envelope. The last // samples catch the legacy bug where the infinite archipelago returned farther out. @@ -201,8 +225,7 @@ public final class SanctuaryWorldGameTests { public void compiledWorldDensityUsesTheSeed(GameTestHelper helper) { ServerLevel level = helper.getLevel(); var registries = level.registryAccess(); - NoiseGeneratorSettings settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS) - .getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value(); + NoiseGeneratorSettings settings = ((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator()).generatorSettings().value(); var noises = registries.lookupOrThrow(Registries.NOISE); RandomState first = RandomState.create(noises, 0L, settings); RandomState replay = RandomState.create(noises, 0L, settings); diff --git a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java index 6a1e918..fe6ebb3 100644 --- a/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java +++ b/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/mixin/SanctuaryGameTestServerMixin.java @@ -32,12 +32,21 @@ public abstract class SanctuaryGameTestServerMixin { @Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target = "Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;")) private static ResourceKey sanctuary$selectActualPreset() { - return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary")); + int players = Integer.getInteger("sanctuary.test.players", 20); + String preset = switch (players) { + case 5 -> "sanctuary_5"; + case 20 -> "sanctuary"; + case 100 -> "sanctuary_100"; + default -> throw new IllegalArgumentException("Test capacity must be 5, 20 or 100"); + }; + return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(preset)); } @Inject(method = "startTests", at = @At("HEAD")) private void sanctuary$captureInitialSpawn(ServerLevel level, CallbackInfo ci) { // The framework moves its own spawn to a random test grid after this point. SanctuaryWorldGameTests.initialSpawn = level.getRespawnData(); + fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn = + fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState()); } } 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 533312b..1d19ca3 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/SanctuaryMod.java @@ -1,6 +1,11 @@ package fr.koka.sanctuary; import fr.koka.sanctuary.worldgen.CavernIslandBiomeSource; +import fr.koka.sanctuary.worldgen.PopulationIslandDensity; +import fr.koka.sanctuary.worldgen.PopulationIslandBiomeSource; +import fr.koka.sanctuary.worldgen.PopulationGrovesFeature; +import fr.koka.sanctuary.worldgen.PopulationDecorationsFeature; +import fr.koka.sanctuary.worldgen.PopulationShoreSugarCaneFeature; import fr.koka.sanctuary.worldgen.CavernGrovesFeature; import fr.koka.sanctuary.worldgen.CavernDecorationsFeature; import fr.koka.sanctuary.worldgen.CavernShoreSugarCaneFeature; @@ -36,6 +41,11 @@ public final class SanctuaryMod implements ModInitializer { @Override public void onInitialize() { Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC); + Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC); + Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_groves"), PopulationGrovesFeature.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_decorations"), PopulationDecorationsFeature.CODEC); + Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_shore_sugar_cane"), PopulationShoreSugarCaneFeature.CODEC); Registry.register(BuiltInRegistries.FEATURE_TYPE, id("shore_sugar_cane"), ShoreSugarCaneFeature.CODEC); Registry.register(BuiltInRegistries.FEATURE_TYPE, id("natural_shore_sugar_cane"), NaturalShoreSugarCaneFeature.CODEC); Registry.register(BuiltInRegistries.BIOME_SOURCE, id("temperate_island"), TemperateIslandBiomeSource.CODEC); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CavernSpringOutletMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CavernSpringOutletMixin.java index 2e934a3..d5009e2 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CavernSpringOutletMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CavernSpringOutletMixin.java @@ -1,6 +1,7 @@ package fr.koka.sanctuary.mixin; import fr.koka.sanctuary.worldgen.CavernSpringOutlets; +import fr.koka.sanctuary.worldgen.PopulationSpringOutlets; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.chunk.LevelChunk; import org.spongepowered.asm.mixin.Mixin; @@ -12,16 +13,23 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; /** Finish only pending generation markers; completed saved chunks are never repaired on reload. */ @Mixin(LevelChunk.class) public abstract class CavernSpringOutletMixin { + @Unique + private PopulationSpringOutlets.Pending sanctuary$pendingPopulationOutlets = PopulationSpringOutlets.Pending.EMPTY; + @Unique private CavernSpringOutlets.Pending sanctuary$pendingCavernOutlets = CavernSpringOutlets.Pending.EMPTY; @Inject(method = "postProcessGeneration", at = @At("HEAD")) private void sanctuary$captureCavernOutlets(ServerLevel level, CallbackInfo callback) { + sanctuary$pendingPopulationOutlets = PopulationSpringOutlets.capturePending(level, (LevelChunk) (Object) this); sanctuary$pendingCavernOutlets = CavernSpringOutlets.capturePending(level, (LevelChunk) (Object) this); } @Inject(method = "postProcessGeneration", at = @At("TAIL")) private void sanctuary$finishCavernOutlets(ServerLevel level, CallbackInfo callback) { + var population = sanctuary$pendingPopulationOutlets; + sanctuary$pendingPopulationOutlets = PopulationSpringOutlets.Pending.EMPTY; + PopulationSpringOutlets.finish(level, (LevelChunk) (Object) this, population); var pending = sanctuary$pendingCavernOutlets; sanctuary$pendingCavernOutlets = CavernSpringOutlets.Pending.EMPTY; CavernSpringOutlets.finish(level, (LevelChunk) (Object) this, pending); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/SurfaceHydrologyMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/SurfaceHydrologyMixin.java index c174035..1b56dc1 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 @@ -6,6 +6,7 @@ import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime; import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime; import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime; import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime; +import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime; import java.util.Set; import java.util.concurrent.CompletableFuture; import net.minecraft.core.Holder; @@ -30,7 +31,12 @@ public abstract class SurfaceHydrologyMixin { StructureManager structures, BiomeManager biomeManager, WorldGenRegion region, Set> biomes, CallbackInfoReturnable> callback) { var generator = (NoiseBasedChunkGenerator) (Object) this; - if (CavernHydrologyRuntime.enabled(generator)) { + if (PopulationHydrologyRuntime.enabled(generator)) { + callback.setReturnValue(callback.getReturnValue().thenApply(generated -> { + PopulationHydrologyRuntime.apply(generator, randomState, generated); + return generated; + })); + } else if (CavernHydrologyRuntime.enabled(generator)) { callback.setReturnValue(callback.getReturnValue().thenApply(generated -> { CavernHydrologyRuntime.apply(generator, randomState, generated); return generated; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandCapacity.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandCapacity.java new file mode 100644 index 0000000..5c1bb13 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandCapacity.java @@ -0,0 +1,52 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; + +/** Versioned alpha.10 world geometry. Capacity describes area, not a login limit. */ +public enum IslandCapacity { + FIVE(5, 256), TWENTY(20, 512), HUNDRED(100, 256 * Math.sqrt(20)); + + public static final int REGION_SIZE = 512; + public static final Codec CODEC = Codec.INT.comapFlatMap(players -> { + for (var capacity : values()) if (capacity.players == players) return DataResult.success(capacity); + return DataResult.error(() -> "Island capacity must be 5, 20 or 100"); + }, IslandCapacity::players); + private final int players; + private final double radius; + IslandCapacity(int players, double radius) { this.players = players; this.radius = radius; } + public int players() { return players; } + public double radius() { return radius; } + public int terrainLimit() { return (int) Math.ceil(radius + 32); } + public static IslandCapacity fromPlayers(int players) { + for (var capacity : values()) if (capacity.players == players) return capacity; + throw new IllegalArgumentException("Island capacity must be 5, 20 or 100"); + } + public static IslandCapacity forGenerator(ChunkGenerator generator) { + if (!(generator instanceof NoiseBasedChunkGenerator noise)) return null; + var settings = noise.generatorSettings(); + if (settings.is(SanctuarySpawn.POPULATION_5_SETTINGS)) return FIVE; + if (settings.is(SanctuarySpawn.POPULATION_20_SETTINGS)) return TWENTY; + if (settings.is(SanctuarySpawn.POPULATION_100_SETTINGS)) return HUNDRED; + return null; + } + public boolean areaExists(int chunkX, int chunkZ) { + double x = Math.max(0, Math.max((long) chunkX * 16, -((long) chunkX * 16 + 15))); + double z = Math.max(0, Math.max((long) chunkZ * 16, -((long) chunkZ * 16 + 15))); + return Math.hypot(x, z) < terrainLimit(); + } + public Region regionAt(int x, int z) { + return new Region((int) Math.floorDiv((long) x + 256, REGION_SIZE), + (int) Math.floorDiv((long) z + 256, REGION_SIZE)); + } + public record Region(int x, int z) { + public int originX() { return Math.multiplyExact(x, REGION_SIZE); } + public int originZ() { return Math.multiplyExact(z, REGION_SIZE); } + public int minX() { return originX() - 256; } + public int minZ() { return originZ() - 256; } + public int maxX() { return originX() + 255; } + public int maxZ() { return originZ() + 255; } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationBiomePalette.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationBiomePalette.java new file mode 100644 index 0000000..3b9f49b --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationBiomePalette.java @@ -0,0 +1,34 @@ +package fr.koka.sanctuary.worldgen; + +/** Alpha.10 keeps the upper woodland climate and specializes sheltered lower layers. */ +public final class PopulationBiomePalette { + public enum Kind { + OAK_FOREST, BIRCH_FOREST, CLEARING, DRY_WOODLAND, ROCKY_HEATH, + DARK_GROVE, BAMBOO_GROVE, SULFUR_DEPTHS, LUSH_CAVES, DRIPSTONE_CAVES + } + + private PopulationBiomePalette() {} + + public static Kind select(int blockY, float variation, float moisture) { + double elevation = blockY - Math.clamp(variation, -1.0F, 1.0F) * 16 + - Math.clamp(moisture, -1.0F, 1.0F) * 10; + // Small climate pockets replace alpha.8's broad sulfur province. Neither + // mineral pockets nor cave vegetation can take over the upper forest. + if (blockY < 176 && elevation < 168 && variation > 0.22F && variation < 0.42F + && moisture > -0.42F && moisture < -0.12F) return Kind.SULFUR_DEPTHS; + if (variation > 0.48F) return Kind.ROCKY_HEATH; + if (blockY < 184 && elevation < 180) { + if (moisture > 0.05F && variation > -0.32F) return Kind.LUSH_CAVES; + if (moisture < -0.18F && variation < 0.22F) return Kind.DRIPSTONE_CAVES; + } + if (elevation < 214) { + if (variation < -0.45F && moisture > 0.40F) return Kind.BAMBOO_GROVE; + double shelteredMoisture = moisture + Math.min(0.55, (214 - elevation) / 160); + if (shelteredMoisture > -0.08) return Kind.DARK_GROVE; + } + if (moisture < -0.55F && variation > -0.25F) return Kind.DRY_WOODLAND; + if (Math.abs(variation) < 0.09F && moisture < 0.35F) return Kind.CLEARING; + if (variation < -0.33F) return Kind.BIRCH_FOREST; + return Kind.OAK_FOREST; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationDecorationsFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationDecorationsFeature.java new file mode 100644 index 0000000..e4c5b67 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationDecorationsFeature.java @@ -0,0 +1,297 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import fr.koka.sanctuary.SanctuaryMod; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.tags.BlockTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.AmethystClusterBlock; +import net.minecraft.world.level.block.CaveVines; +import net.minecraft.world.level.block.GrowingPlantHeadBlock; +import net.minecraft.world.level.block.PotentSulfurBlock; +import net.minecraft.world.level.block.SpeleothemBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.PotentSulfurState; +import net.minecraft.world.level.block.state.properties.SpeleothemThickness; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.GeodeFeature; + +/** Decorations in existing lower cavities only. All writes belong to the owner's chunk. */ +public final class PopulationDecorationsFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(PopulationDecorationsFeature::new); + private static final ResourceKey GEODE = ResourceKey.create(Registries.FEATURE, SanctuaryMod.id("population_amethyst_geode")); + private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; + public enum Kind { LUSH, DRIPSTONE, SULFUR, GEODE } + public record Candidate(Kind kind, BlockPos floor, BlockPos ceiling) { + public Candidate { floor = floor.immutable(); ceiling = ceiling.immutable(); } + } + + @Override public MapCodec codec() { return CODEC; } + + /** Pure, bounded probes for development diagnostics; no chunk loads or successful-placement promise. */ + public static List candidates(NoiseBasedChunkGenerator generator, RandomState state, int chunkX, int chunkZ) { + if (!PopulationHydrologyRuntime.enabled(generator) + || !PopulationHydrologyRuntime.capacity(generator).areaExists(chunkX, chunkZ)) return List.of(); + var density = generator.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + var biomes = generator.getBiomeSource().createUncachedResolver(state); + List found = new ArrayList<>(); + for (int[] offset : new int[][]{{4, 4}, {4, 12}, {12, 4}, {12, 12}, {8, 8}}) { + int x = chunkX * 16 + offset[0], z = chunkZ * 16 + offset[1], floors = 0; + for (int y = 182; y >= 40; y--) { + if (sampler.sample(x, y, z) <= 0 || sampler.sample(x, y + 1, z) > 0 + || sampler.sample(x, y + 2, z) > 0 || sampler.sample(x, y - 1, z) <= 0 + || sampler.sample(x, y - 2, z) <= 0) continue; + int roof = y + 3; + while (roof <= Math.min(230, y + 48) && sampler.sample(x, roof, z) <= 0) roof++; + if (roof > Math.min(230, y + 48) || roof - y < 6 + || sampler.sample(x, roof + 1, z) <= 0) continue; + BlockPos floor = new BlockPos(x, y, z), ceiling = new BlockPos(x, roof, z); + var biome = biomes.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2); + if (biome.is(PopulationIslandBiomeSource.LUSH_CAVES)) found.add(new Candidate(Kind.LUSH, floor, ceiling)); + else if (biome.is(PopulationIslandBiomeSource.DRIPSTONE_CAVES)) found.add(new Candidate(Kind.DRIPSTONE, floor, ceiling)); + else if (biome.is(PopulationIslandBiomeSource.SULFUR_DEPTHS) && y < 176) + found.add(new Candidate(Kind.SULFUR, floor, ceiling)); + if (offset[0] == 8 && offset[1] == 8 && Math.floorMod(chunkSeed(state.seed(), chunkX, chunkZ, 0x9501L), 8) == 0) + found.add(new Candidate(Kind.GEODE, floor, ceiling)); + if (++floors >= 3) break; + } + } + // Geodes keep their entry free before this feature's moss, grass or pointed + // dripstone can occupy it. Stable sorting preserves every other candidate order. + found.sort(java.util.Comparator.comparingInt(candidate -> candidate.kind() == Kind.GEODE ? 0 : 1)); + return List.copyOf(found); + } + + @Override public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false; + var state = level.getLevel().getChunkSource().randomState(); + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + int cx = origin.getX() >> 4, cz = origin.getZ() >> 4; + boolean changed = false, geyser = false, geode = false; + for (Candidate candidate : candidates(noise, state, cx, cz)) { + BlockPos floor = candidate.floor(); + if (!stable(level, sampler, floor) || !level.getBlockState(floor.above()).isAir()) continue; + long seed = WoodlandGroveGeometry.seed(state.seed(), floor.getX(), floor.getY(), floor.getZ(), 0x9502L); + switch (candidate.kind()) { + case LUSH -> changed |= lush(level, noise, state, sampler, candidate, seed); + case DRIPSTONE -> changed |= dripstone(level, noise, state, sampler, candidate, seed); + case SULFUR -> { + if (!geyser && Math.floorMod(chunkSeed(state.seed(), cx, cz, 0x9503L), 2) == 0) + changed |= geyser = geyser(level, noise, state, sampler, candidate); + } + case GEODE -> { if (!geode) changed |= geode = geode(level, noise, state, sampler, candidate, seed); } + } + } + return changed; + } + + private boolean lush(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) { + BlockPos floor = site.floor(), roof = site.ceiling(); + int gap = roof.getY() - floor.getY(); + if (PopulationHydrologyRuntime.protects(noise, state, floor, 3, gap + 2, 3)) return false; + boolean changed = false; + for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) { + if (dx * dx + dz * dz > 5) continue; + BlockPos p = floor.offset(dx, 0, dz); + if (!stable(level, sampler, p) || !stable(level, sampler, p.below()) || !stable(level, sampler, p.below(2)) + || !level.getBlockState(p.above()).isAir()) continue; + setBlock(level, p, Blocks.MOSS_BLOCK.defaultBlockState()); + long local = WoodlandGroveGeometry.seed(seed, p.getX(), p.getY(), p.getZ(), 0x9504L); + BlockState plant = (Math.floorMod(local, 5) == 0 ? Blocks.AZALEA : Math.floorMod(local, 3) == 0 + ? Blocks.MOSS_CARPET : Blocks.SHORT_GRASS).defaultBlockState(); + if (plant.canSurvive(level, p.above())) setBlock(level, p.above(), plant); + changed = true; + } + // Real cave-vine heads and bodies remain harvestable and luminous via BERRIES. + int length = Math.min(gap - 3, 3 + Math.floorMod(seed, 6)); + if (stable(level, sampler, roof) && stable(level, sampler, roof.above())) { + boolean free = true; + for (int d = 1; d <= length; d++) free &= level.getBlockState(roof.below(d)).isAir(); + if (free) for (int d = 1; d <= length; d++) { + BlockPos p = roof.below(d); + BlockState vine = (d == length ? Blocks.CAVE_VINES : Blocks.CAVE_VINES_PLANT).defaultBlockState() + .setValue(CaveVines.BERRIES, d == length || Math.floorMod(seed + d, 3) == 0); + if (d == length) vine = vine.setValue(GrowingPlantHeadBlock.AGE, 25); + if (!vine.canSurvive(level, p)) break; + setBlock(level, p, vine); changed = true; + } + } + return changed; + } + + private boolean dripstone(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) { + BlockPos floor = site.floor(), roof = site.ceiling(); + int gap = roof.getY() - floor.getY(); + if (PopulationHydrologyRuntime.protects(noise, state, floor, 1, gap + 2, 3)) return false; + int length = Math.min((gap - 3) / 2, 1 + Math.floorMod(seed, 4)); + boolean changed = spike(level, sampler, floor, Direction.UP, length); + changed |= spike(level, sampler, roof, Direction.DOWN, length); + return changed; + } + + private boolean spike(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, BlockPos support, Direction direction, int length) { + if (length < 1 || !stable(level, sampler, support) || !stable(level, sampler, support.relative(direction.getOpposite()))) return false; + for (int d = 1; d <= length; d++) { + BlockPos p = support.relative(direction, d); + if (!level.getBlockState(p).isAir() || sampler.sample(p.getX(), p.getY(), p.getZ()) > 0) return false; + } + setBlock(level, support, Blocks.DRIPSTONE_BLOCK.defaultBlockState()); + for (int d = 1; d <= length; d++) { + SpeleothemThickness thickness = d == length ? SpeleothemThickness.TIP + : d == length - 1 ? SpeleothemThickness.FRUSTUM : d == 1 ? SpeleothemThickness.BASE : SpeleothemThickness.MIDDLE; + BlockState pointed = Blocks.POINTED_DRIPSTONE.defaultBlockState().setValue(SpeleothemBlock.TIP_DIRECTION, direction) + .setValue(SpeleothemBlock.THICKNESS, thickness); + setBlock(level, support.relative(direction, d), pointed); + } + return true; + } + + private boolean geyser(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site) { + BlockPos water = site.floor(), potent = water.below(), heat = water.below(2); + if (site.ceiling().getY() - water.getY() < 8 + || PopulationHydrologyRuntime.protects(noise, state, water, 2, 8, 5)) return false; + for (int d = 0; d <= 4; d++) if (!stable(level, sampler, water.below(d))) return false; + for (Direction side : SIDES) for (int d = 0; d <= 2; d++) + if (!stable(level, sampler, water.below(d).relative(side))) return false; + for (int d = 1; d <= 7; d++) if (!level.getBlockState(water.above(d)).isAir()) return false; + // One source, four original rock walls, original deep support. Vanilla's + // periodic geyser ticker requires magma below potent sulfur and source water above. + setBlock(level, heat, Blocks.MAGMA_BLOCK.defaultBlockState()); + setBlock(level, water, Blocks.WATER.defaultBlockState()); + setBlock(level, potent, Blocks.POTENT_SULFUR.defaultBlockState().setValue(PotentSulfurBlock.STATE, PotentSulfurState.DORMANT)); + level.getChunk(water).markPosForPostProcessing(water); + level.getChunk(potent).markPosForPostProcessing(potent); + return true; + } + + private boolean geode(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state, + WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) { + BlockPos origin = site.floor().offset(-1, -9, -1); + // Include the vanilla write range, its crystal neighbours and the entrance. + // This asymmetric envelope is centred explicitly so its positive edge is protected too. + if (origin.getY() - 6 < 24 || !level.getBlockState(site.floor().above(2)).isAir() + || PopulationHydrologyRuntime.protects(noise, state, origin.offset(1, -6, 1), 7, 17, 0)) return false; + Map before = new LinkedHashMap<>(); + for (int dx = -6; dx <= 8; dx++) for (int dz = -6; dz <= 8; dz++) for (int dy = -6; dy <= 11; dy++) { + BlockPos p = origin.offset(dx, dy, dz); + BlockState original = level.getBlockState(p); + // No block-entity data or fluid scheduler state needs to be restored. + if (original.hasBlockEntity() || !original.getFluidState().isEmpty()) return false; + before.put(p, original); + } + Feature feature = level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(GEODE).value(); + if (!(feature instanceof GeodeFeature geode) || geode.minGenOffset() != -5 || geode.maxGenOffset() != 7) return false; + boolean accepted = false; + try { + if (!geode.place(level, noise, RandomSource.create(seed), origin) || !naturalGeodeChanges(level, sampler, before)) return false; + var entrance = geodeEntrance(level, sampler, site.floor().above(), origin, before); + if (entrance.isEmpty()) return false; + for (BlockPos p : entrance) if (!level.getBlockState(p).isAir()) setBlock(level, p, Blocks.AIR.defaultBlockState()); + if (!naturalGeodeChanges(level, sampler, before)) return false; + accepted = true; + return true; + } finally { + if (!accepted) before.forEach((p, original) -> { + if (!level.getBlockState(p).equals(original)) setBlock(level, p, original); + }); + } + } + + /** Test the actual vanilla mask, not unused bounding-box corners. A geode may + * excavate original rock but cannot fill original air or remove earlier decoration. */ + private static boolean naturalGeodeChanges(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + Map before) { + for (var entry : before.entrySet()) { + BlockPos p = entry.getKey(); + BlockState original = entry.getValue(), current = level.getBlockState(p); + if (original.equals(current)) continue; + if (!carvableOriginal(level, sampler, p, original)) return false; + } + return true; + } + + private static boolean carvableOriginal(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + BlockPos p, BlockState original) { + return original != null && original.isCollisionShapeFullBlock(level, p) + && !original.hasBlockEntity() && original.getFluidState().isEmpty() + && !original.is(BlockTags.LOGS) && !original.is(BlockTags.LEAVES) + && !original.is(Blocks.BUDDING_AMETHYST) && !original.is(Blocks.MAGMA_BLOCK) + && sampler.sample(p.getX(), p.getY(), p.getZ()) > 0; + } + + private static List geodeEntrance(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + BlockPos start, BlockPos origin, Map before) { + var previous = new java.util.HashMap(); + var distances = new java.util.HashMap(); + var queue = new java.util.ArrayDeque(); + queue.add(start); distances.put(start, 0); + BlockPos destination = null; + while (!queue.isEmpty()) { + BlockPos p = queue.removeFirst(); + int distance = distances.get(p); + boolean adjacentBudding = false; + for (Direction direction : Direction.values()) + adjacentBudding |= level.getBlockState(p.relative(direction)).is(Blocks.BUDDING_AMETHYST) + || level.getBlockState(p.above().relative(direction)).is(Blocks.BUDDING_AMETHYST); + if (p.getY() <= start.getY() - 3 && level.getBlockState(p).isAir() + && level.getBlockState(p.above()).isAir() && adjacentBudding) { destination = p; break; } + if (distance >= 16) continue; + for (Direction direction : Direction.values()) { + BlockPos next = p.relative(direction); + if (Math.abs(next.getX() - start.getX()) > 6 || Math.abs(next.getZ() - start.getZ()) > 6 + || next.getY() < origin.getY() - 5 || next.getY() > start.getY() + || distances.containsKey(next) || !before.containsKey(next) || !before.containsKey(next.above())) continue; + if (!entranceBlock(level, sampler, next, before) || !entranceBlock(level, sampler, next.above(), before)) continue; + distances.put(next, distance + 1); previous.put(next, p); queue.addLast(next); + } + } + if (destination == null) return List.of(); + var passage = new java.util.LinkedHashSet(); + for (BlockPos p = destination; p != null; p = previous.get(p)) { passage.add(p); passage.add(p.above()); } + return List.copyOf(passage); + } + + private static boolean entranceBlock(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, + BlockPos p, Map before) { + BlockState current = level.getBlockState(p); + if (current.isAir()) return true; + // Keep every budding block and attached crystal. Search around them instead. + if (current.is(Blocks.BUDDING_AMETHYST) || current.is(Blocks.AMETHYST_CLUSTER) + || current.is(Blocks.SMALL_AMETHYST_BUD) || current.is(Blocks.MEDIUM_AMETHYST_BUD) + || current.is(Blocks.LARGE_AMETHYST_BUD)) return false; + for (Direction direction : Direction.values()) { + BlockState neighbour = level.getBlockState(p.relative(direction)); + if (neighbour.getBlock() instanceof AmethystClusterBlock + && neighbour.getValue(AmethystClusterBlock.FACING) == direction) return false; + } + return carvableOriginal(level, sampler, p, before.get(p)); + } + + private static boolean stable(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, BlockPos p) { + BlockState state = level.getBlockState(p); + return sampler.sample(p.getX(), p.getY(), p.getZ()) > 0 && state.isCollisionShapeFullBlock(level, p) + && state.getFluidState().isEmpty() && !state.hasBlockEntity() && !state.is(BlockTags.LOGS) + && !state.is(BlockTags.LEAVES) && !state.is(Blocks.BUDDING_AMETHYST) && !state.is(Blocks.MAGMA_BLOCK); + } + + private static long chunkSeed(long seed, int x, int z, long salt) { + return WoodlandGroveGeometry.seed(seed, x * 16, 0, z * 16, salt); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationGrovesFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationGrovesFeature.java new file mode 100644 index 0000000..85b2aa1 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationGrovesFeature.java @@ -0,0 +1,297 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; +import java.util.WeakHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.data.worldgen.features.TreeFeatures; +import net.minecraft.data.worldgen.features.VegetationFeatures; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.BlockTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.VegetationBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; + +/** Alpha.10: sheltered vanilla mushroom trees and dark oak on natural lower floors, + * plus one globally selected remarkable tree. + * Placement runs only in new generation. No world journal or mutable placement counter is retained. */ +public final class PopulationGrovesFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(PopulationGrovesFeature::new); + private static final Map> REMARKABLE = + Collections.synchronizedMap(new WeakHashMap<>()); + + public record RemarkableTree(BlockPos base, String featureId, int radius, int height) { + public RemarkableTree { base = base.immutable(); } + } + private record Species(ResourceKey key, int width, int radius, int height) {} + private record Candidate(int x, int z, long rank) {} + @FunctionalInterface + private interface Protection { + boolean intersects(BlockPos base, int radius, int height, int rootDepth); + } + private static final Species CHERRY = new Species(TreeFeatures.CHERRY, 1, 9, 15); + private static final Species[] SPECIES = {CHERRY, CHERRY, CHERRY, CHERRY, + new Species(TreeFeatures.SPRUCE, 1, 5, 15), new Species(TreeFeatures.JUNGLE_TREE_NO_VINE, 1, 5, 14), + new Species(TreeFeatures.ACACIA, 1, 7, 14), new Species(TreeFeatures.MANGROVE, 1, 10, 20), + new Species(TreeFeatures.PALE_OAK_BONEMEAL, 2, 6, 12)}; + + @Override public MapCodec codec() { return CODEC; } + + public static Optional remarkablePlan(ServerLevel level) { + if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty(); + return remarkablePlan(noise, level.getChunkSource().randomState()); + } + + public static Optional remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) { + if (!PopulationHydrologyRuntime.enabled(noise)) return Optional.empty(); + return REMARKABLE.computeIfAbsent(state, ignored -> { + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + Protection protection = (base, radius, height, rootDepth) -> + PopulationHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth); + Species species = SPECIES[Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), 0, 0, 0, 0x70EEL), SPECIES.length)]; + var candidates = new ArrayList(); + // One global candidate stays in the starter centre; ordinary groves use the full capacity. + for (int x = -152; x <= 152; x += 16) for (int z = -152; z <= 152; z += 16) { + if (Math.hypot(x, z) <= 168) candidates.add(new Candidate(x, z, + WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x71AFL))); + } + candidates.sort(Comparator.comparingLong(Candidate::rank).thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)); + for (Candidate candidate : candidates) { + int bed = WoodlandGroveGeometry.highestSurface(sampler, candidate.x(), candidate.z()); + if (!WoodlandGroveGeometry.naturalFootprint(sampler, candidate.x(), bed, candidate.z(), species.width())) continue; + BlockPos base = new BlockPos(candidate.x(), bed + 1, candidate.z()); + if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species))) continue; + if (!WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(), + species.width(), species.radius(), species.height())) continue; + return Optional.of(new RemarkableTree(base, species.key().identifier().toString(), species.radius(), species.height())); + } + return Optional.empty(); + }); + } + + @Override + public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false; + int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16; + if (!PopulationHydrologyRuntime.capacity(noise).areaExists(minX >> 4, minZ >> 4)) return false; + var state = level.getLevel().getChunkSource().randomState(); + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z); + Protection protection = (base, radius, height, rootDepth) -> + PopulationHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth); + boolean changed = false; + int[] heights = new int[256]; + Arrays.fill(heights, Integer.MIN_VALUE); + var remarkable = remarkablePlan(noise, state); + if (remarkable.isPresent()) { + var tree = remarkable.get(); + BlockPos base = tree.base(); + if ((base.getX() >> 4) == (minX >> 4) && (base.getZ() >> 4) == (minZ >> 4)) { + for (Species species : SPECIES) if (species.key().identifier().toString().equals(tree.featureId())) { + changed |= tree(level, generator, sampler, state.seed(), base, species, protection, 0x723BL); + break; + } + } + } + // The scarce tall rooms usually go to dark oak first; one quarter of chunks + // give mushroom trees first choice. This changes priority, never attempt budgets + // or natural fit requirements, and uses a separate deterministic chunk seed. + boolean mushroomsFirst = Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), minX, 0, minZ, 0x8503L), 4) == 0; + if (mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights); + // Four spaced origins per chunk; the 2x2 trunk and full canopy still have to fit the cave. + for (int x = minX + 4; x < minX + 16; x += 8) for (int z = minZ + 4; z < minZ + 16; z += 8) { + int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), placed = 0; + for (int bed = highest - 8; bed >= 40; bed--) { + BlockPos base = new BlockPos(x, bed + 1, z); + if (!ground(level, base.below()) || !level.getBlockState(base).isAir()) continue; + var biome = level.getBiome(base); + boolean dark = biome.is(PopulationIslandBiomeSource.DARK_GROVE); + boolean bamboo = biome.is(PopulationIslandBiomeSource.BAMBOO_GROVE); + if (!dark && !bamboo) continue; + long placementSeed = WoodlandGroveGeometry.seed(state.seed(), x, bed, z, 0x730DL); + if (dark) { + if (tree(level, generator, sampler, state.seed(), base, + new Species(TreeFeatures.DARK_OAK, 2, 6, 12), protection, 0x730DL)) { changed = true; placed++; } + } else if (Math.floorMod(placementSeed, 8) == 0) { + if (tree(level, generator, sampler, state.seed(), base, + new Species(TreeFeatures.JUNGLE_TREE_NO_VINE, 1, 5, 14), protection, 0x735BL)) { changed = true; placed++; } + } else if (Math.floorMod(placementSeed, 3) == 0 + && WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1) + && WoodlandGroveGeometry.treeRoom(sampler, x, bed + 1, z, 1, 3, 16) + && !blocked(protection, base, 4, 17) && actualRoom(level, base, 1, 3, 16)) { + setBlock(level, base.below(), Blocks.PODZOL.defaultBlockState()); + changed |= level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(VegetationFeatures.BAMBOO_NO_PODZOL) + .value().place(level, generator, RandomSource.create(placementSeed), base); + placed++; + } + if (placed >= 2) break; + } + } + if (!mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights); + // Coherent ground patches: about 40% moss, 40% podzol, 20% mycelium. + // Small mushrooms are accents (one opportunity per hundred columns), not the canopy. + for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) { + int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), floors = 0; + long detail = WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x843EL); + boolean smallMushroomPlaced = false; + for (int bed = highest - 8; bed >= 32; bed--) { + BlockPos floor = new BlockPos(x, bed, z), plant = floor.above(); + if (!ground(level, floor) || !level.getBlockState(plant).isAir()) continue; + var biome = level.getBiome(plant); + if (!biome.is(PopulationIslandBiomeSource.DARK_GROVE) && !biome.is(PopulationIslandBiomeSource.BAMBOO_GROVE)) continue; + if (!WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1) || blocked(protection, plant, 1, 2)) continue; + long patch = WoodlandGroveGeometry.seed(state.seed(), Math.floorDiv(x, 7), 0, Math.floorDiv(z, 7), 0x742DL); + int soilKind = Math.floorMod(patch, 5); + BlockState soil = (soilKind < 2 ? Blocks.MOSS_BLOCK : soilKind < 4 ? Blocks.PODZOL : Blocks.MYCELIUM).defaultBlockState(); + setBlock(level, floor, soil); + if (!smallMushroomPlaced && Math.floorMod(detail, 100) == 0) { + BlockState mushroom = (Math.floorMod(detail >>> 8, 2) == 0 ? Blocks.BROWN_MUSHROOM : Blocks.RED_MUSHROOM).defaultBlockState(); + if (mushroom.canSurvive(level, plant)) { + setBlock(level, plant, mushroom); + smallMushroomPlaced = true; + } + } + changed = true; + if (++floors >= 3) break; + } + } + return changed; + } + + private boolean mushroomTrees(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler, + long seed, Protection protection, int minX, int minZ, int[] heights) { + boolean changed = false; + RandomSource mushroomSites = RandomSource.create(WoodlandGroveGeometry.seed(seed, minX, 0, minZ, 0x8501L)); + int mushroomAttempts = 1 + mushroomSites.nextInt(3); + for (int attempt = 0; attempt < mushroomAttempts; attempt++) { + int x = minX + 3 + mushroomSites.nextInt(10), z = minZ + 3 + mushroomSites.nextInt(10); + int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights); + for (int bed = highest - 8; bed >= 32; bed--) { + BlockPos base = new BlockPos(x, bed + 1, z); + if (!ground(level, base.below()) || !level.getBlockState(base).isAir() + || !level.getBiome(base).is(PopulationIslandBiomeSource.DARK_GROVE)) continue; + long mushroomSeed = WoodlandGroveGeometry.seed(seed, x, bed, z, 0x8502L + attempt); + var key = Math.floorMod(mushroomSeed, 2) == 0 ? TreeFeatures.HUGE_BROWN_MUSHROOM : TreeFeatures.HUGE_RED_MUSHROOM; + Feature feature = level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(key).value(); + if (!(feature instanceof AbstractHugeMushroomFeature mushroom)) break; + int height = mushroom.getTreeHeight(RandomSource.create(mushroomSeed)); + int radius = mushroom.foliageRadius(); + if (!mushroomRoom(level, sampler, protection, base, radius, height)) continue; + BlockState originalFloor = level.getBlockState(base.below()); + setBlock(level, base.below(), (Math.floorMod(mushroomSeed, 5) == 0 ? Blocks.MYCELIUM : Blocks.PODZOL).defaultBlockState()); + // Vanilla consumes its height draw first. Replaying the same random seed + // gives precisely the height whose natural envelope was checked above. + boolean placed = mushroom.place(level, generator, RandomSource.create(mushroomSeed), base); + if (!placed) setBlock(level, base.below(), originalFloor); + changed |= placed; + break; + } + } + return changed; + } + + private boolean tree(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler, long seed, + BlockPos base, Species species, Protection protection, long salt) { + if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species)) + || !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), species.width()) + || !actualRoom(level, base, species.width(), species.radius(), species.height()) + || !WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(), species.width(), species.radius(), species.height())) return false; + for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) { + BlockPos floor = base.offset(dx, -1, dz); + if (!ground(level, floor)) return false; + } + for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) { + setBlock(level, base.offset(dx, -1, dz), Blocks.PODZOL.defaultBlockState()); + } + return level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(species.key()).value() + .place(level, generator, RandomSource.create(WoodlandGroveGeometry.seed(seed, base.getX(), base.getY(), base.getZ(), salt)), base); + } + + private static boolean actualRoom(WorldGenLevel level, BlockPos base, int width, int radius, int height) { + for (int y = 0; y <= height; y++) { + int from = y < 3 ? 0 : -radius, to = y < 3 ? width - 1 : radius; + for (int dx = from; dx <= to; dx++) for (int dz = from; dz <= to; dz++) { + BlockState current = level.getBlockState(base.offset(dx, y, dz)); + if (!current.getFluidState().isEmpty() || !(current.isAir() || current.is(BlockTags.LEAVES) + || current.getBlock() instanceof VegetationBlock)) return false; + } + } + return true; + } + + private static boolean ground(WorldGenLevel level, BlockPos floor) { + BlockState state = level.getBlockState(floor); + if (state.is(Blocks.DEEPSLATE)) { + // Deep humid ledges can support organic soil. Check every tree foot rather + // than admitting this rock globally, including for the remarkable tree. + var biome = level.getBiome(floor.above()); + return biome.is(PopulationIslandBiomeSource.DARK_GROVE) || biome.is(PopulationIslandBiomeSource.BAMBOO_GROVE); + } + return state.is(Blocks.STONE) || state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.PODZOL) + || state.is(Blocks.MYCELIUM) || state.is(Blocks.ROOTED_DIRT) || state.is(Blocks.COARSE_DIRT) + || state.is(Blocks.GRANITE) || state.is(Blocks.DIORITE) || state.is(Blocks.ANDESITE) || state.is(Blocks.TUFF) + || state.is(Blocks.COBBLESTONE) || state.is(Blocks.MOSSY_COBBLESTONE) || state.is(Blocks.PACKED_MUD); + } + + private static int localTop(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection, + int minX, int minZ, int x, int z, int[] heights) { + int index = (x - minX) * 16 + z - minZ; + if (heights[index] != Integer.MIN_VALUE) return heights[index]; + // In an untouched column the heightmap bounds the original surface cheaply. + // A protected water/lava column may have been carved: recover its raw top + // without depending on the current hydrology plan's representation. + int ceiling = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - 1; + if (protection.intersects(new BlockPos(x, level.getMinY(), z), 0, level.getHeight() - 1, 0)) + ceiling = level.getMaxY(); + return heights[index] = WoodlandGroveGeometry.highestSurfaceBelow(sampler, x, z, ceiling); + } + + private static boolean mushroomRoom(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection, + BlockPos base, int radius, int height) { + if (blocked(protection, base, radius + 1, height + 1, 4) + || !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), 1)) return false; + for (int depth = 1; depth <= 3; depth++) { + BlockPos floor = base.below(depth); + if (!level.getBlockState(floor).isCollisionShapeFullBlock(level, floor)) return false; + } + // A complete conservative column around the cap handles both brown umbrellas + // and the lower rounded red cap. Neither natural rock nor existing logs move. + for (int y = 0; y <= height + 1; y++) for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) { + BlockPos p = base.offset(dx, y, dz); + BlockState current = level.getBlockState(p); + if (sampler.sample(p.getX(), p.getY(), p.getZ()) > 0 || !current.getFluidState().isEmpty() + || !(current.isAir() || current.is(BlockTags.LEAVES))) return false; + } + // Being below another surface is not enough: the same column needs an actual + // original rock ceiling above the whole mushroom, not an open hillside. + for (int y = base.getY() + height + 2; y <= level.getMaxY(); y++) + if (sampler.sample(base.getX(), y, base.getZ()) > 0) return true; + return false; + } + + private static int rootDepth(Species species) { return species.key().equals(TreeFeatures.MANGROVE) ? 16 : 4; } + + private static boolean blocked(Protection protection, BlockPos base, int radius, int height) { + return blocked(protection, base, radius, height, 4); + } + + private static boolean blocked(Protection protection, BlockPos base, int radius, int height, int rootDepth) { + return protection.intersects(base, radius, height, rootDepth); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrology.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrology.java new file mode 100644 index 0000000..0a6715d --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrology.java @@ -0,0 +1,1073 @@ +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.10 river catchments, wider basins and rocky wall springs. + * This version is separate from NaturalHydrology so previously created worlds keep their original terrain. + * Results retain no chunks, sampler, random state or Minecraft objects. */ +public final class PopulationHydrology { + public static final int RADIUS = 192; + public static final int MAX_CARVE = 16; + public static final int MAX_DEPTH = 8; + public static final int MAX_LAKE_CARVE = 12; + public static final int MAX_RIVER_BANK_CARVE = 12; + private static final int GRID = 8; + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + + private PopulationHydrology() {} + + @FunctionalInterface + public interface Sampler { float sample(int x, int y, int z); } + + public enum Kind { POND, LAKE, RIVER, TERRACE } + public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS } + public record Point(int x, int z) {} + public record Bounds(int minX, int minZ, int maxX, int maxZ) {} + + public record Position(int x, int y, int z) {} + public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {} + public record Spring(long id, Position source, Position outlet, List flowPath, FlowBounds flowBounds) { + public Spring { flowPath = List.copyOf(flowPath); } + } + public record Terrace(long id, List basinIds) { + public Terrace { basinIds = List.copyOf(basinIds); } + } + /** A retained upper basin supplies this outlet. toFeatureId=-1 denotes an open terminal fall. */ + public record Spill(long id, long fromFeatureId, long toFeatureId, Position source, Position outlet, + List flowPath, FlowBounds flowBounds) { + public Spill { flowPath = List.copyOf(flowPath); } + } + + /** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below. + * Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry + * terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */ + public record Cell(int x, int z, int waterY, int bedY, int carveTop, + ShoreMaterial material, long featureId, int sedimentDepth) { + public boolean hasWater() { return waterY >= 0; } + } + + public record Feature(long id, Kind kind, int waterY, List path, int waterCells, Bounds bounds) { + public Feature { path = List.copyOf(path); } + } + + public static final class Plan { + private final Map cells; + private final List allCells; + private final Map> layers; + private final Map> chunks; + private final List features; + private final List springs; + private final Map> springChunks; + private final Set springFlow; + private final int sampledColumns; + private final long densitySamples; + private final List terraces; + private final List spills; + private final Set spillOpenings; + + private Plan(Map source, List features, List springs, Set springFlow, int columns, long samples) { + this(source, features, springs, springFlow, columns, samples, PopulationTerraces.Result.EMPTY); + } + + private Plan(Map source, List features, List springs, Set springFlow, + int columns, long samples, PopulationTerraces.Result lower) { + List combined = new ArrayList<>(source.values()); + combined.addAll(lower.cells()); + List sorted = combined.stream() + .sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z).thenComparingInt(Cell::bedY)).toList(); + allCells = List.copyOf(sorted); + Map ordered = new LinkedHashMap<>(); + Map> stacked = new HashMap<>(); + Map> byChunk = new HashMap<>(); + for (Cell cell : sorted) { + ordered.put(key(cell.x(), cell.z()), cell); + stacked.computeIfAbsent(key(cell.x(), cell.z()), ignored -> new ArrayList<>()).add(cell); + byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell); + } + byChunk.replaceAll((ignored, values) -> List.copyOf(values)); + cells = Collections.unmodifiableMap(ordered); + stacked.replaceAll((ignored, values) -> List.copyOf(values)); + layers = Map.copyOf(stacked); + chunks = Map.copyOf(byChunk); + List allFeatures = new ArrayList<>(features); + allFeatures.addAll(lower.features()); + this.features = List.copyOf(allFeatures); + this.springs = List.copyOf(springs); + Set allFlow = new HashSet<>(springFlow); + allFlow.addAll(lower.predictedFlow()); + this.springFlow = Set.copyOf(allFlow); + terraces = List.copyOf(lower.terraces()); + spills = List.copyOf(lower.spills()); + spillOpenings = spills.stream().map(Spill::outlet).collect(java.util.stream.Collectors.toUnmodifiableSet()); + Map> groupedSprings = new HashMap<>(); + for (Spring spring : springs) groupedSprings.computeIfAbsent( + key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring); + groupedSprings.replaceAll((ignored, values) -> List.copyOf(values)); + springChunks = Map.copyOf(groupedSprings); + sampledColumns = columns; + densitySamples = samples; + } + + public Cell cellAt(int x, int z) { return cells.get(key(x, z)); } + public List cellsAt(int x, int z) { return layers.getOrDefault(key(x, z), List.of()); } + /** Return the modified vertical interval, including its two unchanged support layers. */ + public Cell cellAt(int x, int y, int z) { + for (Cell cell : cellsAt(x, z)) if (y >= cell.bedY() - cell.sedimentDepth() - 1 + && y <= Math.max(cell.waterY(), cell.carveTop())) return cell; + return null; + } + public Collection cells() { return allCells; } + public List cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); } + public List features() { return features; } + public boolean isRiverCell(Cell cell) { return features.stream().anyMatch(f -> f.kind() == Kind.RIVER && f.id() == cell.featureId()); } + public boolean isTerraceCell(Cell cell) { return features.stream().anyMatch(f -> f.kind() == Kind.TERRACE && f.id() == cell.featureId()); } + public List terraces() { return terraces; } + public List spills() { return spills; } + public boolean isSpillOpening(int x, int y, int z) { return spillOpenings.contains(new Position(x, y, z)); } + public List springs() { return springs; } + public List springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); } + /** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */ + public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); } + public int sampledColumns() { return sampledColumns; } + public long densitySamples() { return densitySamples; } + + /** Admit complete local catchments only. Never clip a bank, waterfall or terrace group. */ + public Plan admitted() { + Set rejected = new HashSet<>(); + for (Feature feature : features) { + Bounds b = feature.bounds(); + if (!PopulationOwnership.contains(b.minX(), b.minZ(), b.maxX(), b.maxZ(), 16) + || feature.path().stream().anyMatch(p -> !owned(p.x(), p.z()))) rejected.add(feature.id()); + } + for (Cell cell : allCells) if (!owned(cell.x(), cell.z())) rejected.add(cell.featureId()); + for (Spill spill : spills) if (!owned(spill.source()) || !owned(spill.outlet()) + || !owned(spill.flowBounds()) || spill.flowPath().stream().anyMatch(p -> !owned(p))) { + rejected.add(spill.fromFeatureId()); + if (spill.toFeatureId() != -1) rejected.add(spill.toFeatureId()); + } + // A spill was traced against all earlier cuts. Removing one catchment can + // invalidate another group's route, so propagate rejection until stable. + boolean changed; + do { + int previous = rejected.size(); + for (Terrace terrace : terraces) if (terrace.basinIds().stream().anyMatch(rejected::contains)) + rejected.addAll(terrace.basinIds()); + List removed = allCells.stream().filter(c -> rejected.contains(c.featureId())).toList(); + for (Spill spill : spills) if (touches(spill.flowBounds(), removed)) { + rejected.add(spill.fromFeatureId()); + if (spill.toFeatureId() != -1) rejected.add(spill.toFeatureId()); + } + changed = rejected.size() != previous; + } while (changed); + List removed = allCells.stream().filter(c -> rejected.contains(c.featureId())).toList(); + List keptCells = allCells.stream().filter(c -> !rejected.contains(c.featureId())).toList(); + List keptFeatures = features.stream().filter(f -> !rejected.contains(f.id())).toList(); + List keptTerraces = terraces.stream() + .filter(t -> t.basinIds().stream().noneMatch(rejected::contains)).toList(); + List keptSpills = spills.stream().filter(s -> !rejected.contains(s.fromFeatureId()) + && (s.toFeatureId() == -1 || !rejected.contains(s.toFeatureId()))).toList(); + List keptSprings = springs.stream().filter(s -> owned(s.source()) && owned(s.outlet()) + && owned(s.flowBounds()) && s.flowPath().stream().allMatch(Plan::owned) + && !touches(s.flowBounds(), removed)).toList(); + List retainedBounds = new ArrayList<>(); + keptSprings.forEach(s -> retainedBounds.add(s.flowBounds())); + keptSpills.forEach(s -> retainedBounds.add(s.flowBounds())); + // The original prediction is a union, not a per-source journal. Keep only + // its voxels inside admitted flow envelopes; it never drives block writes. + Set retainedFlow = springFlow.stream() + .filter(p -> retainedBounds.stream().anyMatch(b -> contains(b, p))) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return rebuild(keptCells, keptFeatures, keptSprings, retainedFlow, keptTerraces, keptSpills); + } + + /** Translate every geometry-bearing field while preserving regional feature identities. */ + public Plan translated(int dx, int dz) { + List movedCells = allCells.stream().map(c -> new Cell(Math.addExact(c.x(), dx), + Math.addExact(c.z(), dz), c.waterY(), c.bedY(), c.carveTop(), c.material(), c.featureId(), c.sedimentDepth())).toList(); + List movedFeatures = features.stream().map(f -> new Feature(f.id(), f.kind(), f.waterY(), + f.path().stream().map(p -> new Point(Math.addExact(p.x(), dx), Math.addExact(p.z(), dz))).toList(), + f.waterCells(), new Bounds(Math.addExact(f.bounds().minX(), dx), Math.addExact(f.bounds().minZ(), dz), + Math.addExact(f.bounds().maxX(), dx), Math.addExact(f.bounds().maxZ(), dz)))).toList(); + List movedSprings = springs.stream().map(s -> new Spring(s.id(), move(s.source(), dx, dz), + move(s.outlet(), dx, dz), s.flowPath().stream().map(p -> move(p, dx, dz)).toList(), move(s.flowBounds(), dx, dz))).toList(); + List movedSpills = spills.stream().map(s -> new Spill(s.id(), s.fromFeatureId(), s.toFeatureId(), + move(s.source(), dx, dz), move(s.outlet(), dx, dz), + s.flowPath().stream().map(p -> move(p, dx, dz)).toList(), move(s.flowBounds(), dx, dz))).toList(); + Set movedFlow = springFlow.stream().map(p -> move(p, dx, dz)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return rebuild(movedCells, movedFeatures, movedSprings, movedFlow, terraces, movedSpills); + } + + private Plan rebuild(List selectedCells, List selectedFeatures, List selectedSprings, + Set selectedFlow, List selectedTerraces, List selectedSpills) { + Set lowerIds = selectedFeatures.stream().filter(f -> f.kind() == Kind.TERRACE).map(Feature::id) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + Map surfaceCells = new LinkedHashMap<>(); + List lowerCells = new ArrayList<>(); + for (Cell cell : selectedCells) { + if (lowerIds.contains(cell.featureId())) lowerCells.add(cell); + else if (surfaceCells.put(key(cell.x(), cell.z()), cell) != null) + throw new IllegalStateException("Two surface cells share a column"); + } + var lower = new PopulationTerraces.Result(lowerCells, + selectedFeatures.stream().filter(f -> lowerIds.contains(f.id())).toList(), + selectedTerraces, selectedSpills, Set.of()); + return new Plan(surfaceCells, selectedFeatures.stream().filter(f -> !lowerIds.contains(f.id())).toList(), + selectedSprings, selectedFlow, sampledColumns, densitySamples, lower); + } + + private static boolean owned(int x, int z) { return PopulationOwnership.contains(x, z, 16); } + private static boolean owned(Position p) { return owned(p.x(), p.z()); } + private static boolean owned(FlowBounds b) { + return PopulationOwnership.contains(b.minX(), b.minZ(), b.maxX(), b.maxZ(), 16); + } + private static boolean touches(FlowBounds bounds, List removed) { + return removed.stream().anyMatch(c -> c.x() >= bounds.minX() && c.x() <= bounds.maxX() + && c.z() >= bounds.minZ() && c.z() <= bounds.maxZ() + && c.bedY() - c.sedimentDepth() - 1 <= bounds.maxY() && c.carveTop() >= bounds.minY()); + } + private static boolean contains(FlowBounds b, Position p) { + return p.x() >= b.minX() && p.x() <= b.maxX() && p.z() >= b.minZ() && p.z() <= b.maxZ() + && p.y() >= b.minY() && p.y() <= b.maxY(); + } + private static Position move(Position p, int dx, int dz) { + return new Position(Math.addExact(p.x(), dx), p.y(), Math.addExact(p.z(), dz)); + } + private static FlowBounds move(FlowBounds b, int dx, int dz) { + return new FlowBounds(Math.addExact(b.minX(), dx), b.minY(), Math.addExact(b.minZ(), dz), + Math.addExact(b.maxX(), dx), b.maxY(), Math.addExact(b.maxZ(), dz)); + } + } + + public static Plan create(long seed, Sampler sampler) { + return new Planner(seed, sampler).build(); + } + + private record Column(int top, int solidBottom) {} + private record Candidate(int x, int z, double score) {} + private record Node(int x, int z, double cost) {} + private static final Comparator NODE_ORDER = Comparator.comparingDouble(Node::cost) + .thenComparingInt(Node::x).thenComparingInt(Node::z); + + private static final class Planner { + private final long seed; + private final Sampler sampler; + private final Map columns = new HashMap<>(); + private final Map coarse = new HashMap<>(); + private final Map cells = new HashMap<>(); + private final List features = new ArrayList<>(); + private final List springs = new ArrayList<>(); + private final Set springFlow = new HashSet<>(); + private long samples; + + Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; } + + Plan build() { + List candidates = candidates(); + addRiver(); + int attempts = 0; + for (Candidate candidate : candidates) { + if (features.size() >= 4 || attempts++ >= 96) break; + Point center = new Point(candidate.x(), candidate.z()); + if (nearWater(center.x(), center.z(), 38)) continue; + Column column = column(center.x(), center.z()); + if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue; + Kind kind = features.size() < 2 ? Kind.LAKE : Kind.POND; + int target = kind == Kind.LAKE ? 700 + (int) (random(seed, center.x(), center.z()) * 400) + : 100 + (int) (random(seed + 1, center.x(), center.z()) * 140); + // Prefer an existing low spot; a shallow cut is the fallback on level terrain. + for (int level : new int[]{column.top() + 1, column.top(), column.top() - 1, column.top() - 2}) { + Set footprint = growBasin(center, level, target); + if (footprint == null) continue; + long id = featureId(features.size()); + Map wet = kind == Kind.LAKE ? lakeCells(footprint, level, id) : waterCells(footprint, level, id); + if (wet == null || !contained(wet)) continue; + addFeature(id, kind, level, List.of(center), wet); + break; + } + } + addRiverSpring(); + addSedimentSheets(); + addLowerWallSprings(); + addSprings(); + Plan surface = new Plan(cells, features, springs, springFlow, columns.size(), samples); + var lower = PopulationTerraces.create(seed, sampler, surface); + return new Plan(cells, features, springs, springFlow, columns.size(), samples, lower); + } + + /** A connected catchment is excavated only in a thick natural plateau. The route is + * selected on the actual fissured density, not painted across missing rock. Its two + * terminal pools share the same retained waterline; a separate rock spring feeds it. */ + private void addRiver() { + List heights = coarse.values().stream().filter(y -> y >= 160).sorted().toList(); + if (heights.isEmpty()) return; + Set levels = new java.util.LinkedHashSet<>(); + for (double quantile : new double[]{.55, .35, .75, .15, .90}) { + int top = heights.get(Math.min(heights.size() - 1, (int) (heights.size() * quantile))); + for (int cut : new int[]{4, 8, 0}) levels.add(top - cut); + } + for (int level : levels) { + Set allowed = new HashSet<>(); + for (int x = -168; x <= 168; x += 4) for (int z = -168; z <= 168; z += 4) { + if (!inside(x, z, 170)) continue; + boolean safe = riverRock(x, z, level); + for (int dx : new int[]{-3, 3}) for (int dz : new int[]{-3, 3}) { + if (safe && !riverRock(x + dx, z + dz, level)) safe = false; + } + if (safe) allowed.add(key(x, z)); + } + for (int attempt = 0; attempt < 4 && allowed.size() > 100; attempt++) { + List route = longestRoute(allowed, level); + if (route.size() < 36) break; + Point first = route.getFirst(), last = route.getLast(); + if (Math.hypot(last.x() - first.x(), last.z() - first.z()) < 100) break; + List path = smoothRoute(route); + Set wet = new HashSet<>(); + Set bad = new HashSet<>(); + for (Point at : path) { + int radius = field(at.x(), at.z(), 23, 8119) > .52 ? 3 : 2; + for (int dx = -radius - 1; dx <= radius + 1; dx++) for (int dz = -radius - 1; dz <= radius + 1; dz++) { + if (!riverRock(at.x() + dx, at.z() + dz, level)) { + for (int gx = -1; gx <= 1; gx++) for (int gz = -1; gz <= 1; gz++) + bad.add(key(Math.floorDiv(at.x(), 4) * 4 + gx * 4, Math.floorDiv(at.z(), 4) * 4 + gz * 4)); + } + if (dx * dx + dz * dz <= radius * radius + 1) wet.add(key(at.x() + dx, at.z() + dz)); + } + } + if (!bad.isEmpty()) { allowed.removeAll(bad); continue; } + // Both end pools are required and remain connected to the channel. + boolean pools = true; + for (Point endpoint : List.of(first, last)) { + Set pool = growRiverPool(endpoint, level); + if (pool.size() < 90) { pools = false; break; } + wet.addAll(pool); + } + if (!pools) { allowed.remove(key(first.x(), first.z())); allowed.remove(key(last.x(), last.z())); continue; } + long id = featureId(features.size()); + Map river = new HashMap<>(); + boolean safe = true; + for (long k : wet) { + Point at = point(k); + Column rock = column(at.x(), at.z()); + int depth = 2; + boolean edge = false; + for (int[] d : CARDINALS) edge |= !wet.contains(key(at.x() + d[0], at.z() + d[1])); + if (edge) depth = 1; + int bed = level - depth; + if (rock.top() < bed || rock.top() - bed > MAX_CARVE || rock.solidBottom() > bed - 6) { safe = false; break; } + river.put(k, new Cell(at.x(), at.z(), level, bed, Math.max(level, rock.top()), + wetMaterial(at.x(), at.z(), edge), id, 3)); + } + if (!safe || !contained(river)) break; + addFeature(id, Kind.RIVER, level, path, river); + return; + } + } + } + + private Set growRiverPool(Point center, int level) { + Set result = new HashSet<>(); + PriorityQueue queue = new PriorityQueue<>(NODE_ORDER); + Set seen = new HashSet<>(); + queue.add(new Node(center.x(), center.z(), 0)); + while (!queue.isEmpty() && result.size() < 240) { + Node at = queue.remove(); + if (!seen.add(key(at.x(), at.z())) || Math.hypot(at.x() - center.x(), at.z() - center.z()) > 12 + || !riverRock(at.x(), at.z(), level)) continue; + boolean retained = true; + for (int[] d : CARDINALS) retained &= solid(at.x() + d[0], level, at.z() + d[1]); + if (!retained) continue; + result.add(key(at.x(), at.z())); + for (int[] d : CARDINALS) { + int x = at.x() + d[0], z = at.z() + d[1]; + queue.add(new Node(x, z, Math.hypot(x - center.x(), z - center.z()) + + field(x, z, 8, 411) * 6 + Math.max(0, column(x,z).top() - level) * .4)); + } + } + return result; + } + + private boolean riverRock(int x, int z, int level) { + Column rock = column(x, z); + return rock.top() >= level && rock.top() <= level + 12 && rock.solidBottom() <= level - 8; + } + + private List longestRoute(Set allowed, int level) { + Set remaining = new HashSet<>(allowed); + List longest = List.of(); + while (!remaining.isEmpty()) { + long start = remaining.stream().min(Long::compare).orElseThrow(); + Map first = routeTree(start, allowed); + remaining.removeAll(first.keySet()); + if (first.size() < 36) continue; + long far = lastKey(first); + Map second = routeTree(far, first.keySet()); + long end = lastKey(second); + Map shaped = riverRouteTree(far, end, first.keySet(), level); + List path = new ArrayList<>(); + for (long at = end; ; at = shaped.get(at)) { + path.add(point(at)); + if (at == far) break; + } + if (path.size() > longest.size()) longest = path; + } + return longest; + } + + private Map riverRouteTree(long start, long end, Set allowed, int level) { + Map parent = new HashMap<>(); + Map costs = new HashMap<>(); + PriorityQueue queue = new PriorityQueue<>(NODE_ORDER); + Point origin = point(start); + parent.put(start, start); costs.put(start, 0.0); queue.add(new Node(origin.x(), origin.z(), 0)); + while (!queue.isEmpty()) { + Node at = queue.remove(); + long current = key(at.x(), at.z()); + if (at.cost() > costs.getOrDefault(current, Double.POSITIVE_INFINITY)) continue; + if (current == end) break; + for (int[] d : NEIGHBORS) { + int x = at.x() + d[0] * 4, z = at.z() + d[1] * 4; + long next = key(x, z); + if (!allowed.contains(next)) continue; + double cost = at.cost() + Math.hypot(d[0], d[1]) * (1 + Math.max(0, column(x,z).top() - level) * .14 + + field(x, z, 27, 8113) * 1.8); + if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue; + parent.put(next, current); costs.put(next, cost); queue.add(new Node(x,z,cost)); + } + } + return parent; + } + + private List smoothRoute(List coarsePath) { + record Smooth(double x, double z) {} + List values = coarsePath.stream().map(p -> new Smooth(p.x(), p.z())).toList(); + // Bend over several grid steps rather than rounding each voxel-sized corner. + for (int pass = 0; pass < 2; pass++) { + List next = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + if (i == 0 || i == values.size() - 1) { next.add(values.get(i)); continue; } + double x = 0, z = 0, sum = 0; + for (int offset = -4; offset <= 4; offset++) { + int weight = 5 - Math.abs(offset); + Smooth p = values.get(Math.clamp(i + offset, 0, values.size() - 1)); + x += p.x() * weight; z += p.z() * weight; sum += weight; + } + next.add(new Smooth(x / sum, z / sum)); + } + values = next; + } + for (int pass = 0; pass < 2; pass++) { + List next = new ArrayList<>(); next.add(values.getFirst()); + for (int i = 1; i < values.size(); i++) { + Smooth a = values.get(i - 1), b = values.get(i); + next.add(new Smooth(a.x() * .75 + b.x() * .25, a.z() * .75 + b.z() * .25)); + next.add(new Smooth(a.x() * .25 + b.x() * .75, a.z() * .25 + b.z() * .75)); + } + next.add(values.getLast()); values = next; + } + List path = new ArrayList<>(); + for (int i = 1; i < values.size(); i++) { + Smooth a = values.get(i - 1), b = values.get(i); + int steps = Math.max(1, (int) Math.ceil(Math.max(Math.abs(b.x() - a.x()), Math.abs(b.z() - a.z())))); + for (int step = 0; step <= steps; step++) { + double t = step / (double) steps; + Point p = new Point((int) Math.round(a.x() + (b.x() - a.x()) * t), (int) Math.round(a.z() + (b.z() - a.z()) * t)); + if (path.isEmpty() || !path.getLast().equals(p)) path.add(p); + } + } + return path; + } + + private Map routeTree(long start, Set allowed) { + Map parent = new LinkedHashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + parent.put(start, start); queue.add(start); + while (!queue.isEmpty()) { + long current = queue.removeFirst(); + Point at = point(current); + for (int[] d : NEIGHBORS) { + long next = key(at.x() + d[0] * 4, at.z() + d[1] * 4); + if (allowed.contains(next) && !parent.containsKey(next)) { parent.put(next, current); queue.addLast(next); } + } + } + return parent; + } + + private long lastKey(Map values) { + long result = 0; + for (long key : values.keySet()) result = key; + return result; + } + + /** A real spring emerges from one of the river's taller rock banks and drops into the + * connected retained channel. The source replaces rock; its mouth is the carved valley. */ + private void addRiverSpring() { + Feature river = features.stream().filter(f -> f.kind() == Kind.RIVER).findFirst().orElse(null); + if (river == null) return; + List bank = cells.values().stream().filter(c -> c.featureId() == river.id()) + .sorted(Comparator.comparingInt(Cell::carveTop).reversed().thenComparingInt(Cell::x).thenComparingInt(Cell::z)).toList(); + for (Cell wet : bank) for (int[] d : CARDINALS) { + int x = wet.x() + d[0], z = wet.z() + d[1]; + if (cells.containsKey(key(x, z))) continue; + for (int height : new int[]{8, 7, 6, 5}) { + int y = wet.waterY() + height; + if (wet.carveTop() < y) continue; + Position source = new Position(x, y, z), outlet = new Position(wet.x(), y, wet.z()); + boolean safe = true; + for (int sy = y - 3; sy <= y + 1; sy++) safe &= flowSolid(new Position(x, sy, z), outlet); + for (int[] side : CARDINALS) { + if (side[0] == -d[0] && side[1] == -d[1]) continue; + safe &= flowSolid(new Position(x + side[0], y, z + side[1]), outlet); + } + if (!safe) continue; + Flow flow = traceSpring(source, outlet); + if (flow == null || flow.volume().stream().noneMatch(p -> { + Cell c = cells.get(key(p.x(), p.z())); + return c != null && c.featureId() == river.id() && p.y() <= c.waterY() && p.y() > c.bedY(); + })) continue; + long id = mix(seed ^ 0xA81BE2L); + springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds())); + springFlow.addAll(flow.volume()); + return; + } + } + } + + private List candidates() { + for (int x = -RADIUS; x <= RADIUS; x += GRID) { + for (int z = -RADIUS; z <= RADIUS; z += GRID) { + if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z)); + } + } + List result = new ArrayList<>(); + for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) { + for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) { + int height = coarse.getOrDefault(key(x, z), -1); + if (height < 64 || !inside(x, z, RADIUS - 24)) continue; + double average = 0; + boolean safe = true; + for (int[] d : CARDINALS) { + int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1); + if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false; + average += neighbor * 0.25; + } + if (safe) result.add(new Candidate(x, z, + (average - height) * 2.0 + random(seed + 2, x, z) * 3.0)); + } + } + result.sort(Comparator.comparingDouble(Candidate::score).reversed() + .thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)); + return result; + } + + private int coarseTop(int x, int z) { + for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y; + return -1; + } + + private Column column(int x, int z) { + long key = key(x, z); + Column old = columns.get(key); + if (old != null) return old; + int top = -1; + // Exact scan, including everything above the planned water: a cave roof cannot be + // mistaken for a surface. Only candidates and their nearby shore need this refinement. + for (int y = 383; y >= 24; y--) { + if (solid(x, y, z)) { top = y; break; } + } + int bottom = top; + if (top >= 0) { + while (bottom > Math.max(16, top - 40) && solid(x, bottom - 1, z)) bottom--; + } + Column created = new Column(top, bottom); + columns.put(key, created); + return created; + } + + private boolean eligible(int x, int z, int level) { + if (level < 32 || level >= 383 || !inside(x, z, RADIUS - 2)) return false; + Cell old = cells.get(key(x, z)); + if (old != null && old.hasWater() && old.waterY() != level) return false; + Column column = column(x, z); + return column.top() >= level - 3 && column.top() < level + 6 + && column.solidBottom() <= Math.min(column.top(), level - 1) - 4; + } + + private Set growBasin(Point center, int level, int target) { + if (!eligible(center.x(), center.z(), level)) return null; + Set wet = new HashSet<>(); + Map costs = new HashMap<>(); + PriorityQueue queue = new PriorityQueue<>(NODE_ORDER); + queue.add(new Node(center.x(), center.z(), 0)); + costs.put(key(center.x(), center.z()), 0.0); + while (!queue.isEmpty() && wet.size() < target) { + Node current = queue.remove(); + if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue; + if (!eligible(current.x(), current.z(), level)) continue; + wet.add(key(current.x(), current.z())); + for (int[] d : NEIGHBORS) { + int x = current.x() + d[0], z = current.z() + d[1]; + if (Math.hypot(x - center.x(), z - center.z()) > 40 || wet.contains(key(x, z))) continue; + if (!eligible(x, z, level)) continue; + double step = Math.hypot(d[0], d[1]); + double cost = current.cost() + step * (1 + Math.max(0, column(x, z).top() - level) * 0.8 + + terrainNoise(x, z) * 4.5 + field(x + 71, z - 93, 21, 107) * 3.5); + if (cost >= costs.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue; + costs.put(key(x, z), cost); + queue.add(new Node(x, z, cost)); + } + } + wet = connectedTo(wet, key(center.x(), center.z())); + if (wet.size() < Math.min(target, 30)) return null; + // Filling a hollow requires every lower connected outlet to be included. Abort rather + // than place a dam if this natural basin runs to a cliff, cave or an oversized catchment. + List work = new ArrayList<>(wet); + for (int i = 0; i < work.size(); i++) { + Point point = point(work.get(i)); + for (int[] d : CARDINALS) { + int x = point.x() + d[0], z = point.z() + d[1]; + long neighbor = key(x, z); + if (wet.contains(neighbor) || column(x, z).top() >= level) continue; + if (wet.size() >= 1200 || Math.hypot(x - center.x(), z - center.z()) > 48 + || !eligible(x, z, level)) return null; + wet.add(neighbor); + work.add(neighbor); + } + } + return wet; + } + + private Map waterCells(Set footprint, int level, long id) { + Map result = new HashMap<>(); + for (long key : footprint) { + Point p = point(key); + if (!eligible(p.x(), p.z(), level)) return null; + boolean edge = false; + for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1])); + Column column = column(p.x(), p.z()); + int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0)); + depth = Math.min(depth, 8 - Math.max(0, column.top() - level)); + if (depth < 1 || depth > MAX_DEPTH) return null; + int bed = level - depth; + if (column.top() < bed || column.solidBottom() > bed - 2) return null; + ShoreMaterial material = wetMaterial(p.x(), p.z(), edge); + int sedimentDepth = sedimentDepth(p.x(), p.z()); + if (column.solidBottom() > bed - sedimentDepth - 1) return null; + result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()), + material, id, sedimentDepth)); + } + return result; + } + + /** Deepen only sufficiently thick interiors, with one-block shore steps. Natural weak + * columns keep a shallower bed instead of receiving a manufactured lake foundation. */ + private Map lakeCells(Set footprint, int level, long id) { + Map distance = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + for (long k : footprint) { + Point at = point(k); + for (int[] d : CARDINALS) if (!footprint.contains(key(at.x() + d[0], at.z() + d[1]))) { + distance.put(k, 0); queue.addLast(k); break; + } + } + while (!queue.isEmpty()) { + long k = queue.removeFirst(); Point at = point(k); + for (int[] d : CARDINALS) { + long next = key(at.x() + d[0], at.z() + d[1]); + if (footprint.contains(next) && !distance.containsKey(next)) { + distance.put(next, distance.get(k) + 1); queue.addLast(next); + } + } + } + Map result = new HashMap<>(); + for (long k : footprint) { + Point at = point(k); + if (!eligible(at.x(), at.z(), level)) return null; + Column rock = column(at.x(), at.z()); + int sediment = sedimentDepth(at.x(), at.z()); + int wanted = Math.max(level - rock.top() + 1, Math.min(MAX_DEPTH, 1 + distance.get(k))); + int depth = Math.min(wanted, Math.min(level - rock.solidBottom() - sediment - 1, + MAX_LAKE_CARVE - Math.max(0, rock.top() - level))); + if (depth < 1 || rock.top() < level - depth) return null; + int bed = level - depth; + result.put(k, new Cell(at.x(), at.z(), level, bed, Math.max(level, rock.top()), + wetMaterial(at.x(), at.z(), distance.get(k) == 0), id, sediment)); + } + return result; + } + + private boolean contained(Map wet) { + for (Cell cell : wet.values()) { + for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) { + if (!solid(cell.x(), y, cell.z())) return false; + } + for (int[] d : CARDINALS) { + int x = cell.x() + d[0], z = cell.z() + d[1]; + Cell neighbor = wet.get(key(x, z)); + if (neighbor == null) neighbor = cells.get(key(x, z)); + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + if (neighbor != null && neighbor.hasWater()) { + if (neighbor.waterY() != cell.waterY()) return false; + if (y > neighbor.bedY()) continue; + } + if (!solid(x, y, z)) return false; + } + } + } + return true; + } + + private void addFeature(long id, Kind kind, int level, List path, Map wet) { + int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE; + int count = 0; + for (Cell cell : wet.values()) { + Cell old = cells.get(key(cell.x(), cell.z())); + if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; } + minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z()); + maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z()); + } + features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ))); + } + + private static Set connectedTo(Set footprint, long start) { + Set connected = new HashSet<>(); + List work = new ArrayList<>(); + connected.add(start); work.add(start); + for (int i = 0; i < work.size(); i++) { + Point point = point(work.get(i)); + for (int[] d : CARDINALS) { + long next = key(point.x() + d[0], point.z() + d[1]); + if (footprint.contains(next) && connected.add(next)) work.add(next); + } + } + return connected; + } + + private record ShoreNode(int x, int z, int level, long feature, double cost) {} + + /** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby + * pocket can share the same sheet; the result is not a one-block material ring. */ + private void addSedimentSheets() { + Comparator order = Comparator.comparingDouble(ShoreNode::cost) + .thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature); + Set rivers = features.stream().filter(f -> f.kind() == Kind.RIVER).map(Feature::id) + .collect(java.util.stream.Collectors.toSet()); + PriorityQueue queue = new PriorityQueue<>(order); + Map best = new HashMap<>(); + for (Cell cell : cells.values()) if (cell.hasWater()) { + queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0)); + best.put(key(cell.x(), cell.z()), 0.0); + } + while (!queue.isEmpty()) { + ShoreNode node = queue.remove(); + if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue; + boolean riverBank = rivers.contains(node.feature()); + Column column = column(node.x(), node.z()); + Cell existing = cells.get(key(node.x(), node.z())); + if (existing == null) { + boolean springRock = springs.stream().anyMatch(spring -> + Math.abs(spring.source().x() - node.x()) <= 4 && Math.abs(spring.source().z() - node.z()) <= 4); + if (springRock) continue; + double extent = riverBank ? 18 + field(node.x(), node.z(), 25, 131) * 6 + : 9 + field(node.x(), node.z(), 25, 131) * 10; + if (node.cost() > extent) continue; + int delta = column.top() - node.level(); + if (delta < 0 || delta > (riverBank ? 14 : 5)) continue; + // Low shore: waterline, then one-block steps. Cut at most two existing blocks; + // far deposits keep the original landform and feather back into grass/stone. + int terrace = node.level() + (int) Math.floor(node.cost() / (riverBank ? 2.5 : 4.5)); + int cut = riverBank ? MAX_RIVER_BANK_CARVE : 2; + int bed = Math.max(node.level(), Math.max(column.top() - cut, Math.min(column.top(), terrace))); + int depth = sedimentDepth(node.x(), node.z()); + if (column.solidBottom() > bed - depth - 1) continue; + boolean safe = true; + for (int[] d : CARDINALS) { + Cell next = cells.get(key(node.x() + d[0], node.z() + d[1])); + if (next != null && next.hasWater() && bed < next.waterY()) safe = false; + } + if (!safe) continue; + ShoreMaterial material = dryMaterial(node.x(), node.z(), node.cost() / extent); + cells.put(key(node.x(), node.z()), new Cell(node.x(), node.z(), -1, bed, + column.top(), material, node.feature(), depth)); + } + for (int[] d : NEIGHBORS) { + int x = node.x() + d[0], z = node.z() + d[1]; + if (!inside(x, z, RADIUS - 2)) continue; + Cell water = cells.get(key(x, z)); + if (water != null && water.hasWater()) continue; + Column next = column(x, z); + if (next.top() < node.level() || next.top() > node.level() + (riverBank ? 14 : 5)) continue; + double cost = node.cost() + Math.hypot(d[0], d[1]) + * (0.7 + Math.abs(next.top() - column.top()) * 0.32 + field(x, z, 12, 211) * 0.6); + if (cost > (riverBank ? 24 : 19) || cost >= best.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue; + best.put(key(x, z), cost); + queue.add(new ShoreNode(x, z, node.level(), node.feature(), cost)); + } + } + } + + private int sedimentDepth(int x, int z) { return 3 + (int) (field(x, z, 15, 319) * 2.999); } + + private ShoreMaterial wetMaterial(int x, int z, boolean edge) { + double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25; + if (geology < 0.46) return ShoreMaterial.STONE; + if (geology < 0.65) return ShoreMaterial.GRAVEL; + if (!edge && geology > 0.78) return ShoreMaterial.CLAY; + return ShoreMaterial.SAND; + } + + private ShoreMaterial dryMaterial(int x, int z, double distance) { + double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25; + double grass = field(x + 103, z - 137, 19, 521); + if (grass > 0.62 || distance > 0.60 + grass * 0.65) return ShoreMaterial.GRASS; + if (geology < 0.46) return ShoreMaterial.STONE; + if (geology < 0.65) return ShoreMaterial.GRAVEL; + return ShoreMaterial.SAND; + } + + private record Cliff(int x, int z, int dx, int dz, double score) {} + private record FlowNode(Position position, int reach) {} + private record Flow(Set volume, List path, FlowBounds bounds) {} + + /** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces + * one rock block under an existing roof. Its outlet is already air over an eight-block drop; + * no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */ + private void addSprings() { + List cliffs = new ArrayList<>(); + for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) { + if (!inside(x, z, 264)) continue; + int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z())); + if (top < 88) continue; + for (int[] d : CARDINALS) { + int nx = x + d[0] * GRID, nz = z + d[1] * GRID; + int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z())); + if (top - lower < 16) continue; + cliffs.add(new Cliff(x, z, d[0], d[1], + Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3)); + } + } + cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed() + .thenComparingInt(Cliff::x).thenComparingInt(Cliff::z) + .thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz)); + int attempts = 0; + for (Cliff cliff : cliffs) { + if (springs.size() >= 3 || attempts++ >= 160) break; + boolean placed = false; + for (int tangent : new int[]{0, -2, 2, -4, 4}) { + for (int along = 0; along < GRID; along++) { + int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent; + int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent; + if (!inside(x, z, 266)) continue; + int ox = x + cliff.dx(), oz = z + cliff.dz(); + Column rock = column(x, z), outletColumn = column(ox, oz); + if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue; + // Five blocks below the highest natural surface is below the soil cap. + int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8)); + for (int y = rock.top() - 5; y >= floor; y -= 3) { + Position source = new Position(x, y, z), outlet = new Position(ox, y, oz); + if (!separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz()) || nearModifiedGround(source, 32)) continue; + Flow flow = traceSpring(source, outlet); + if (flow == null || flow.bounds().minY() > y - 12) continue; + long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L)); + springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds())); + springFlow.addAll(flow.volume()); + placed = true; + break; + } + if (placed) break; + } + if (placed) break; + } + } + } + + private boolean nearModifiedGround(Position source, int distance) { + for (Cell cell : cells.values()) { + int dy = Math.max(Math.max(cell.bedY() - cell.sedimentDepth() - source.y(), 0), + source.y() - cell.carveTop()); + long dx = source.x() - cell.x(), dz = source.z() - cell.z(); + if (dx * dx + dz * dz + (long) dy * dy < (long) distance * distance) return true; + } + return false; + } + + private boolean separatedSpring(Position source) { + for (Spring spring : springs) { + double horizontal = Math.hypot(source.x() - spring.source().x(), source.z() - spring.source().z()); + int dy = Math.abs(source.y() - spring.source().y()); + if (Math.hypot(horizontal, dy) < 64 || dy < 12) return false; + } + return true; + } + + private record Wall(int x, int y, int z, int dx, int dz, double score) {} + + /** Probe a few exposed faces below the highest surface as well. This finds overhangs and + * lower strata that a highest-column heightmap cannot represent, without inventing a ledge. */ + private void addLowerWallSprings() { + List candidates = new ArrayList<>(); + for (int x = -240; x <= 240; x += 16) for (int z = -240; z <= 240; z += 16) { + if (!inside(x, z, 240)) continue; + int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z())); + for (int y = top - 32; y >= 64; y -= 16) { + if (!solid(x, y, z)) continue; + for (int[] direction : CARDINALS) { + if (solid(x + direction[0] * 4, y, z + direction[1] * 4)) continue; + candidates.add(new Wall(x, y, z, direction[0], direction[1], + random(seed + 1217 + y, x + direction[0], z + direction[1]) * 3 + + Math.min(100, top - y) * 0.015)); + } + } + } + candidates.sort(Comparator.comparingDouble(Wall::score).reversed() + .thenComparingInt(Wall::x).thenComparingInt(Wall::z).thenComparingInt(Wall::y) + .thenComparingInt(Wall::dx).thenComparingInt(Wall::dz)); + int attempts = 0; + for (Wall candidate : candidates) { + if (springs.size() >= 2 || attempts++ >= 96) break; + boolean placed = false; + for (int tangent : new int[]{0, -2, 2}) { + for (int along = 0; along < 4; along++) { + int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent; + int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent; + for (int dy : new int[]{0, 3, -3, 6, -6}) { + int y = candidate.y() + dy; + if (y < 56 || column(x, z).top() - y < 24) continue; + Position source = new Position(x, y, z); + if (!separatedSpring(source) || !springNiche(source, candidate.dx(), candidate.dz()) + || nearModifiedGround(source, 32)) continue; + Position outlet = new Position(x + candidate.dx(), y, z + candidate.dz()); + Flow flow = traceSpring(source, outlet); + if (flow == null || flow.bounds().minY() > y - 12) continue; + long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L)); + springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds())); + springFlow.addAll(flow.volume()); + placed = true; + break; + } + if (placed) break; + } + if (placed) break; + } + } + } + + private boolean springNiche(Position source, int dx, int dz) { + for (int y = source.y() - 3; y <= source.y() + 1; y++) { + if (!solid(source.x(), y, source.z())) return false; + } + for (int[] d : CARDINALS) { + if (d[0] == dx && d[1] == dz) continue; + if (!solid(source.x() + d[0], source.y(), source.z() + d[1])) return false; + } + for (int y = source.y(); y >= source.y() - 8; y--) { + if (solid(source.x() + dx, y, source.z() + dz)) return false; + } + return true; + } + + /** Predict drainage through undecorated rock to select springs and inspection bounds. + * Descend through air first, then explore seven horizontal steps on a shelf. Later trees + * can divert the actual fluid: this prediction is not a bound on decorated-world physics. + * Integration tests trace actual water back to its source within the inspected chunk halo. */ + private Flow traceSpring(Position source, Position outlet) { + Map reach = new HashMap<>(); + Map parent = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + reach.put(source, 7); + queue.add(new FlowNode(source, 7)); + Position lowest = source; + while (!queue.isEmpty()) { + FlowNode node = queue.removeFirst(); + Position at = node.position(); + if (node.reach() < reach.getOrDefault(at, -1)) continue; + if (at.y() < lowest.y()) lowest = at; + if (reach.size() > 24000) return null; + if (at.y() <= 0) continue; + Position below = new Position(at.x(), at.y() - 1, at.z()); + if (!flowSolid(below, source)) { + enqueueFlow(below, 7, at, queue, reach, parent); + continue; + } + if (node.reach() <= 0) continue; + for (int[] d : CARDINALS) { + Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]); + if (flowSolid(next, source)) continue; + enqueueFlow(next, node.reach() - 1, at, queue, reach, parent); + } + } + if (!reach.containsKey(outlet)) return null; + int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY; + int minZ = source.z(), maxZ = minZ; + for (Position position : reach.keySet()) { + minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x()); + minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y()); + minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z()); + } + List path = new ArrayList<>(); + for (Position at = lowest; at != null; at = parent.get(at)) path.add(at); + Collections.reverse(path); + return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ)); + } + + private boolean flowSolid(Position at, Position source) { + if (at.equals(source)) return false; + Cell cell = cells.get(key(at.x(), at.z())); + if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false; + return solid(at.x(), at.y(), at.z()); + } + + private static void enqueueFlow(Position next, int available, Position from, ArrayDeque queue, + Map reach, Map parent) { + if (available <= reach.getOrDefault(next, -1)) return; + if (!reach.containsKey(next)) parent.put(next, from); + reach.put(next, available); + queue.addLast(new FlowNode(next, available)); + } + + private boolean nearWater(int x, int z, int distance) { + for (Feature feature : features) { + Point point = feature.path().getFirst(); + if (Math.hypot(x - point.x(), z - point.z()) < distance) return true; + } + return false; + } + + private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; } + private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); } + + private double terrainNoise(int x, int z) { return field(x, z, 8, 31); } + + private double field(int x, int z, int scale, long salt) { + int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale); + double tx = smooth(Math.floorMod(x, scale) / (double) scale); + double tz = smooth(Math.floorMod(z, scale) / (double) scale); + double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx; + double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, gx + 1, gz + 1) * tx; + return a * (1 - tz) + b * tz; + } + } + + private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; } + private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } + private static Point point(long key) { return new Point((int) (key >> 32), (int) key); } + private static double smooth(double t) { return t * t * (3 - 2 * t); } + private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; } + private static long mix(long value) { + value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; + return value ^ (value >>> 31); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java new file mode 100644 index 0000000..f725e2b --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java @@ -0,0 +1,240 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.SanctuaryMod; +import java.util.*; +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.10 plans only regions touched by generation or a requested protection volume. */ +public final class PopulationHydrologyRuntime { + public record RegionPlan(IslandCapacity.Region region, PopulationHydrology.Plan water, PopulationLavaDeposit.Plan lava) {} + private static final Map> WORLDS = + Collections.synchronizedMap(new WeakHashMap<>()); + private PopulationHydrologyRuntime() {} + public static boolean enabled(NoiseBasedChunkGenerator generator) { return IslandCapacity.forGenerator(generator) != null; } + public static IslandCapacity capacity(NoiseBasedChunkGenerator generator) { + return Objects.requireNonNull(IslandCapacity.forGenerator(generator), "Population generation is not enabled"); + } + private static Map regions(RandomState state) { + synchronized (WORLDS) { return WORLDS.computeIfAbsent(state, ignored -> new LinkedHashMap<>()); } + } + public static int cachedRegionCount(RandomState state) { + Map regions; + synchronized (WORLDS) { regions = WORLDS.get(state); } + if (regions == null) return 0; + synchronized (regions) { return regions.size(); } + } + public static RegionPlan regionPlan(NoiseBasedChunkGenerator generator, RandomState state, IslandCapacity.Region region) { + var capacity = capacity(generator); + var regions = regions(state); + // One build per world at a time bounds the temporary sign-mask memory. No recursive + // neighbouring plan is needed: each accepted assembly owns its complete protected extent. + synchronized (regions) { + RegionPlan existing = regions.get(region); + if (existing != null) return existing; + long start = System.nanoTime(); + var density = generator.generatorSettings().value().noiseRouter().finalDensity(); + var memo = new SignMemo((x, y, z) -> { + long wx = (long) x + region.originX(), wz = (long) z + region.originZ(); + if (Math.hypot((double) wx, wz) >= capacity.terrainLimit()) return -1; + return state.sampleBlockValueUncached(density, (int) wx, y, (int) wz); + }); + long seed = PopulationIslandDensity.regionSeed(state.seed(), region); + var water = PopulationHydrology.create(seed, memo).admitted(); + var lava = PopulationLavaDeposit.create(seed, memo, water).admitted(); + var result = new RegionPlan(region, water.translated(region.originX(), region.originZ()), + lava.translated(region.originX(), region.originZ())); + regions.put(region, result); + SanctuaryMod.LOGGER.info("Population {} seed {} region {},{}: {} features, {} water columns, {} lava sources, {} density probes, {} ms", + capacity.players(), state.seed(), region.x(), region.z(), water.features().size(), water.cells().size(), + lava.lavaCells().size(), memo.samples, (System.nanoTime() - start) / 1_000_000); + return result; + } + } + public static RegionPlan regionPlan(ServerLevel level, IslandCapacity.Region region) { + return regionPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), level.getChunkSource().randomState(), region); + } + public static List plansIntersecting(NoiseBasedChunkGenerator generator, RandomState state, + int minX, int minZ, int maxX, int maxZ) { + var capacity = capacity(generator); + var first = capacity.regionAt(minX, minZ); var last = capacity.regionAt(maxX, maxZ); + List plans = new ArrayList<>(); + for (int x = first.x(); x <= last.x(); x++) for (int z = first.z(); z <= last.z(); z++) { + var region = new IslandCapacity.Region(x, z); + double nearestX = Math.max(0, Math.max(region.minX(), -region.maxX())); + double nearestZ = Math.max(0, Math.max(region.minZ(), -region.maxZ())); + if (Math.hypot(nearestX, nearestZ) < capacity.terrainLimit()) plans.add(regionPlan(generator, state, region)); + } + return List.copyOf(plans); + } + public static List cellsInChunk(NoiseBasedChunkGenerator generator, RandomState state, int cx, int cz) { + if (!capacity(generator).areaExists(cx, cz)) return List.of(); + return regionPlan(generator, state, capacity(generator).regionAt(cx * 16, cz * 16)).water().cellsInChunk(cx, cz); + } + public static List cellsAt(NoiseBasedChunkGenerator generator, RandomState state, int x, int z) { + if (Math.hypot((double) x, z) >= capacity(generator).terrainLimit()) return List.of(); + return regionPlan(generator, state, capacity(generator).regionAt(x, z)).water().cellsAt(x, z); + } + public static PopulationHydrology.Cell cellAt(NoiseBasedChunkGenerator generator, RandomState state, int x, int y, int z) { + for (var cell : cellsAt(generator, state, x, z)) if (y >= cell.bedY() - cell.sedimentDepth() - 1 + && y <= Math.max(cell.waterY(), cell.carveTop())) return cell; + return null; + } + /** Only the sign is observed by all local planners. The two bit masks are populated lazily + * and released after one region; there is no dense whole-island height-volume scan. */ + private static final class SignMemo implements PopulationHydrology.Sampler { + final PopulationHydrology.Sampler original; + final Map columns = new HashMap<>(); + long samples; + SignMemo(PopulationHydrology.Sampler original) { this.original = original; } + @Override public float sample(int x, int y, int z) { + if (y < 0 || y >= 384) return -1; + long key = ((long) x << 32) | (z & 0xffffffffL); + long[] bits = columns.computeIfAbsent(key, ignored -> new long[12]); + int word = y >>> 6; long mask = 1L << (y & 63); + if ((bits[word] & mask) == 0) { + samples++; + if (original.sample(x, y, z) > 0) bits[word + 6] |= mask; + bits[word] |= mask; + } + return (bits[word + 6] & mask) != 0 ? 1 : -1; + } + } + + public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) { + if (!enabled(generator)) return; + var chunkPos = chunk.getPos(); + if (!capacity(generator).areaExists(chunkPos.x(), chunkPos.z())) return; + var region = regionPlan(generator, randomState, capacity(generator).regionAt(chunkPos.getMinBlockX(), chunkPos.getMinBlockZ())); + var plan = region.water(); + var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z()); + var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z()); + var outlets = new java.util.ArrayList<>(plan.springs().stream().map(PopulationHydrology.Spring::outlet) + .filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList()); + for (var spill : plan.spills()) { + var outlet = spill.outlet(); + if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()) outlets.add(outlet); + } + var lava = region.lava(); + for (var fall : lava.falls()) { + var outlet = fall.outlet(); + if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()) + outlets.add(new PopulationHydrology.Position(outlet.x(), outlet.y(), outlet.z())); + } + var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z()); + var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z()); + if (cells.isEmpty() && springs.isEmpty() && outlets.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + // Validate all replacements and two intact support layers before modifying this chunk. + for (var cell : cells) { + for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) { + requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed()); + } + } + for (var spring : springs) { + var source = spring.source(); + requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed()); + } + for (var support : lavaSupports) { + requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed()); + if (chunk.getBlockState(pos).ignitedByLava()) { + throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos); + } + } + for (var cell : lavaCells) { + requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed()); + } + for (var cell : cells) { + for (int depth = 0; depth < cell.sedimentDepth(); depth++) { + pos.set(cell.x(), cell.bedY() - depth, cell.z()); + BlockState material = PopulationMaterials.sedimentBlock(cell, randomState.seed(), depth); + chunk.setBlockState(pos, material, 0); + } + for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) { + pos.set(cell.x(), y, cell.z()); + chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY() + ? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0); + if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos); + } + } + for (var spring : springs) { + var source = spring.source(); + pos.set(source.x(), source.y(), source.z()); + chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0); + // Only the source is generated. Vanilla fluid ticks create the descending waterfall. + chunk.markPosForPostProcessing(pos); + } + for (var cell : lavaCells) { + pos.set(cell.x(), cell.y(), cell.z()); + chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0); + if (cell.lava()) chunk.markPosForPostProcessing(pos); + } + // This existing vanilla queue is consumed after neighbouring chunks finish decoration. + // Mark the outlet in its own chunk, including when its source belongs to a neighbour. + for (var outlet : outlets) { + pos.set(outlet.x(), outlet.y(), outlet.z()); + chunk.markPosForPostProcessing(pos); + } + Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG, + Heightmap.Types.OCEAN_FLOOR_WG)); + } + + /** Reserve water, sediment and hot-rock volumes before placing vegetation. */ + public static boolean protects(NoiseBasedChunkGenerator generator, RandomState randomState, BlockPos base, + int radius, int height, int rootDepth) { + for (RegionPlan region : plansIntersecting(generator, randomState, base.getX() - radius, base.getZ() - radius, + base.getX() + radius, base.getZ() + radius)) { + if (protects(region, base, radius, height, rootDepth)) return true; + } + return false; + } + + private static boolean protects(RegionPlan region, BlockPos base, int radius, int height, int rootDepth) { + var water = region.water(); + var lava = region.lava(); + int low = base.getY() - rootDepth, high = base.getY() + height; + for (int x = base.getX() - radius; x <= base.getX() + radius; x++) { + for (int z = base.getZ() - radius; z <= base.getZ() + radius; z++) { + for (var cell : water.cellsAt(x, z)) + if (low <= cell.carveTop() && high >= cell.bedY() - cell.sedimentDepth() - 1) return true; + } + } + for (var spring : water.springs()) { + var bounds = spring.flowBounds(); + if (base.getX() + radius >= bounds.minX() - 2 && base.getX() - radius <= bounds.maxX() + 2 + && base.getZ() + radius >= bounds.minZ() - 2 && base.getZ() - radius <= bounds.maxZ() + 2 + && low <= bounds.maxY() + 2 && high >= bounds.minY() - 2) return true; + var source = spring.source(); + if (Math.abs(base.getX() - source.x()) <= radius + 3 && Math.abs(base.getZ() - source.z()) <= radius + 3 + && low <= source.y() + 3 && high >= source.y() - 3) return true; + } + for (var bounds : lava.flowBounds()) { + if (base.getX() + radius >= bounds.minX() - 8 && base.getX() - radius <= bounds.maxX() + 8 + && base.getZ() + radius >= bounds.minZ() - 8 && base.getZ() - radius <= bounds.maxZ() + 8 + && low <= bounds.maxY() + 8 && high >= bounds.minY() - 8) return true; + } + for (var spill : water.spills()) { + var bounds = spill.flowBounds(); + if (base.getX() + radius >= bounds.minX() - 3 && base.getX() - radius <= bounds.maxX() + 3 + && base.getZ() + radius >= bounds.minZ() - 3 && base.getZ() - radius <= bounds.maxZ() + 3 + && low <= bounds.maxY() + 3 && high >= bounds.minY() - 3) return true; + } + for (var cell : lava.cells()) if (Math.abs(base.getX() - cell.x()) <= radius + 2 && Math.abs(base.getZ() - cell.z()) <= radius + 2 + && low <= cell.y() + 2 && high >= cell.y() - 2) return true; + for (var support : lava.supports()) if (Math.abs(base.getX() - support.x()) <= radius + 2 && Math.abs(base.getZ() - support.z()) <= radius + 2 + && low <= support.y() + 2 && high >= support.y() - 2) return true; + return false; + } + + private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) { + if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) { + throw new IllegalStateException("Population hydrology differs from terrain at " + pos + " for seed " + seed); + } + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandBiomeSource.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandBiomeSource.java new file mode 100644 index 0000000..9dafeed --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandBiomeSource.java @@ -0,0 +1,99 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import java.util.stream.Stream; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeResolver; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.biome.Climate; + +/** Seeded alpha.10 rift climate, separate from all saved earlier biome-source codecs. */ +public final class PopulationIslandBiomeSource extends BiomeSource { + public static final ResourceKey OAK_FOREST = key("population_oak_forest"); + public static final ResourceKey BIRCH_FOREST = key("population_birch_forest"); + public static final ResourceKey CLEARING = key("population_clearing"); + public static final ResourceKey DRY_WOODLAND = key("population_dry_woodland"); + public static final ResourceKey ROCKY_HEATH = key("population_rocky_heath"); + public static final ResourceKey DARK_GROVE = key("population_dark_grove"); + public static final ResourceKey BAMBOO_GROVE = key("population_bamboo_grove"); + public static final ResourceKey SULFUR_DEPTHS = key("population_sulfur_depths"); + public static final ResourceKey LUSH_CAVES = key("population_lush_caves"); + public static final ResourceKey DRIPSTONE_CAVES = key("population_dripstone_caves"); + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + Biome.CODEC.fieldOf("oak_forest").forGetter(source -> source.oakForest), + Biome.CODEC.fieldOf("birch_forest").forGetter(source -> source.birchForest), + Biome.CODEC.fieldOf("clearing").forGetter(source -> source.clearing), + Biome.CODEC.fieldOf("dry_woodland").forGetter(source -> source.dryWoodland), + Biome.CODEC.fieldOf("rocky_heath").forGetter(source -> source.rockyHeath), + Biome.CODEC.fieldOf("dark_grove").forGetter(source -> source.darkGrove), + Biome.CODEC.fieldOf("bamboo_grove").forGetter(source -> source.bambooGrove), + Biome.CODEC.fieldOf("sulfur_depths").forGetter(source -> source.sulfurDepths), + Biome.CODEC.fieldOf("lush_caves").forGetter(source -> source.lushCaves), + Biome.CODEC.fieldOf("dripstone_caves").forGetter(source -> source.dripstoneCaves) + ).apply(instance, PopulationIslandBiomeSource::new)); + + private final Holder oakForest; + private final Holder birchForest; + private final Holder clearing; + private final Holder dryWoodland; + private final Holder rockyHeath; + private final Holder darkGrove; + private final Holder bambooGrove; + private final Holder sulfurDepths; + private final Holder lushCaves; + private final Holder dripstoneCaves; + + public PopulationIslandBiomeSource(Holder oakForest, Holder birchForest, Holder clearing, + Holder dryWoodland, Holder rockyHeath, Holder darkGrove, + Holder bambooGrove, Holder sulfurDepths, Holder lushCaves, Holder dripstoneCaves) { + this.oakForest = oakForest; + this.birchForest = birchForest; + this.clearing = clearing; + this.dryWoodland = dryWoodland; + this.rockyHeath = rockyHeath; + this.darkGrove = darkGrove; + this.bambooGrove = bambooGrove; + this.sulfurDepths = sulfurDepths; + this.lushCaves = lushCaves; + this.dripstoneCaves = dripstoneCaves; + } + + @Override + protected MapCodec codec() { return CODEC; } + + @Override + protected Stream> collectPossibleBiomes() { + return Stream.of(oakForest, birchForest, clearing, dryWoodland, rockyHeath, darkGrove, bambooGrove, sulfurDepths, lushCaves, dripstoneCaves); + } + + @Override + public BiomeResolver createResolver(Climate.Sampler sampler) { + return (quartX, quartY, quartZ) -> { + int x = quartX * 4, y = quartY * 4, z = quartZ * 4; + float variation = sampler.temperature().sampleValue(x, 0, z); + float moisture = sampler.humidity().sampleValue(x, 0, z); + return switch (PopulationBiomePalette.select(y, variation, moisture)) { + case OAK_FOREST -> oakForest; + case BIRCH_FOREST -> birchForest; + case CLEARING -> clearing; + case DRY_WOODLAND -> dryWoodland; + case ROCKY_HEATH -> rockyHeath; + case DARK_GROVE -> darkGrove; + case BAMBOO_GROVE -> bambooGrove; + case SULFUR_DEPTHS -> sulfurDepths; + case LUSH_CAVES -> lushCaves; + case DRIPSTONE_CAVES -> dripstoneCaves; + }; + }; + } + + private static ResourceKey key(String path) { + return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path)); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java new file mode 100644 index 0000000..95d200d --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java @@ -0,0 +1,65 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.koka.sanctuary.SanctuaryMod; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.util.Interval; +import net.minecraft.world.level.levelgen.densityfunction.*; + +/** Capacity changes the envelope; all natural noise is still sampled at world coordinates. */ +public record PopulationIslandDensity(DensityFunction terrain, DensityFunction distortion, + DensityFunction sculpt, DensityFunction detail, DensityFunction underside, + DensityFunction riftWarp, DensityFunction riftDetail, IslandCapacity capacity) implements DensityFunction { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + DensityFunction.CODEC.fieldOf("terrain").forGetter(PopulationIslandDensity::terrain), + DensityFunction.CODEC.fieldOf("distortion").forGetter(PopulationIslandDensity::distortion), + DensityFunction.CODEC.fieldOf("sculpt").forGetter(PopulationIslandDensity::sculpt), + DensityFunction.CODEC.fieldOf("detail").forGetter(PopulationIslandDensity::detail), + DensityFunction.CODEC.fieldOf("underside").forGetter(PopulationIslandDensity::underside), + DensityFunction.CODEC.fieldOf("rift_warp").forGetter(PopulationIslandDensity::riftWarp), + DensityFunction.CODEC.fieldOf("rift_detail").forGetter(PopulationIslandDensity::riftDetail), + IslandCapacity.CODEC.optionalFieldOf("players", IslandCapacity.TWENTY).forGetter(PopulationIslandDensity::capacity) + ).apply(instance, PopulationIslandDensity::new)); + + @Override public DensitySampler compileSampler(CompileContext context) { + var raw = terrain.compileSampler(context); var edge = distortion.compileSampler(context); + var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context); + var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context); + var fractureDetail = riftDetail.compileSampler(context); + long seed = context.createRandom(SanctuaryMod.id("population_rifts_alpha10")).nextLong(); + var layouts = new ConcurrentHashMap(); + return new DensitySampler() { + @Override public float sampleValue(SamplerContext sample, int x, int y, int z) { + if (Math.hypot((double) x, z) >= capacity.terrainLimit() || y <= 0 || y >= 384) return -1; + int localY = y - 64; + float natural = PopulationIslandShape.raisedDensity(x, y, z, raw.sampleValue(sample, x, localY, z), + edge.sampleValue(sample, x, 0, z), coarse.sampleValue(sample, x, localY, z), + fine.sampleValue(sample, x, localY, z), bottom.sampleValue(sample, x, localY, z), 64, 384, capacity); + if (natural <= 0) return natural; + var region = capacity.regionAt(x, z); + var layout = layouts.computeIfAbsent(region, r -> RiftShape.layout(regionSeed(seed, r))); + int rx = x - region.originX(), rz = z - region.originZ(); + if (!RiftShape.mayAffect(layout, rx, rz)) return natural; + return RiftShape.density(layout, rx, y, rz, natural, + warp.sampleValue(sample, x, y, z), fractureDetail.sampleValue(sample, x, y, z)); + } + @Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) { + DensitySampler.sampleVolumeNaive(sample, buffer, volume, this); + } + }; + } + public static long regionSeed(long seed, IslandCapacity.Region region) { + long value = seed ^ ((long) region.x() << 32) ^ (region.z() & 0xffffffffL) ^ 0xA10CA7E1L; + value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; + return value ^ (value >>> 31); + } + @Override public DensityFunction rewriteChildren(DfRewriteRule rule) { + return new PopulationIslandDensity(rule.rewrite(terrain), rule.rewrite(distortion), rule.rewrite(sculpt), + rule.rewrite(detail), rule.rewrite(underside), rule.rewrite(riftWarp), rule.rewrite(riftDetail), capacity); + } + @Override public Interval range() { return Interval.of(-1, 1); } + @Override public int domainAxes() { return ALL_AXES; } + @Override public MapCodec codec() { return CODEC; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandShape.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandShape.java new file mode 100644 index 0000000..27350b7 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandShape.java @@ -0,0 +1,66 @@ +package fr.koka.sanctuary.worldgen; + +/** Alpha.10 keeps local erosion scales while expanding only the outer envelope. */ +public final class PopulationIslandShape { + private PopulationIslandShape() {} + + /** Generation v3. The natural field and lateral sculpture are sampled in their original frame. */ + public static float raisedDensity(int x, int y, int z, float terrain, float distortion, + float sculpt, float detail, float underside, int offset, int height, IslandCapacity capacity) { + double distance = Math.hypot((double) x, (double) z); + if (distance >= capacity.terrainLimit() || y <= 0 || y >= height) return -1.0F; + double localY = (double) y - offset; + double erosion = rimErosion(distance, localY, distortion, sculpt, detail, capacity); + + // The former uniform lower gradient is absent from the new natural field. Let its seeded + // lobes continue downwards, then sculpt them over a broad, spatially varying transition. + // The extra margin below the translated island must not merely reveal the same flat cut. + double bottom = -24.0 + Math.clamp(underside, -1.0F, 1.0F) * 16.0 + + Math.clamp(sculpt, -1.0F, 1.0F) * 8.0 + Math.clamp(detail, -1.0F, 1.0F) * 4.0; + double lower = Math.clamp((bottom + 72.0 - localY) / 72.0, 0.0, 1.0); + erosion += 2.0 * lower * lower * lower * lower; + // Upper silhouette is exactly the old one translated by offset. Absolute-world fades are + // safety margins, outside the normal terrain, rather than the island's visible underside. + erosion += 2.0 * smoothstep((localY - 224.0) / 32.0); + erosion += 2.0 * smoothstep((24.0 - y) / 24.0); + erosion += 2.0 * smoothstep((y - (height - 24.0)) / 24.0); + return bounded(terrain, erosion); + } + + private static double rimErosion(double distance, double y, float distortion, float sculpt, float detail, IslandCapacity capacity) { + double coast = Math.clamp(distortion, -1.0F, 1.0F); + double coarse = Math.clamp(sculpt, -1.0F, 1.0F); + double fine = Math.clamp(detail, -1.0F, 1.0F); + + // Pass 1: a broad outline bends through the island's height with two truly 3D noises. + // Keep the central field untouched even when a deeply recessed rim approaches it. + double rimEnd = Math.min(capacity.terrainLimit(), capacity.radius() + coast * 16.0 + coarse * 28.0 + fine * 8.0); + double rim = Math.clamp((distance - (capacity.radius() - 128)) / (rimEnd - (capacity.radius() - 128)), 0.0, 1.0); + // Raising the density threshold gradually shrinks each natural lobe from all sides. Unlike + // min(terrain, distance-to-edge), this never shears every altitude at one vertical contour. + double erosion = 2.0 * rim * rim * rim * rim; + + // A saturated natural lobe has almost no density gradient through its middle. Merely + // raising its threshold can still leave a tall near-vertical wall. Retreat its upper and + // lower rim faces as well, with a curved profile that disappears entirely at the interior. + // This is an outer-rim cut, never an ellipsoid combined with (or added to) the natural field. + double vertical = (y - (104.0 + coarse * 20.0)) / 88.0; + erosion += 8.0 * rim * rim * vertical * vertical; + + // Passes 2 and 3: broad recesses and smaller surface erosion, fading into the untouched + // interior. Both subtract material; holes stay empty and detached fragments are never joined. + double sculptWeight = smoothstep(rim); + erosion += sculptWeight * (0.10 * (coarse + 1.0) * 0.5 + 0.04 * (fine + 1.0) * 0.5); + + return erosion; + } + + private static float bounded(float terrain, double erosion) { + return (float) Math.clamp(Math.clamp(terrain, -1.0F, 1.0F) - erosion, -1.0, 1.0); + } + + private static double smoothstep(double value) { + double t = Math.clamp(value, 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationLavaDeposit.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationLavaDeposit.java new file mode 100644 index 0000000..f416a0d --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationLavaDeposit.java @@ -0,0 +1,377 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** Alpha.10 lava: an optional covered pocket and up to two deep rock outlets. + * All sources replace natural rock; Minecraft alone produces the falling lava. */ +public final class PopulationLavaDeposit { + 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 PopulationLavaDeposit() {} + + public record Position(int x, int y, int z) {} + /** lava=false removes existing rock to open the two-block-high chamber or approach. */ + public record Cell(int x, int y, int z, boolean lava) {} + + public static final class Plan { + private final List cells; + private final List lavaCells; + private final List supports; + private final Optional access; + private final Map> chunks; + private final Map> supportChunks; + private final List falls; + private final Map> fallChunks; + private final List pocketCells; + private final Set pocketSupports; + + private Plan(List cells, Set supports, Position access) { + this(cells, supports, access, List.of(), cells, supports); + } + + private Plan(List cells, Set supports, Position access, List falls, + List pocketCells, Set pocketSupports) { + this.pocketCells = pocketCells.stream().sorted(CELL_ORDER).toList(); + this.pocketSupports = Set.copyOf(pocketSupports); + this.falls = List.copyOf(falls); + Map> groupedFalls = new HashMap<>(); + for (var fall : falls) groupedFalls.computeIfAbsent( + key(fall.source().x() >> 4, fall.source().z() >> 4), ignored -> new ArrayList<>()).add(fall); + groupedFalls.replaceAll((ignored, values) -> List.copyOf(values)); + fallChunks = Map.copyOf(groupedFalls); + this.cells = cells.stream().sorted(CELL_ORDER).toList(); + lavaCells = this.cells.stream().filter(Cell::lava).toList(); + this.supports = supports.stream().sorted(POSITION_ORDER).toList(); + this.access = Optional.ofNullable(access); + Map> byChunk = new HashMap<>(); + for (Cell cell : this.cells) byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), + ignored -> new ArrayList<>()).add(cell); + byChunk.replaceAll((ignored, values) -> List.copyOf(values)); + chunks = Map.copyOf(byChunk); + Map> bySupportChunk = new HashMap<>(); + for (Position support : this.supports) bySupportChunk.computeIfAbsent(key(support.x() >> 4, support.z() >> 4), + ignored -> new ArrayList<>()).add(support); + bySupportChunk.replaceAll((ignored, values) -> List.copyOf(values)); + supportChunks = Map.copyOf(bySupportChunk); + } + + public List cells() { return cells; } + public List lavaCells() { return lavaCells; } + public List supports() { return supports; } + public List falls() { return falls; } + public List fallsInChunk(int x, int z) { return fallChunks.getOrDefault(key(x, z), List.of()); } + public List flowSources() { return falls.stream().map(PopulationLavaFlows.Fall::source).toList(); } + public List flowBounds() { return falls.stream().map(PopulationLavaFlows.Fall::flowBounds).toList(); } + /** Player feet on a natural ledge, with three blocks of headroom below a roof or sky. */ + public Optional access() { return access; } + public List cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); } + public List supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); } + + /** Keep a complete pocket or complete falls, with a 32-block regional safety margin. */ + public Plan admitted() { + boolean keepPocket = pocketCells.stream().allMatch(c -> owned(c.x(), c.z())) + && pocketSupports.stream().allMatch(Plan::owned) && access.map(Plan::owned).orElse(true); + List keptFalls = falls.stream().filter(f -> owned(f.source()) && owned(f.outlet()) + && owned(f.flowBounds()) && f.supports().stream().allMatch(Plan::owned) + && f.flowPath().stream().allMatch(Plan::owned)).toList(); + return compose(keepPocket ? pocketCells : List.of(), keepPocket ? pocketSupports : Set.of(), + keepPocket ? access.orElse(null) : null, keptFalls); + } + + /** IDs stay region-seeded; every position, flow envelope and chunk index moves together. */ + public Plan translated(int dx, int dz) { + List movedPocket = pocketCells.stream().map(c -> new Cell(Math.addExact(c.x(), dx), c.y(), + Math.addExact(c.z(), dz), c.lava())).toList(); + Set movedSupports = pocketSupports.stream().map(p -> move(p, dx, dz)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + List movedFalls = falls.stream().map(f -> new PopulationLavaFlows.Fall(f.id(), + move(f.source(), dx, dz), move(f.outlet(), dx, dz), f.flowPath().stream().map(p -> move(p, dx, dz)).toList(), + move(f.flowBounds(), dx, dz), f.supports().stream().map(p -> move(p, dx, dz)).toList())).toList(); + return compose(movedPocket, movedSupports, access.map(p -> move(p, dx, dz)).orElse(null), movedFalls); + } + + private static Plan compose(List pocket, Set support, Position access, + List selectedFalls) { + List combined = new ArrayList<>(pocket); + Set combinedSupports = new HashSet<>(support); + for (var fall : selectedFalls) { + var source = fall.source(); + combined.add(new Cell(source.x(), source.y(), source.z(), true)); + combinedSupports.addAll(fall.supports()); + } + return new Plan(combined, combinedSupports, access, selectedFalls, pocket, support); + } + + private static boolean owned(int x, int z) { return PopulationOwnership.contains(x, z, 32); } + private static boolean owned(Position p) { return owned(p.x(), p.z()); } + private static boolean owned(PopulationHydrology.FlowBounds b) { + return PopulationOwnership.contains(b.minX(), b.minZ(), b.maxX(), b.maxZ(), 32); + } + private static Position move(Position p, int dx, int dz) { + return new Position(Math.addExact(p.x(), dx), p.y(), Math.addExact(p.z(), dz)); + } + private static PopulationHydrology.FlowBounds move(PopulationHydrology.FlowBounds b, int dx, int dz) { + return new PopulationHydrology.FlowBounds(Math.addExact(b.minX(), dx), b.minY(), Math.addExact(b.minZ(), dz), + Math.addExact(b.maxX(), dx), b.maxY(), Math.addExact(b.maxZ(), dz)); + } + } + + public static Plan create(long seed, PopulationHydrology.Sampler sampler, PopulationHydrology.Plan hydrology) { + Plan pocket = new Search(seed, sampler, hydrology).find(); + List falls = PopulationLavaFlows.create(seed, sampler, hydrology, pocket); + return Plan.compose(pocket.pocketCells, pocket.pocketSupports, pocket.access().orElse(null), falls); + } + + private record Candidate(int x, int z, int dx, int dz, double score) {} + private record LowerCandidate(int x, int y, int z, int dx, int dz, double score) {} + + private static final class Search { + private final long seed; + private final PopulationHydrology.Sampler sampler; + private final PopulationHydrology.Plan hydrology; + private final Map heights = new HashMap<>(); + + Search(long seed, PopulationHydrology.Sampler sampler, PopulationHydrology.Plan hydrology) { + this.seed = seed; + this.sampler = sampler; + this.hydrology = hydrology; + } + + Plan find() { + Plan lower = findLowerPocket(); + if (lower != null) return lower; + List candidates = new ArrayList<>(); + for (int x = -184; x <= 184; x += 8) for (int z = -184; z <= 184; z += 8) { + if (!inside(x, z, RADIUS - 8)) continue; + int ledge = top(x, z); + if (ledge < 64 || ledge > 300) continue; + for (int[] direction : DIRECTIONS) { + int cx = x - direction[0] * ENTRY_DISTANCE, cz = z - direction[1] * ENTRY_DISTANCE; + if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) continue; + int rise = top(cx, cz) - ledge; + if (rise < 12) continue; + candidates.add(new Candidate(x, z, direction[0], direction[1], + random(seed, cx, cz) + Math.min(rise, 40) * 0.015)); + } + } + candidates.sort(Comparator.comparingDouble(Candidate::score).reversed() + .thenComparingInt(Candidate::x).thenComparingInt(Candidate::z) + .thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz)); + int attempts = 0; + for (Candidate candidate : candidates) { + if (attempts++ >= 192) break; + // Refine the coarse edge, including a ledge lying between two eight-block samples. + for (int along = 0; along < 8; along++) for (int tangent : new int[]{0, -3, 3}) { + int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent; + int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent; + Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz()); + if (plan != null) return plan; + } + } + return new Plan(List.of(), Set.of(), null); + } + + /** Prefer a real lower ledge below an overhang. The chamber uses exactly the same rock + * shell as the outdoor fallback; only the entrance may now have a natural ceiling. */ + private Plan findLowerPocket() { + List candidates = new ArrayList<>(); + for (int x = -176; x <= 176; x += 16) for (int z = -176; z <= 176; z += 16) { + if (!inside(x, z, RADIUS - 8)) continue; + for (int coarseY = 64; coarseY <= 160; coarseY += 8) { + if (!solid(x, coarseY, z) || solid(x, coarseY + 8, z)) continue; + int level = coarseY; + for (int y = coarseY + 7; y > coarseY; y--) { + if (solid(x, y, z)) { level = y; break; } + } + if (level > 160 || top(x, z) < level + 12) continue; + for (int[] direction : DIRECTIONS) { + int inwardX = x - direction[0] * 16, inwardZ = z - direction[1] * 16; + if (!inside(inwardX, inwardZ, RADIUS - 12) + || !solid(inwardX, level + 2, inwardZ) || !solid(inwardX, level + 10, inwardZ)) continue; + candidates.add(new LowerCandidate(x, level, z, direction[0], direction[1], + random(seed + level + 0x108E2L, x - direction[0], z - direction[1]) + + (160 - level) * .002)); + } + } + } + candidates.sort(Comparator.comparingDouble(LowerCandidate::score).reversed() + .thenComparingInt(LowerCandidate::x).thenComparingInt(LowerCandidate::z) + .thenComparingInt(LowerCandidate::y).thenComparingInt(LowerCandidate::dx).thenComparingInt(LowerCandidate::dz)); + int attempts = 0; + for (LowerCandidate candidate : candidates) { + if (attempts++ >= 128) break; + for (int along = 0; along < 16; along++) for (int tangent : new int[]{0, -3, 3}) { + int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent; + int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent; + for (int level = Math.min(160, candidate.y() + 5); level >= Math.max(64, candidate.y() - 5); level--) { + // Reject solid cave walls and missing floors before expensive shell probes. + if (!solid(x, level, z) || solid(x, level + 1, z)) continue; + Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz(), level, true); + if (plan != null) return plan; + } + } + } + return null; + } + + private Plan tryNiche(int entryX, int entryZ, int dx, int dz) { + return tryNiche(entryX, entryZ, dx, dz, top(entryX, entryZ), false); + } + + private Plan tryNiche(int entryX, int entryZ, int dx, int dz, int level, boolean underground) { + int cx = entryX - dx * ENTRY_DISTANCE, cz = entryZ - dz * ENTRY_DISTANCE; + if (!inside(cx, cz, RADIUS - 12) || level < 64 || level > (underground ? 160 : 300) + || top(cx, cz) < level + 12) return null; + // A lower entrance has genuine overhead island rock, but does not need a sky view. + if (underground && top(entryX, entryZ) < level + 12) return null; + int airLimit = underground ? level + 3 : 383; + for (int y = level + 1; y <= airLimit; y++) if (solid(entryX, y, entryZ)) return null; + for (int y = level - 2; y <= level; y++) if (!solid(entryX, y, entryZ)) return null; + if (underground ? !clearOfWater(cx, level, cz) : !clearOfWater(cx, cz)) return null; + + List cells = new ArrayList<>(); + Set changed = new HashSet<>(); + Set supports = new HashSet<>(); + for (int x = cx - 1; x <= cx + 1; x++) for (int z = cz - 1; z <= cz + 1; z++) { + for (int y = level - 1; y <= level + 2; y++) { + if (!solid(x, y, z)) return null; + add(cells, changed, x, y, z, y <= level); + } + } + // A dry lip separates the ledge from the pool. Only the air above it is opened. + for (int distance = 2; distance < ENTRY_DISTANCE; distance++) { + int x = cx + dx * distance, z = cz + dz * distance; + for (int y = level + 1; y <= level + 2; y++) { + if (solid(x, y, z)) add(cells, changed, x, y, z, false); + } + } + // Every carved column must lie under eight continuous natural rock blocks. This + // excludes soil-cap excavation at the entrance as well as exposed surface lava. + Map highest = new HashMap<>(); + for (Cell cell : cells) highest.merge(key(cell.x(), cell.z()), cell.y(), Math::max); + for (var column : highest.entrySet()) { + int x = (int) (column.getKey() >> 32), z = (int) (long) column.getKey(); + for (int y = column.getValue() + 1; y <= column.getValue() + 8; y++) { + if (!requireRock(supports, x, y, z)) return null; + } + } + // Three intact blocks under the pool, two-block-thick side walls, and a solid chamber + // surround prevent escape through an adjacent cave. No part of this shell is created. + for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 3; z <= cz + 3; z++) { + for (int y = level - 4; y <= level + 2; y++) { + if (changed.contains(new Position(x, y, z))) continue; + if (!requireRock(supports, x, y, z)) return null; + } + } + // The natural roof also covers a conservative envelope around local lava fire spread. + // Its thickness keeps later surface vegetation well above the lava chamber. + for (int x = cx - 4; x <= cx + 4; x++) for (int z = cz - 4; z <= cz + 4; z++) { + for (int y = level + 3; y <= level + 10; y++) { + if (!requireRock(supports, x, y, z)) return null; + } + } + for (int distance = 2; distance <= ENTRY_DISTANCE; distance++) { + int x = cx + dx * distance, z = cz + dz * distance; + for (int y = level - 2; y <= level; y++) { + if (!requireRock(supports, x, y, z)) return null; + } + } + supports.removeAll(changed); + return new Plan(cells, supports, new Position(entryX, level + 1, entryZ)); + } + + private boolean requireRock(Set supports, int x, int y, int z) { + if (!solid(x, y, z)) return false; + supports.add(new Position(x, y, z)); + return true; + } + + private boolean clearOfWater(int x, int z) { + // The extra ten blocks include both the entrance and the complete protected shell. + int clearance = WATER_CLEARANCE + 10; + for (var cell : hydrology.cells()) { + long dx = x - cell.x(), dz = z - cell.z(); + if (dx * dx + dz * dz < (long) clearance * clearance) return false; + } + for (var spring : hydrology.springs()) { + var bounds = spring.flowBounds(); + long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX()); + long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ()); + if (dx * dx + dz * dz < (long) clearance * clearance) return false; + } + for (var spill : hydrology.spills()) { + var bounds = spill.flowBounds(); + long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX()); + long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ()); + if (dx * dx + dz * dz < (long) clearance * clearance) return false; + } + return true; + } + + private boolean clearOfWater(int x, int level, int z) { + // This box covers chamber, dry approach, intact shell and roof. A lake far above is + // allowed; an actual waterfall crossing this level still excludes the entire pocket. + var pocket = new PopulationHydrology.FlowBounds(x - 10, level - 4, z - 10, x + 10, level + 10, z + 10); + for (var cell : hydrology.cells()) { + var ground = new PopulationHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1, + cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z()); + if (distanceSquared(pocket, ground) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spring : hydrology.springs()) { + if (distanceSquared(pocket, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spill : hydrology.spills()) { + if (distanceSquared(pocket, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + return true; + } + + private static long distanceSquared(PopulationHydrology.FlowBounds a, PopulationHydrology.FlowBounds b) { + long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX())); + long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY())); + long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ())); + return dx * dx + dy * dy + dz * dz; + } + + private int top(int x, int z) { + return heights.computeIfAbsent(key(x, z), ignored -> { + for (int y = 383; y >= 32; y -= 4) { + if (!solid(x, y, z)) continue; + for (int exact = Math.min(383, y + 3); exact > y; exact--) if (solid(x, exact, z)) return exact; + return y; + } + return -1; + }); + } + + private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; } + } + + private static void add(List cells, Set changed, int x, int y, int z, boolean lava) { + if (changed.add(new Position(x, y, z))) cells.add(new Cell(x, y, z, lava)); + } + + private static boolean inside(int x, int z, int radius) { return (long) x * x + (long) z * z < (long) radius * radius; } + private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } + private static double random(long seed, int x, int z) { + long value = seed ^ key(x, z) ^ 0x1A7A5EEDL; + value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; + return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationLavaFlows.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationLavaFlows.java new file mode 100644 index 0000000..8d36e7e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationLavaFlows.java @@ -0,0 +1,241 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.worldgen.PopulationLavaDeposit.Position; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Selects a few volcanic seeps in deep exposed rock. Sources replace rock; their outlets and + * entire falling columns are left empty for vanilla lava ticks. No retaining wall is generated. */ +public final class PopulationLavaFlows { + public static final int MIN_SOURCE_Y = 40; + public static final int MAX_SOURCE_Y = 160; + public static final int MAX_FALLS = 2; + public static final int HORIZONTAL_REACH = 3; + public static final int FIRE_CLEARANCE = 8; + public static final int WATER_CLEARANCE = 24; + private static final int RADIUS = 224; + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final Comparator POSITION_ORDER = Comparator.comparingInt(Position::x) + .thenComparingInt(Position::z).thenComparingInt(Position::y); + + private PopulationLavaFlows() {} + + /** flowBounds predicts undecorated rock drainage; it does not override Minecraft physics. + * supports contains only unchanged natural rock around the source, never the source itself. */ + public record Fall(long id, Position source, Position outlet, List flowPath, + PopulationHydrology.FlowBounds flowBounds, List supports) { + public Fall { flowPath = List.copyOf(flowPath); supports = List.copyOf(supports); } + } + + public static List create(long seed, PopulationHydrology.Sampler sampler, + PopulationHydrology.Plan water, PopulationLavaDeposit.Plan pocket) { + return new Search(seed, sampler, water, pocket).find(); + } + + private record Candidate(int x, int y, int z, int dx, int dz, double score) {} + private record Node(Position at, int reach) {} + private record Trace(List path, PopulationHydrology.FlowBounds bounds) {} + + private static final class Search { + private final long seed; + private final PopulationHydrology.Sampler sampler; + private final PopulationHydrology.Plan water; + private final PopulationLavaDeposit.Plan pocket; + private final List falls = new ArrayList<>(); + + Search(long seed, PopulationHydrology.Sampler sampler, PopulationHydrology.Plan water, PopulationLavaDeposit.Plan pocket) { + this.seed = seed; this.sampler = sampler; this.water = water; this.pocket = pocket; + } + + List find() { + List candidates = new ArrayList<>(); + // Coarse probes below the surface can see a lower overhang that a heightmap hides. + for (int x = -216; x <= 216; x += 8) for (int z = -216; z <= 216; z += 8) { + if ((long) x * x + (long) z * z >= (long) RADIUS * RADIUS) continue; + for (int y = MIN_SOURCE_Y; y <= MAX_SOURCE_Y; y += 8) { + if (!solid(x, y, z)) continue; + for (int[] d : CARDINALS) { + if (solid(x + d[0] * 4, y, z + d[1] * 4)) continue; + double score = random(seed + y, x + d[0], z + d[1]) + + (MAX_SOURCE_Y - y) * .002; + candidates.add(new Candidate(x, y, z, d[0], d[1], score)); + } + } + } + candidates.sort(Comparator.comparingDouble(Candidate::score).reversed() + .thenComparingInt(Candidate::x).thenComparingInt(Candidate::z) + .thenComparingInt(Candidate::y).thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz)); + int attempts = 0; + for (Candidate candidate : candidates) { + if (falls.size() >= MAX_FALLS || attempts++ >= 160) break; + boolean placed = false; + for (int tangent : new int[]{0, -2, 2}) { + for (int along = 0; along < 4; along++) { + int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent; + int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent; + for (int dy : new int[]{0, 2, -2, 4, -4}) { + Position source = new Position(x, candidate.y() + dy, z); + if (source.y() < MIN_SOURCE_Y || source.y() > MAX_SOURCE_Y || !separated(source)) continue; + List supports = niche(source, candidate.dx(), candidate.dz()); + if (supports == null) continue; + Position outlet = new Position(x + candidate.dx(), source.y(), z + candidate.dz()); + Trace flow = trace(source, outlet); + if (flow == null || flow.bounds().minY() > source.y() - 12 || !clearOfWater(flow.bounds())) continue; + long id = mix(seed ^ (0x1A7AFA11L + falls.size() * 0x9E3779B97F4A7C15L)); + falls.add(new Fall(id, source, outlet, flow.path(), flow.bounds(), supports)); + placed = true; + break; + } + if (placed) break; + } + if (placed) break; + } + } + return List.copyOf(falls); + } + + private boolean separated(Position source) { + PopulationHydrology.FlowBounds point = bounds(source); + if (!clearOfWater(point)) return false; + for (Fall fall : falls) { + if (distanceSquared(source, fall.source()) < 80L * 80 + || distanceSquared(point, fall.flowBounds()) < 40L * 40) return false; + } + for (var cell : pocket.cells()) { + if (distanceSquared(source, new Position(cell.x(), cell.y(), cell.z())) < 32L * 32) return false; + } + return true; + } + + private List niche(Position source, int dx, int dz) { + if (!solid(source)) return null; + Set supports = new HashSet<>(); + // The source sits in rock, with a short intact roof, floor and three back/side faces. + // The spill column needs no floor: its absence is precisely what makes a cascade. + for (int dy = -3; dy <= 8; dy++) { + if (dy == 0) continue; + Position at = new Position(source.x(), source.y() + dy, source.z()); + if (!solid(at)) return null; + supports.add(at); + } + for (int[] d : CARDINALS) { + if (d[0] == dx && d[1] == dz) continue; + for (int depth = 1; depth <= 2; depth++) { + Position at = new Position(source.x() + d[0] * depth, source.y(), source.z() + d[1] * depth); + if (!solid(at)) return null; + supports.add(at); + } + } + for (int dy = 0; dy >= -8; dy--) { + if (solid(source.x() + dx, source.y() + dy, source.z() + dz)) return null; + } + return supports.stream().sorted(POSITION_ORDER).toList(); + } + + private boolean clearOfWater(PopulationHydrology.FlowBounds bounds) { + for (var cell : water.cells()) { + var column = new PopulationHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1, + cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z()); + if (distanceSquared(bounds, column) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spring : water.springs()) { + if (distanceSquared(bounds, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + for (var spill : water.spills()) { + if (distanceSquared(bounds, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false; + } + return true; + } + + /** Descending lava resets its horizontal reach to three in this non-ultrawarm dimension. + * Explore all downhill branches through rock, so selection does not depend on chunk order. */ + private Trace trace(Position source, Position outlet) { + Map reach = new HashMap<>(); + Map parent = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + reach.put(source, HORIZONTAL_REACH); + queue.addLast(new Node(source, HORIZONTAL_REACH)); + Position lowest = source; + while (!queue.isEmpty()) { + Node node = queue.removeFirst(); + Position at = node.at(); + if (node.reach() < reach.getOrDefault(at, -1)) continue; + if (at.y() < lowest.y()) lowest = at; + if (reach.size() > 12000 || Math.abs(at.x() - source.x()) > 64 || Math.abs(at.z() - source.z()) > 64) return null; + if (at.y() <= 0) continue; + Position below = new Position(at.x(), at.y() - 1, at.z()); + if (!flowSolid(below, source)) { + enqueue(below, HORIZONTAL_REACH, at, reach, parent, queue); + continue; + } + if (node.reach() <= 0) continue; + for (int[] d : CARDINALS) { + Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]); + if (!flowSolid(next, source)) enqueue(next, node.reach() - 1, at, reach, parent, queue); + } + } + if (!reach.containsKey(outlet)) return null; + int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY, minZ = source.z(), maxZ = minZ; + for (Position at : reach.keySet()) { + minX = Math.min(minX, at.x()); maxX = Math.max(maxX, at.x()); + minY = Math.min(minY, at.y()); maxY = Math.max(maxY, at.y()); + minZ = Math.min(minZ, at.z()); maxZ = Math.max(maxZ, at.z()); + } + List path = new ArrayList<>(); + for (Position at = lowest; at != null; at = parent.get(at)) path.add(at); + Collections.reverse(path); + return new Trace(List.copyOf(path), new PopulationHydrology.FlowBounds(minX, minY, minZ, maxX, maxY, maxZ)); + } + + private boolean flowSolid(Position at, Position source) { + if (at.equals(source)) return false; + var cell = water.cellAt(at.x(), at.y(), at.z()); + if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false; + return solid(at); + } + + private boolean solid(Position at) { return solid(at.x(), at.y(), at.z()); } + private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; } + } + + private static void enqueue(Position next, int available, Position from, Map reach, + Map parent, ArrayDeque queue) { + if (available <= reach.getOrDefault(next, -1)) return; + if (!reach.containsKey(next)) parent.put(next, from); + reach.put(next, available); + queue.addLast(new Node(next, available)); + } + + private static PopulationHydrology.FlowBounds bounds(Position at) { + return new PopulationHydrology.FlowBounds(at.x(), at.y(), at.z(), at.x(), at.y(), at.z()); + } + + private static long distanceSquared(Position a, Position b) { + long dx = a.x() - b.x(), dy = a.y() - b.y(), dz = a.z() - b.z(); + return dx * dx + dy * dy + dz * dz; + } + + private static long distanceSquared(PopulationHydrology.FlowBounds a, PopulationHydrology.FlowBounds b) { + long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX())); + long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY())); + long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ())); + return dx * dx + dy * dy + dz * dz; + } + + private static double random(long seed, int x, int z) { + return (mix(seed ^ ((long) x << 32) ^ (z & 0xffffffffL)) >>> 11) * 0x1.0p-53; + } + + private static long mix(long value) { + value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; + return value ^ (value >>> 31); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationMaterials.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationMaterials.java new file mode 100644 index 0000000..9409e5d --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationMaterials.java @@ -0,0 +1,44 @@ +package fr.koka.sanctuary.worldgen; + +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +/** Alpha.10 shore deposits: broad continuous patches through the existing sediment thickness. */ +public final class PopulationMaterials { + private PopulationMaterials() {} + + public static BlockState sedimentBlock(PopulationHydrology.Cell cell, long seed, int depth) { + if (cell.material() == PopulationHydrology.ShoreMaterial.GRASS) { + return (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState(); + } + if (cell.material() == PopulationHydrology.ShoreMaterial.CLAY) return Blocks.CLAY.defaultBlockState(); + double patch = patch(seed, cell.x(), cell.z(), 32); + Block material; + if (cell.hasWater() && patch < 0.24) material = Blocks.CLAY; + else if (patch < 0.53) material = Blocks.SAND; + else if (patch < 0.68) material = Blocks.GRAVEL; + else { + double rock = patch(seed ^ 0x4c6179657273L, cell.x(), cell.z(), 40); + material = rock < 0.28 ? Blocks.GRANITE : rock < 0.45 ? Blocks.DIORITE + : rock < 0.65 ? Blocks.ANDESITE : Blocks.STONE; + } + return material.defaultBlockState(); + } + + private static double patch(long seed, int x, int z, int scale) { + int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale); + double tx = smooth(Math.floorMod(x, scale) / (double) scale); + double tz = smooth(Math.floorMod(z, scale) / (double) scale); + double near = lerp(value(seed, gx, gz), value(seed, gx + 1, gz), tx); + double far = lerp(value(seed, gx, gz + 1), value(seed, gx + 1, gz + 1), tx); + return lerp(near, far, tz); + } + + private static double value(long seed, int x, int z) { + return (WoodlandGroveGeometry.seed(seed, x, 0, z, 0x53484f5245L) >>> 11) * 0x1.0p-53; + } + + private static double smooth(double t) { return t * t * (3 - 2 * t); } + private static double lerp(double a, double b, double t) { return a + (b - a) * t; } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationOwnership.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationOwnership.java new file mode 100644 index 0000000..601bc84 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationOwnership.java @@ -0,0 +1,15 @@ +package fr.koka.sanctuary.worldgen; + +/** Local ownership of one 512-block planning region, with unused space at each edge. */ +final class PopulationOwnership { + private PopulationOwnership() {} + + static boolean contains(int x, int z, int margin) { + return x >= -256 + margin && x <= 255 - margin + && z >= -256 + margin && z <= 255 - margin; + } + + static boolean contains(int minX, int minZ, int maxX, int maxZ, int margin) { + return contains(minX, minZ, margin) && contains(maxX, maxZ, margin); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationShoreSugarCaneFeature.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationShoreSugarCaneFeature.java new file mode 100644 index 0000000..285b30f --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationShoreSugarCaneFeature.java @@ -0,0 +1,42 @@ +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 PopulationShoreSugarCaneFeature implements Feature { + public static final MapCodec CODEC = MapCodec.unit(PopulationShoreSugarCaneFeature::new); + + @Override + public MapCodec codec() { + return CODEC; + } + + @Override + public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) { + if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false; + var state = level.getLevel().getChunkSource().randomState(); + var cells = PopulationHydrologyRuntime.cellsInChunk(noise, state, origin.getX() >> 4, origin.getZ() >> 4); + var cane = Blocks.SUGAR_CANE.defaultBlockState(); + boolean placed = false; + for (var cell : cells) { + 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/PopulationSpringOutlets.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationSpringOutlets.java new file mode 100644 index 0000000..b2952d5 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationSpringOutlets.java @@ -0,0 +1,111 @@ +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.10 outlet finishing, using vanilla's persisted and consumed generation queue. */ +public final class PopulationSpringOutlets { + 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 PopulationSpringOutlets() {} + + /** 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) + || !PopulationHydrologyRuntime.enabled(noise)) return Pending.EMPTY; + if (!PopulationHydrologyRuntime.capacity(noise).areaExists(chunk.getPos().x(), chunk.getPos().z())) 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 regional = PopulationHydrologyRuntime.regionPlan(noise, level.getChunkSource().randomState(), + PopulationHydrologyRuntime.capacity(noise).regionAt(chunk.getPos().getMinBlockX(), chunk.getPos().getMinBlockZ())); + var plan = regional.water(); + List sources = new ArrayList<>(), outlets = new ArrayList<>(); + for (var spring : plan.springs()) { + if (markedHere(chunk, spring.source())) sources.add(spring.source()); + if (markedHere(chunk, spring.outlet())) outlets.add(spring.outlet()); + } + for (var spill : plan.spills()) { + if (markedHere(chunk, spill.source())) sources.add(spill.source()); + if (markedHere(chunk, spill.outlet())) outlets.add(spill.outlet()); + } + for (var fall : regional.lava().falls()) { + var source = new PopulationHydrology.Position(fall.source().x(), fall.source().y(), fall.source().z()); + var outlet = new PopulationHydrology.Position(fall.outlet().x(), fall.outlet().y(), fall.outlet().z()); + if (markedHere(chunk, source)) sources.add(source); + if (markedHere(chunk, outlet)) outlets.add(outlet); + } + return new Pending(sources, outlets); + } + + /** All neighbouring FEATURES have finished before a chunk reaches this stage. */ + public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) { + if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise) + || !PopulationHydrologyRuntime.enabled(noise)) return; + var random = level.getChunkSource().randomState(); + var density = noise.generatorSettings().value().noiseRouter().finalDensity(); + for (var outlet : pending.outlets()) { + if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet); + var modified = PopulationHydrologyRuntime.cellAt(noise, random, outlet.x(), outlet.y(), outlet.z()); + boolean carved = modified != null && outlet.y() > modified.waterY() && outlet.y() > modified.bedY() + && outlet.y() <= modified.carveTop(); + if (!carved && random.sampleBlockValueUncached(density, outlet.x(), outlet.y(), outlet.z()) > 0) { + throw new IllegalStateException("A spring outlet must already be natural air: " + outlet); + } + BlockPos pos = blockPos(outlet); + BlockState current = chunk.getBlockState(pos); + if (removableDecoration(current)) { + // Only this declared air cell is reopened. Neighbour notification wakes the + // adjacent source even if its own chunk was post-processed first. + level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + } + for (var source : pending.sources()) { + if (!owns(chunk, source)) throw new IllegalArgumentException("A source belongs to another chunk: " + source); + BlockPos pos = blockPos(source); + BlockState current = chunk.getBlockState(pos); + if (current.getFluidState().isSource()) { + if (current.is(Blocks.WATER)) level.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level)); + else if (current.is(Blocks.LAVA)) level.scheduleTick(pos, Fluids.LAVA, Fluids.LAVA.getTickDelay(level)); + } + } + } + + private static boolean markedHere(LevelChunk chunk, PopulationHydrology.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, PopulationHydrology.Position position) { + return (position.x() >> 4) == chunk.getPos().x() && (position.z() >> 4) == chunk.getPos().z(); + } + + private static BlockPos blockPos(PopulationHydrology.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/PopulationTerraces.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationTerraces.java new file mode 100644 index 0000000..fc6c951 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationTerraces.java @@ -0,0 +1,435 @@ +package fr.koka.sanctuary.worldgen; + +import fr.koka.sanctuary.worldgen.PopulationHydrology.*; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.function.Predicate; + +/** Small connected water terraces inside existing cave ledges. Each retained pool is excavated + * in rock; one declared edge may overflow. No dam, shelf, cave fill or fluid column is generated. */ +public final class PopulationTerraces { + public static final int MIN_Y = 56, MAX_Y = 208; + public static final int MAX_GROUPS = 2, MAX_DEPTH = 3, MAX_CARVE = 5; + private static final int RADIUS = 216, SEDIMENT = 3; + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + + private PopulationTerraces() {} + + public record Result(List cells, List features, List terraces, + List spills, Set predictedFlow) { + public static final Result EMPTY = new Result(List.of(), List.of(), List.of(), List.of(), Set.of()); + public Result { + cells = List.copyOf(cells); features = List.copyOf(features); terraces = List.copyOf(terraces); + spills = List.copyOf(spills); predictedFlow = Set.copyOf(predictedFlow); + } + } + + public static Result create(long seed, Sampler sampler, PopulationHydrology.Plan surface) { + return new Search(seed, sampler, surface).build(); + } + + private record Candidate(int x, int y, int z, int dx, int dz, double score) {} + private record Floor(int top, int bottom, int ceiling) {} + private record Node(int x, int z, double cost) {} + private record FlowNode(Position at, int reach) {} + private record Flow(Set volume, List path, FlowBounds bounds) {} + private record Pool(Map cells, Position source, Position outlet) {} + private static final Floor NO_FLOOR = new Floor(-1, -1, -1); + + private static final class Search { + private final long seed; + private final Sampler sampler; + private final PopulationHydrology.Plan surface; + private final Map floors = new HashMap<>(); + private final List acceptedCells = new ArrayList<>(); + private final List features = new ArrayList<>(); + private final List terraces = new ArrayList<>(); + private final List spills = new ArrayList<>(); + private final Set predicted = new HashSet<>(); + + Search(long seed, Sampler sampler, PopulationHydrology.Plan surface) { + this.seed = seed; this.sampler = sampler; this.surface = surface; + } + + Result build() { + List candidates = new ArrayList<>(); + for (int x = -208; x <= 208; x += 8) for (int z = -208; z <= 208; z += 8) { + if (!inside(x, z)) continue; + for (int y = 64; y <= MAX_Y; y += 8) { + if (!solid(x, y, z) || solid(x, y + 8, z)) continue; + int level = y; + for (int exact = y + 7; exact > y; exact--) if (solid(x, exact, z)) { level = exact; break; } + if (level > MAX_Y || floor(x, z, level) == NO_FLOOR) continue; + for (int[] d : CARDINALS) if (!solid(x + d[0] * 4, level, z + d[1] * 4)) { + candidates.add(new Candidate(x, level, z, d[0], d[1], random(seed + level, x + d[0], z + d[1]))); + } + } + } + candidates.sort(Comparator.comparingDouble(Candidate::score).reversed() + .thenComparingInt(Candidate::x).thenComparingInt(Candidate::z).thenComparingInt(Candidate::y) + .thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz)); + int attempts = 0; + for (Candidate candidate : candidates) { + if (terraces.size() >= MAX_GROUPS || attempts++ >= 160) break; + boolean added = false; + for (int tangent : new int[]{0, -2, 2}) { + for (int along = 0; along < 4; along++) { + int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent; + int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent; + for (int dy : new int[]{0, -1, 1, -2, 2}) { + Position source = new Position(x, candidate.y() + dy, z); + Position outlet = new Position(x + candidate.dx(), source.y(), z + candidate.dz()); + if (!mouth(source, outlet) || !separated(source)) continue; + if (addGroup(source, outlet)) { added = true; break; } + } + if (added) break; + } + if (added) break; + } + } + return new Result(acceptedCells, features, terraces, spills, predicted); + } + + private boolean addGroup(Position source, Position outlet) { + long groupId = mix(seed ^ (0xA9CA7E2L + terraces.size() * 0x9E3779B97F4A7C15L)); + long upperId = mix(groupId + 1), lowerId = mix(groupId + 2); + Pool upper = grow(source, source.y(), source, outlet, upperId); + if (upper == null) return false; + + // The first impact on a slope is often a thin lip. Follow natural drainage across + // its ledges, then find a supported receiving pool at a genuinely lower level. + // The same bounded fluid trace is used before and after carving the receiving pool. + Flow drainage = trace(source, outlet, new ArrayList<>(upper.cells().values())); + if (drainage == null) return false; + List contacts = new ArrayList<>(); + for (Position at : drainage.volume()) { + int level = at.y() - 1; + if (level < MIN_Y || level > source.y() - 4 || !solid(at.x(), level, at.z())) continue; + Floor ground = floor(at.x(), at.z(), level); + if (ground != NO_FLOOR && ground.bottom() <= level - 7) + contacts.add(new Position(at.x(), level, at.z())); + } + contacts.sort(Comparator.comparingInt(Position::y).reversed() + .thenComparingInt(Position::x).thenComparingInt(Position::z)); + Pool lower = null; + Position landing = null; + int attempts = 0; + for (Position contact : contacts) { + if (attempts++ >= 48) break; + lower = grow(contact, contact.y(), null, null, lowerId); + if (lower != null) { landing = contact; break; } + } + if (lower == null) return false; + List groupCells = new ArrayList<>(upper.cells().values()); + groupCells.addAll(lower.cells().values()); + if (overlapping(groupCells)) return false; + Flow inlet = trace(source, outlet, groupCells); + if (inlet == null || !reaches(inlet, lower)) return false; + + // If the same lower ledge has another natural edge, connect it to an open terminal + // fall. Otherwise its second pool stays closed; the first inter-pool cascade remains. + Pool drained = terminalPool(landing, lowerId); + Flow terminal = null; + if (drained != null) { + List replacement = new ArrayList<>(upper.cells().values()); + replacement.addAll(drained.cells().values()); + if (!overlapping(replacement)) { + Flow in = trace(source, outlet, replacement); + Flow out = trace(drained.source(), drained.outlet(), replacement); + if (in != null && reaches(in, drained) && out != null + && out.bounds().minY() <= drained.source().y() - 12) { + lower = drained; inlet = in; terminal = out; groupCells = replacement; + } + } + } + acceptedCells.addAll(groupCells); + features.add(feature(upperId, upper, source)); + features.add(feature(lowerId, lower, landing)); + terraces.add(new Terrace(groupId, List.of(upperId, lowerId))); + spills.add(new Spill(mix(groupId + 3), upperId, lowerId, source, outlet, inlet.path(), inlet.bounds())); + predicted.addAll(inlet.volume()); + if (terminal != null) { + spills.add(new Spill(mix(groupId + 4), lowerId, -1, lower.source(), lower.outlet(), terminal.path(), terminal.bounds())); + predicted.addAll(terminal.volume()); + } + return true; + } + + private Pool terminalPool(Position center, long id) { + List candidates = new ArrayList<>(); + for (int dx = -12; dx <= 12; dx++) for (int dz = -12; dz <= 12; dz++) { + if (dx * dx + dz * dz > 144) continue; + candidates.add(new Position(center.x() + dx, center.y(), center.z() + dz)); + } + candidates.sort(Comparator.comparingDouble((Position p) -> Math.hypot(p.x() - center.x(), p.z() - center.z())) + .thenComparingInt(Position::x).thenComparingInt(Position::z)); + int tried = 0; + for (Position source : candidates) for (int[] d : CARDINALS) { + Position outlet = new Position(source.x() + d[0], source.y(), source.z() + d[1]); + if (!mouth(source, outlet)) continue; + if (tried++ >= 12) return null; + Pool pool = grow(center, center.y(), source, outlet, id); + if (pool != null) return pool; + } + return null; + } + + private Pool grow(Position center, int level, Position source, Position outlet, long id) { + PriorityQueue queue = new PriorityQueue<>(Comparator.comparingDouble(Node::cost) + .thenComparingInt(Node::x).thenComparingInt(Node::z)); + Map costs = new HashMap<>(); + Set wet = new HashSet<>(); + long origin = key(center.x(), center.z()); + queue.add(new Node(center.x(), center.z(), 0)); costs.put(origin, 0.0); + int target = 100 + (int) (random(seed + level, center.x(), center.z()) * 80); + while (!queue.isEmpty() && wet.size() < target) { + Node node = queue.remove(); long k = key(node.x(), node.z()); + if (wet.contains(k) || node.cost() > costs.getOrDefault(k, Double.POSITIVE_INFINITY) + || !poolColumn(node.x(), node.z(), level, source, outlet)) continue; + wet.add(k); + for (int[] d : NEIGHBORS) { + int x = node.x() + d[0], z = node.z() + d[1]; long next = key(x, z); + if (Math.hypot(x - center.x(), z - center.z()) > 16 || wet.contains(next)) continue; + double cost = node.cost() + Math.hypot(d[0], d[1]) * (1 + noise(x, z, 11) * 1.8); + if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue; + costs.put(next, cost); queue.add(new Node(x, z, cost)); + } + } + wet = connected(wet, origin); + if (wet.size() < 40 || source != null && !wet.contains(key(source.x(), source.z()))) return null; + Map cells = new HashMap<>(); + for (long k : wet) { + int x = (int) (k >> 32), z = (int) k; + boolean edge = false; + for (int[] d : CARDINALS) edge |= !wet.contains(key(x + d[0], z + d[1])); + int depth = edge ? 1 : 2 + (noise(x, z, 9) > .6 ? 1 : 0); + Floor floor = floor(x, z, level); + int bed = level - depth; + if (floor == NO_FLOOR || floor.top() - bed > MAX_CARVE || floor.bottom() > bed - SEDIMENT - 1) return null; + ShoreMaterial material = noise(x + 101, z - 87, 19) > .42 ? ShoreMaterial.CLAY : ShoreMaterial.STONE; + cells.put(k, new Cell(x, z, level, bed, floor.top(), material, id, SEDIMENT)); + } + for (Cell cell : cells.values()) for (int[] d : CARDINALS) { + int x = cell.x() + d[0], z = cell.z() + d[1]; + Cell next = cells.get(key(x, z)); + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) { + if (next != null && y > next.bedY()) continue; + if (source != null && cell.x() == source.x() && cell.z() == source.z() + && x == outlet.x() && z == outlet.z() && y == outlet.y()) continue; + if (!solid(x, y, z)) return null; + } + } + return new Pool(Map.copyOf(cells), source, outlet); + } + + private boolean poolColumn(int x, int z, int level, Position source, Position outlet) { + if (!inside(x, z)) return false; + Floor floor = floor(x, z, level); + if (floor == NO_FLOOR || floor.bottom() > level - 7 || !available(x, z, level - 8, floor.top())) return false; + for (int[] d : CARDINALS) { + if (source != null && x == source.x() && z == source.z() + && x + d[0] == outlet.x() && z + d[1] == outlet.z()) continue; + if (!solid(x + d[0], level, z + d[1])) return false; + } + return true; + } + + private Floor floor(int x, int z, int level) { + return floors.computeIfAbsent(new Position(x, level, z), ignored -> { + if (level < MIN_Y || level > MAX_Y) return NO_FLOOR; + int top = -1; + for (int y = level + 2; y >= level; y--) { + if (solid(x, y, z)) { top = y; break; } + } + if (top < 0) return NO_FLOOR; + for (int y = top + 1; y <= top + 4; y++) if (solid(x, y, z)) return NO_FLOOR; + int ceiling = top + 5; + while (ceiling < 384 && !solid(x, ceiling, z)) ceiling++; + if (ceiling >= 384) return NO_FLOOR; + int bottom = top; + while (bottom > top - 16 && solid(x, bottom - 1, z)) bottom--; + return new Floor(top, bottom, ceiling); + }); + } + + private boolean mouth(Position source, Position outlet) { + if (source.y() < MIN_Y || source.y() > MAX_Y || !solid(source.x(), source.y(), source.z())) return false; + // An adjacent natural step may receive the water before it descends again. Requiring + // a vertical shaft here would discard the gentle ledges that form cave terraces. + if (solid(outlet.x(), outlet.y(), outlet.z())) return false; + return floor(source.x(), source.z(), source.y()) != NO_FLOOR; + } + + private boolean available(int x, int z, int low, int high) { + for (Cell cell : surface.cellsAt(x, z)) if (low <= cell.carveTop() + 4 + && high >= cell.bedY() - cell.sedimentDepth() - 5) return false; + for (Cell cell : acceptedCells) if (cell.x() == x && cell.z() == z && low <= cell.carveTop() + 4 + && high >= cell.bedY() - cell.sedimentDepth() - 5) return false; + for (Spring spring : surface.springs()) if (inBounds(x, low, high, z, spring.flowBounds(), 6)) return false; + for (Spill spill : spills) if (inBounds(x, low, high, z, spill.flowBounds(), 6)) return false; + return true; + } + + private boolean separated(Position source) { + for (Spill spill : spills) if (Math.hypot(source.x() - spill.source().x(), source.z() - spill.source().z()) < 64) return false; + return available(source.x(), source.z(), source.y() - 8, source.y() + 4); + } + + private static boolean overlapping(List cells) { + Map> columns = new HashMap<>(); + for (Cell cell : cells) { + var existing = columns.computeIfAbsent(key(cell.x(), cell.z()), ignored -> new ArrayList<>()); + for (Cell old : existing) if (cell.bedY() - cell.sedimentDepth() - 1 <= old.carveTop() + && old.bedY() - old.sedimentDepth() - 1 <= cell.carveTop()) return true; + existing.add(cell); + } + return false; + } + + private Flow trace(Position source, Position outlet, List group) { + Map> changes = new HashMap<>(); + Set retained = new HashSet<>(); + for (Cell cell : group) { + changes.computeIfAbsent(key(cell.x(), cell.z()), ignored -> new ArrayList<>()).add(cell); + for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) retained.add(new Position(cell.x(), y, cell.z())); + } + Map solidCache = new HashMap<>(); + Predicate open = at -> !solidCache.computeIfAbsent(at, p -> flowSolid(p, changes)); + Predicate canEnter = at -> !retained.contains(at) && open.test(at); + Predicate hole = at -> open.test(new Position(at.x(), at.y() - 1, at.z())); + Map reach = new HashMap<>(); Map parent = new HashMap<>(); + ArrayDeque queue = new ArrayDeque<>(); + reach.put(source, 7); queue.addLast(new FlowNode(source, 7)); Position lowest = source; + while (!queue.isEmpty()) { + FlowNode node = queue.removeFirst(); Position at = node.at(); + if (node.reach() < reach.getOrDefault(at, -1)) continue; + if (at.y() < lowest.y()) lowest = at; + if (reach.size() > 16000 || Math.abs(at.x() - source.x()) > 64 || Math.abs(at.z() - source.z()) > 64) return null; + // Vanilla never replaces an existing source. Record the arriving contact, + // but do not manufacture a route through the separately retained basin. + if (retained.contains(at) && !at.equals(source)) continue; + if (at.y() <= 0) continue; + Position below = new Position(at.x(), at.y() - 1, at.z()); + if (open.test(below)) { enqueue(below, 7, at, reach, parent, queue); continue; } + if (node.reach() <= 0) continue; + for (Position next : downhillChoices(at, canEnter, hole)) + enqueue(next, node.reach() - 1, at, reach, parent, queue); + } + if (!reach.containsKey(outlet)) return null; + int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY, minZ = source.z(), maxZ = minZ; + for (Position at : reach.keySet()) { + minX = Math.min(minX, at.x()); maxX = Math.max(maxX, at.x()); minY = Math.min(minY, at.y()); maxY = Math.max(maxY, at.y()); + minZ = Math.min(minZ, at.z()); maxZ = Math.max(maxZ, at.z()); + } + List path = new ArrayList<>(); + for (Position at = lowest; at != null; at = parent.get(at)) path.add(at); + Collections.reverse(path); + return new Flow(Set.copyOf(reach.keySet()), List.copyOf(path), new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ)); + } + + private boolean flowSolid(Position at, Map> changes) { + for (Cell cell : changes.getOrDefault(key(at.x(), at.z()), List.of())) + if (at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false; + Cell surfaceCell = surface.cellAt(at.x(), at.y(), at.z()); + if (surfaceCell != null && at.y() > surfaceCell.bedY() && at.y() <= surfaceCell.carveTop()) return false; + return solid(at.x(), at.y(), at.z()); + } + + private static boolean reaches(Flow flow, Pool pool) { + return flow.volume().stream().anyMatch(at -> { + Cell cell = pool.cells().get(key(at.x(), at.z())); + return cell != null && at.y() > cell.bedY() && at.y() <= cell.waterY(); + }); + } + + private static Feature feature(long id, Pool pool, Position center) { + int minX = Integer.MAX_VALUE, minZ = minX, maxX = Integer.MIN_VALUE, maxZ = maxX; + for (Cell cell : pool.cells().values()) { + minX = Math.min(minX, cell.x()); maxX = Math.max(maxX, cell.x()); minZ = Math.min(minZ, cell.z()); maxZ = Math.max(maxZ, cell.z()); + } + return new Feature(id, Kind.TERRACE, center.y(), List.of(new Point(center.x(), center.z())), pool.cells().size(), + new Bounds(minX, minZ, maxX, maxZ)); + } + + private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; } + private double noise(int x, int z, int scale) { + int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale); + double tx = smooth(Math.floorMod(x, scale) / (double) scale), tz = smooth(Math.floorMod(z, scale) / (double) scale); + double a = random(seed, gx, gz) * (1 - tx) + random(seed, gx + 1, gz) * tx; + double b = random(seed, gx, gz + 1) * (1 - tx) + random(seed, gx + 1, gz + 1) * tx; + return a * (1 - tz) + b * tz; + } + } + + /** WaterFluid's bare-rock downhill choice: consider holes through four recursive + * steps, keep only the nearest directions (including ties), spread everywhere + * only when no direction finds a hole. Retained sources cannot be entered. */ + static List downhillChoices(Position at, Predicate canEnter, Predicate hole) { + List selected = new ArrayList<>(); + int best = 1000; + for (int[] d : CARDINALS) { + Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]); + if (!canEnter.test(next)) continue; + int cost = hole.test(next) ? 0 : slopeDistance(next, 1, -d[0], -d[1], canEnter, hole); + if (cost < best) selected.clear(); + if (cost <= best) { selected.add(next); best = cost; } + } + return selected; + } + + private static int slopeDistance(Position at, int distance, int backX, int backZ, + Predicate canEnter, Predicate hole) { + int best = 1000; + for (int[] d : CARDINALS) { + if (d[0] == backX && d[1] == backZ) continue; + Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]); + if (!canEnter.test(next)) continue; + if (hole.test(next)) return distance; + if (distance < 4) best = Math.min(best, + slopeDistance(next, distance + 1, -d[0], -d[1], canEnter, hole)); + } + return best; + } + + private static void enqueue(Position next, int amount, Position from, Map reach, + Map parent, ArrayDeque queue) { + if (amount <= reach.getOrDefault(next, -1)) return; + if (!reach.containsKey(next)) parent.put(next, from); + reach.put(next, amount); queue.addLast(new FlowNode(next, amount)); + } + private static Set connected(Set footprint, long origin) { + if (!footprint.contains(origin)) return Set.of(); + Set seen = new HashSet<>(); ArrayDeque queue = new ArrayDeque<>(); + seen.add(origin); queue.add(origin); + while (!queue.isEmpty()) { + long at = queue.removeFirst(); int x = (int) (at >> 32), z = (int) at; + for (int[] d : CARDINALS) { + long next = key(x + d[0], z + d[1]); + if (footprint.contains(next) && seen.add(next)) queue.addLast(next); + } + } + return seen; + } + private static boolean inBounds(int x, int low, int high, int z, FlowBounds b, int margin) { + return x >= b.minX() - margin && x <= b.maxX() + margin && z >= b.minZ() - margin && z <= b.maxZ() + margin + && low <= b.maxY() + margin && high >= b.minY() - margin; + } + private static boolean inside(int x, int z) { return (long) x * x + (long) z * z < (long) RADIUS * RADIUS; } + private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } + private static double smooth(double t) { return t * t * (3 - 2 * t); } + private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; } + private static long mix(long value) { + value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; + return value ^ (value >>> 31); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuarySpawn.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/SanctuarySpawn.java index 617241d..d3c9dce 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 @@ -36,6 +36,13 @@ public final class SanctuarySpawn { public static final ResourceKey CAVERN_SETTINGS = ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_cavern")); + public static final ResourceKey POPULATION_5_SETTINGS = + ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_5")); + public static final ResourceKey POPULATION_20_SETTINGS = + ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_20")); + public static final ResourceKey POPULATION_100_SETTINGS = + ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_100")); + private SanctuarySpawn() {} public static boolean usesSanctuaryGenerator(ServerLevel level) { @@ -47,7 +54,10 @@ public final class SanctuarySpawn { || generator.generatorSettings().is(LAYERED_SETTINGS) || generator.generatorSettings().is(WOODLAND_SETTINGS) || generator.generatorSettings().is(RIFT_SETTINGS) - || generator.generatorSettings().is(CAVERN_SETTINGS)); + || generator.generatorSettings().is(CAVERN_SETTINGS) + || generator.generatorSettings().is(POPULATION_5_SETTINGS) + || generator.generatorSettings().is(POPULATION_20_SETTINGS) + || generator.generatorSettings().is(POPULATION_100_SETTINGS)); } /** Called only during first creation; saved spawns and /setworldspawn survive subsequent loads. */ @@ -56,14 +66,16 @@ public final class SanctuarySpawn { return; } NoiseBasedChunkGenerator generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(); + IslandCapacity capacity = IslandCapacity.forGenerator(generator); + int terrainLimit = capacity == null ? IslandShape.TERRAIN_LIMIT : capacity.terrainLimit(); BlockPos fallback = null; // Search the finite island from its centre outwards. Sampling base terrain first avoids // generating/decorating full chunks for the holes that can now cross the origin. - for (int radius = 0; radius <= IslandShape.TERRAIN_LIMIT; radius += 4) { + for (int radius = 0; radius <= terrainLimit; radius += 4) { for (int x = -radius; x <= radius; x += 4) { for (int z = -radius; z <= radius; z += 4) { if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue; - if (Math.hypot((double) x, (double) z) >= IslandShape.TERRAIN_LIMIT) continue; + if (Math.hypot((double) x, (double) z) >= terrainLimit) continue; if (generator.getBaseHeight(x, z, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, level, level.getChunkSource().randomState()) <= generator.getMinY()) continue; // Level.getHeight returns minY for an unloaded chunk; request full terrain first. @@ -86,7 +98,7 @@ public final class SanctuarySpawn { return; } throw new IllegalStateException("Sanctuary found no natural safe ground within " - + IslandShape.TERRAIN_LIMIT + " blocks for seed " + level.getSeed() + + terrainLimit + " blocks for seed " + level.getSeed() + "; no artificial platform was created"); } 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 542b522..4eeda7c 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 @@ -1,5 +1,5 @@ { - "generator.sanctuary.sanctuary": "Sanctuary", + "generator.sanctuary.sanctuary": "Sanctuary (20 players)", "biome.sanctuary.starter_forest": "Sanctuary Forest", "biome.sanctuary.starter_forest_hydrology": "Sanctuary Forest and Shores", "biome.sanctuary.temperate_forest": "Sanctuary Temperate Forest", @@ -37,5 +37,17 @@ "biome.sanctuary.cavern_bamboo_grove": "Bamboo Grove", "biome.sanctuary.cavern_sulfur_depths": "Sulfur Depths", "biome.sanctuary.cavern_lush_caves": "Sanctuary Lush Caves", - "biome.sanctuary.cavern_dripstone_caves": "Sanctuary Dripstone Caves" + "biome.sanctuary.cavern_dripstone_caves": "Sanctuary Dripstone Caves", + "generator.sanctuary.sanctuary_5": "Sanctuary (5 players)", + "generator.sanctuary.sanctuary_100": "Sanctuary (100 players)", + "biome.sanctuary.population_oak_forest": "Sanctuary Oak Woodland", + "biome.sanctuary.population_birch_forest": "Sanctuary Birch Woodland", + "biome.sanctuary.population_clearing": "Flowered Clearing", + "biome.sanctuary.population_dry_woodland": "Dry Woodland", + "biome.sanctuary.population_rocky_heath": "Rocky Heath", + "biome.sanctuary.population_dark_grove": "Dark Undergrowth", + "biome.sanctuary.population_bamboo_grove": "Bamboo Grove", + "biome.sanctuary.population_sulfur_depths": "Sulfur Depths", + "biome.sanctuary.population_lush_caves": "Sanctuary Lush Caves", + "biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves" } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 0aae96b..0b19d8b 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 @@ -1,5 +1,5 @@ { - "generator.sanctuary.sanctuary": "Sanctuary", + "generator.sanctuary.sanctuary": "Sanctuary (20 joueurs)", "biome.sanctuary.starter_forest": "Forêt de Sanctuary", "biome.sanctuary.starter_forest_hydrology": "Forêt et rives de Sanctuary", "biome.sanctuary.temperate_forest": "Forêt tempérée de Sanctuary", @@ -37,5 +37,17 @@ "biome.sanctuary.cavern_bamboo_grove": "Bosquet de bambous", "biome.sanctuary.cavern_sulfur_depths": "Profondeurs soufrées", "biome.sanctuary.cavern_lush_caves": "Grottes luxuriantes de Sanctuary", - "biome.sanctuary.cavern_dripstone_caves": "Grottes de spéléothèmes de Sanctuary" + "biome.sanctuary.cavern_dripstone_caves": "Grottes de spéléothèmes de Sanctuary", + "generator.sanctuary.sanctuary_5": "Sanctuary (5 joueurs)", + "generator.sanctuary.sanctuary_100": "Sanctuary (100 joueurs)", + "biome.sanctuary.population_oak_forest": "Chênaie de Sanctuary", + "biome.sanctuary.population_birch_forest": "Bétulaie de Sanctuary", + "biome.sanctuary.population_clearing": "Clairière fleurie", + "biome.sanctuary.population_dry_woodland": "Bois sec", + "biome.sanctuary.population_rocky_heath": "Lande rocheuse", + "biome.sanctuary.population_dark_grove": "Sous-bois sombre", + "biome.sanctuary.population_bamboo_grove": "Bosquet de bambous", + "biome.sanctuary.population_sulfur_depths": "Profondeurs soufrées", + "biome.sanctuary.population_lush_caves": "Grottes luxuriantes de Sanctuary", + "biome.sanctuary.population_dripstone_caves": "Grottes de spéléothèmes de Sanctuary" } diff --git a/mods/sanctuary/src/main/resources/data/minecraft/tags/worldgen/world_preset/normal.json b/mods/sanctuary/src/main/resources/data/minecraft/tags/worldgen/world_preset/normal.json index ae679a4..e376b7f 100644 --- a/mods/sanctuary/src/main/resources/data/minecraft/tags/worldgen/world_preset/normal.json +++ b/mods/sanctuary/src/main/resources/data/minecraft/tags/worldgen/world_preset/normal.json @@ -1,6 +1,8 @@ { "replace": false, "values": [ - "sanctuary:sanctuary" + "sanctuary:sanctuary", + "sanctuary:sanctuary_5", + "sanctuary:sanctuary_100" ] } diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_bamboo_grove.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_bamboo_grove.json new file mode 100644 index 0000000..442a765 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_bamboo_grove.json @@ -0,0 +1,167 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.sparse_jungle" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:wolf", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 2 + }, + "weight": 8 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.95, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.8 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_birch_forest.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_birch_forest.json new file mode 100644 index 0000000..03ecf49 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_birch_forest.json @@ -0,0 +1,162 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.forest" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.7, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:forest_flowers", + "minecraft:wildflowers_birch_forest", + "minecraft:trees_birch", + "minecraft:patch_bush", + "minecraft:flower_default", + "minecraft:patch_grass_forest", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "minecraft:patch_pumpkin", + "minecraft:patch_sugar_cane", + "minecraft:patch_firefly_bush_near_water", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_clearing.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_clearing.json new file mode 100644 index 0000000..0e3a7b8 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_clearing.json @@ -0,0 +1,164 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.meadow" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:donkey", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 2, + "min_inclusive": 1 + }, + "weight": 1 + }, + { + "type": "minecraft:rabbit", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 2 + }, + "weight": 2 + }, + { + "type": "minecraft:sheep", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 2 + }, + "weight": 2 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.75, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:patch_tall_grass_2", + "minecraft:patch_grass_meadow", + "minecraft:flower_meadow", + "minecraft:trees_meadow", + "minecraft:wildflowers_meadow", + "minecraft:flower_flower_forest", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dark_grove.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dark_grove.json new file mode 100644 index 0000000..f642914 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dark_grove.json @@ -0,0 +1,153 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.forest" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.9, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dripstone_caves.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dripstone_caves.json new file mode 100644 index 0000000..d9dc4c1 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dripstone_caves.json @@ -0,0 +1,153 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.forest" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.55, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.5 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dry_woodland.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dry_woodland.json new file mode 100644 index 0000000..aa43f41 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_dry_woodland.json @@ -0,0 +1,163 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.forest" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + }, + { + "type": "minecraft:wolf", + "count": 4, + "weight": 5 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.4, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:forest_flowers", + "sanctuary:rift_sparse_trees", + "minecraft:patch_bush", + "minecraft:flower_default", + "minecraft:patch_grass_forest", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.75 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_lush_caves.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_lush_caves.json new file mode 100644 index 0000000..a01a398 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_lush_caves.json @@ -0,0 +1,153 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.forest" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.9, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.5 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_oak_forest.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_oak_forest.json new file mode 100644 index 0000000..0f14d0d --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_oak_forest.json @@ -0,0 +1,166 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.forest" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + }, + { + "type": "minecraft:wolf", + "count": 4, + "weight": 5 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.8, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "minecraft:forest_flowers", + "minecraft:trees_birch_and_oak_leaf_litter", + "minecraft:patch_bush", + "minecraft:flower_default", + "minecraft:patch_grass_forest", + "minecraft:brown_mushroom_normal", + "minecraft:red_mushroom_normal", + "minecraft:patch_pumpkin", + "minecraft:patch_sugar_cane", + "minecraft:patch_firefly_bush_near_water", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_rocky_heath.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_rocky_heath.json new file mode 100644 index 0000000..52da982 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_rocky_heath.json @@ -0,0 +1,158 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.meadow" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:donkey", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 2, + "min_inclusive": 1 + }, + "weight": 1 + }, + { + "type": "minecraft:rabbit", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 2 + }, + "weight": 2 + }, + { + "type": "minecraft:sheep", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 2 + }, + "weight": 2 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.65, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_sulfur_depths.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_sulfur_depths.json new file mode 100644 index 0000000..7a44a41 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/population_sulfur_depths.json @@ -0,0 +1,156 @@ +{ + "attributes": { + "minecraft:audio/background_music": { + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.overworld.forest" + } + }, + "minecraft:gameplay/natural_mob_spawns": { + "argument": { + "spawn_costs": {}, + "spawns_by_category": { + "ambient": [ + { + "type": "minecraft:bat", + "count": 8, + "weight": 10 + } + ], + "creature": [ + { + "type": "minecraft:sheep", + "count": 4, + "weight": 12 + }, + { + "type": "minecraft:pig", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:chicken", + "count": 4, + "weight": 10 + }, + { + "type": "minecraft:cow", + "count": 4, + "weight": 8 + }, + { + "type": "minecraft:wolf", + "count": 4, + "weight": 5 + } + ], + "monster": [ + { + "type": "minecraft:spider", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:zombie", + "count": 4, + "weight": 95 + }, + { + "type": "minecraft:zombie_villager", + "count": 1, + "weight": 5 + }, + { + "type": "minecraft:skeleton", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:creeper", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:slime", + "count": 4, + "weight": 100 + }, + { + "type": "minecraft:enderman", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 4, + "min_inclusive": 1 + }, + "weight": 10 + }, + { + "type": "minecraft:witch", + "count": 1, + "weight": 5 + } + ], + "underground_water_creature": [ + { + "type": "minecraft:glow_squid", + "count": { + "type": "minecraft:uniform", + "max_inclusive": 6, + "min_inclusive": 4 + }, + "weight": 10 + } + ] + } + }, + "modifier": "overlay" + }, + "minecraft:visual/sky_color": "#79a6ff" + }, + "carvers": [], + "downfall": 0.5, + "effects": { + "water_color": "#3f76e4", + "grass_color": "#88b96b", + "foliage_color": "#79aa61", + "dry_foliage_color": "#b9a766" + }, + "features": [ + [], + [], + [], + [], + [], + [], + [ + "sanctuary:rift_ore_coal", + "sanctuary:rift_ore_iron", + "sanctuary:rift_ore_copper", + "sanctuary:rift_ore_gold", + "sanctuary:rift_ore_redstone", + "sanctuary:rift_ore_lapis", + "sanctuary:rift_ore_diamond", + "sanctuary:rift_ore_emerald", + "sanctuary:rift_ore_coal_surface", + "sanctuary:rift_ore_iron_surface", + "sanctuary:rift_ore_copper_surface", + "sanctuary:rift_ore_coal_ledges", + "sanctuary:rift_ore_iron_ledges", + "sanctuary:rift_ore_copper_ledges" + ], + [], + [], + [ + "minecraft:glow_lichen", + "sanctuary:population_groves", + "sanctuary:population_decorations", + "sanctuary:population_shore_sugar_cane" + ], + [ + "minecraft:freeze_top_layer" + ] + ], + "has_precipitation": true, + "temperature": 0.8 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_100.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_100.json new file mode 100644 index 0000000..db260f1 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_100.json @@ -0,0 +1,53 @@ +{ + "type": "sanctuary:population_island", + "terrain": { + "type": "minecraft:squeeze", + "input": { + "type": "minecraft:interpolated", + "cell_size_xz": 8, + "cell_size_y": 4, + "input": { + "type": "minecraft:mul", + "left": 0.64, + "right": "sanctuary:floating_archipelago_384" + } + } + }, + "distortion": { + "type": "minecraft:noise", + "noise": "sanctuary:island_edge", + "xz_scale": 1.0, + "y_scale": 0.0 + }, + "sculpt": { + "type": "minecraft:noise", + "noise": "sanctuary:island_sculpt", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "detail": { + "type": "minecraft:noise", + "noise": "sanctuary:island_detail", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "underside": { + "type": "minecraft:noise", + "noise": "sanctuary:island_underside", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "players": 100, + "rift_warp": { + "type": "minecraft:noise", + "noise": "sanctuary:rift_warp", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "rift_detail": { + "type": "minecraft:noise", + "noise": "sanctuary:rift_detail", + "xz_scale": 1.0, + "y_scale": 1.0 + } +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_20.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_20.json new file mode 100644 index 0000000..6509c86 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_20.json @@ -0,0 +1,53 @@ +{ + "type": "sanctuary:population_island", + "terrain": { + "type": "minecraft:squeeze", + "input": { + "type": "minecraft:interpolated", + "cell_size_xz": 8, + "cell_size_y": 4, + "input": { + "type": "minecraft:mul", + "left": 0.64, + "right": "sanctuary:floating_archipelago_384" + } + } + }, + "distortion": { + "type": "minecraft:noise", + "noise": "sanctuary:island_edge", + "xz_scale": 1.0, + "y_scale": 0.0 + }, + "sculpt": { + "type": "minecraft:noise", + "noise": "sanctuary:island_sculpt", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "detail": { + "type": "minecraft:noise", + "noise": "sanctuary:island_detail", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "underside": { + "type": "minecraft:noise", + "noise": "sanctuary:island_underside", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "players": 20, + "rift_warp": { + "type": "minecraft:noise", + "noise": "sanctuary:rift_warp", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "rift_detail": { + "type": "minecraft:noise", + "noise": "sanctuary:rift_detail", + "xz_scale": 1.0, + "y_scale": 1.0 + } +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_5.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_5.json new file mode 100644 index 0000000..28c5743 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/population_5.json @@ -0,0 +1,53 @@ +{ + "type": "sanctuary:population_island", + "terrain": { + "type": "minecraft:squeeze", + "input": { + "type": "minecraft:interpolated", + "cell_size_xz": 8, + "cell_size_y": 4, + "input": { + "type": "minecraft:mul", + "left": 0.64, + "right": "sanctuary:floating_archipelago_384" + } + } + }, + "distortion": { + "type": "minecraft:noise", + "noise": "sanctuary:island_edge", + "xz_scale": 1.0, + "y_scale": 0.0 + }, + "sculpt": { + "type": "minecraft:noise", + "noise": "sanctuary:island_sculpt", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "detail": { + "type": "minecraft:noise", + "noise": "sanctuary:island_detail", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "underside": { + "type": "minecraft:noise", + "noise": "sanctuary:island_underside", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "players": 5, + "rift_warp": { + "type": "minecraft:noise", + "noise": "sanctuary:rift_warp", + "xz_scale": 1.0, + "y_scale": 1.0 + }, + "rift_detail": { + "type": "minecraft:noise", + "noise": "sanctuary:rift_detail", + "xz_scale": 1.0, + "y_scale": 1.0 + } +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/feature/population_amethyst_geode.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/feature/population_amethyst_geode.json new file mode 100644 index 0000000..b4254a3 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/feature/population_amethyst_geode.json @@ -0,0 +1,39 @@ +{ + "type": "minecraft:geode", + "blocks": { + "alternate_inner_layer_provider": { + "id": "minecraft:budding_amethyst" + }, + "cannot_replace": "#minecraft:features_cannot_replace", + "filling_provider": { + "id": "minecraft:air" + }, + "inner_layer_provider": { + "id": "minecraft:amethyst_block" + }, + "inner_placements": [ + "minecraft:small_amethyst_bud", + "minecraft:medium_amethyst_bud", + "minecraft:large_amethyst_bud", + "minecraft:amethyst_cluster" + ], + "invalid_blocks": "#minecraft:geode_invalid_blocks", + "middle_layer_provider": { + "id": "minecraft:calcite" + }, + "outer_layer_provider": { + "id": "minecraft:smooth_basalt" + } + }, + "crack": { + "generate_crack_chance": 1.0 + }, + "invalid_blocks_threshold": 1, + "layers": {}, + "outer_wall_distance": 1, + "use_alternate_layer0_chance": 0.2, + "distribution_points": 2, + "point_offset": 1, + "min_gen_offset": -5, + "max_gen_offset": 7 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_deep_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_deep_strata.json new file mode 100644 index 0000000..21bd0fe --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_deep_strata.json @@ -0,0 +1,37 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": -1000, + "max_threshold": -0.3, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:blackstone" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": 0.3, + "max_threshold": 1000, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:basalt" + } + }, + { + "type": "minecraft:block", + "result_state": "minecraft:deepslate" + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_depth_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_depth_strata.json new file mode 100644 index 0000000..85bf60d --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_depth_strata.json @@ -0,0 +1,71 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 136 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:population_deep_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 152 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_deep_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 168 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_deep_strata" + } + }, + "sanctuary:population_upper_strata" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_humid_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_humid_strata.json new file mode 100644 index 0000000..cee6462 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_humid_strata.json @@ -0,0 +1,51 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": -1000, + "max_threshold": -0.36, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:cobblestone" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": 0.36, + "max_threshold": 1000, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:packed_mud" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": -0.14, + "max_threshold": 0.14, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:mossy_cobblestone" + } + }, + { + "type": "minecraft:block", + "result_state": "minecraft:tuff" + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_island.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_island.json new file mode 100644 index 0000000..f0e3376 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_island.json @@ -0,0 +1,279 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:population_sulfur_depths" + }, + "then_run": "sanctuary:population_sulfur_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 136 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:population_deep_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 152 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_deep_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 168 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_deep_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:population_lush_caves" + }, + "then_run": "sanctuary:population_humid_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:population_dark_grove" + }, + "then_run": { + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 192 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:population_humid_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 208 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_humid_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 224 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_humid_strata" + } + } + ] + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:population_bamboo_grove" + }, + "then_run": { + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 192 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": "sanctuary:population_humid_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 208 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": 0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_humid_strata" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:not", + "invert": { + "type": "minecraft:y_above", + "add_stone_depth": false, + "anchor": { + "absolute": 224 + }, + "surface_depth_multiplier": 0 + } + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_depth_boundary", + "min_threshold": -1000, + "max_threshold": -0.45, + "is_3d": true + }, + "then_run": "sanctuary:population_humid_strata" + } + } + ] + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:population_rocky_heath" + }, + "then_run": "sanctuary:population_upper_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_exposure", + "min_threshold": 0.85, + "max_threshold": 1000, + "is_3d": false + }, + "then_run": "sanctuary:population_upper_strata" + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:biome", + "biome_is": "sanctuary:population_dry_woodland" + }, + "then_run": { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_exposure", + "min_threshold": 0.15, + "max_threshold": 1000, + "is_3d": false + }, + "then_run": "sanctuary:population_upper_strata" + } + }, + "sanctuary:starter_island", + "sanctuary:population_upper_strata" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_sulfur_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_sulfur_strata.json new file mode 100644 index 0000000..2783c36 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_sulfur_strata.json @@ -0,0 +1,34 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": -0.15, + "max_threshold": 0.55, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:sulfur" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": -0.55, + "max_threshold": -0.3, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:cinnabar" + } + }, + "sanctuary:population_depth_strata" + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_upper_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_upper_strata.json new file mode 100644 index 0000000..727ed30 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/material_rule/population_upper_strata.json @@ -0,0 +1,51 @@ +{ + "type": "minecraft:sequence", + "sequence": [ + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": -1000, + "max_threshold": -0.36, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:granite" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": 0.36, + "max_threshold": 1000, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:diorite" + } + }, + { + "type": "minecraft:condition", + "if_true": { + "type": "minecraft:noise_threshold", + "noise": "sanctuary:population_strata", + "min_threshold": -0.14, + "max_threshold": 0.14, + "is_3d": true + }, + "then_run": { + "type": "minecraft:block", + "result_state": "minecraft:andesite" + } + }, + { + "type": "minecraft:block", + "result_state": "minecraft:stone" + } + ] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_depth_boundary.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_depth_boundary.json new file mode 100644 index 0000000..ed46579 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_depth_boundary.json @@ -0,0 +1,5 @@ +{ + "base_amplitude": 1.0, + "base_octave": -6, + "octave_count": 2 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_exposure.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_exposure.json new file mode 100644 index 0000000..f4e40d6 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_exposure.json @@ -0,0 +1,5 @@ +{ + "base_amplitude": 1.0, + "base_octave": -7, + "octave_count": 2 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_strata.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_strata.json new file mode 100644 index 0000000..ed46579 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise/population_strata.json @@ -0,0 +1,5 @@ +{ + "base_amplitude": 1.0, + "base_octave": -6, + "octave_count": 2 +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/population_100.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/population_100.json new file mode 100644 index 0000000..afaca51 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/population_100.json @@ -0,0 +1,23 @@ +{ + "default_block": "minecraft:stone", + "default_fluid": "minecraft:air", + "disable_mob_generation": false, + "legacy_random_source": true, + "material_rule": "sanctuary:population_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:population_100", + "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/noise_settings/population_20.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/population_20.json new file mode 100644 index 0000000..b297fb0 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/population_20.json @@ -0,0 +1,23 @@ +{ + "default_block": "minecraft:stone", + "default_fluid": "minecraft:air", + "disable_mob_generation": false, + "legacy_random_source": true, + "material_rule": "sanctuary:population_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:population_20", + "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/noise_settings/population_5.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/population_5.json new file mode 100644 index 0000000..683a00d --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/population_5.json @@ -0,0 +1,23 @@ +{ + "default_block": "minecraft:stone", + "default_fluid": "minecraft:air", + "disable_mob_generation": false, + "legacy_random_source": true, + "material_rule": "sanctuary:population_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:population_5", + "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/population_decorations.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_decorations.json new file mode 100644 index 0000000..9e6bc80 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_decorations.json @@ -0,0 +1,6 @@ +{ + "feature": { + "type": "sanctuary:population_decorations" + }, + "placement": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_groves.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_groves.json new file mode 100644 index 0000000..51af18b --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_groves.json @@ -0,0 +1,6 @@ +{ + "feature": { + "type": "sanctuary:population_groves" + }, + "placement": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_shore_sugar_cane.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_shore_sugar_cane.json new file mode 100644 index 0000000..56952ec --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/population_shore_sugar_cane.json @@ -0,0 +1,6 @@ +{ + "feature": { + "type": "sanctuary:population_shore_sugar_cane" + }, + "placement": [] +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json index 948a778..f320baf 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,19 +5,19 @@ "generator": { "type": "minecraft:noise", "biome_source": { - "type": "sanctuary:cavern_island", - "oak_forest": "sanctuary:cavern_oak_forest", - "birch_forest": "sanctuary:cavern_birch_forest", - "clearing": "sanctuary:cavern_clearing", - "dry_woodland": "sanctuary:cavern_dry_woodland", - "dark_grove": "sanctuary:cavern_dark_grove", - "bamboo_grove": "sanctuary:cavern_bamboo_grove", - "sulfur_depths": "sanctuary:cavern_sulfur_depths", - "rocky_heath": "sanctuary:cavern_rocky_heath", - "lush_caves": "sanctuary:cavern_lush_caves", - "dripstone_caves": "sanctuary:cavern_dripstone_caves" + "type": "sanctuary:population_island", + "oak_forest": "sanctuary:population_oak_forest", + "birch_forest": "sanctuary:population_birch_forest", + "clearing": "sanctuary:population_clearing", + "dry_woodland": "sanctuary:population_dry_woodland", + "dark_grove": "sanctuary:population_dark_grove", + "bamboo_grove": "sanctuary:population_bamboo_grove", + "sulfur_depths": "sanctuary:population_sulfur_depths", + "rocky_heath": "sanctuary:population_rocky_heath", + "lush_caves": "sanctuary:population_lush_caves", + "dripstone_caves": "sanctuary:population_dripstone_caves" }, - "settings": "sanctuary:sanctuary_cavern" + "settings": "sanctuary:population_20" } }, "minecraft:the_end": { diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_100.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_100.json new file mode 100644 index 0000000..2bb8f6e --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_100.json @@ -0,0 +1,45 @@ +{ + "dimensions": { + "minecraft:overworld": { + "type": "sanctuary:sanctuary_384", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "sanctuary:population_island", + "oak_forest": "sanctuary:population_oak_forest", + "birch_forest": "sanctuary:population_birch_forest", + "clearing": "sanctuary:population_clearing", + "dry_woodland": "sanctuary:population_dry_woodland", + "dark_grove": "sanctuary:population_dark_grove", + "bamboo_grove": "sanctuary:population_bamboo_grove", + "sulfur_depths": "sanctuary:population_sulfur_depths", + "rocky_heath": "sanctuary:population_rocky_heath", + "lush_caves": "sanctuary:population_lush_caves", + "dripstone_caves": "sanctuary:population_dripstone_caves" + }, + "settings": "sanctuary:population_100" + } + }, + "minecraft:the_end": { + "type": "minecraft:the_end", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "minecraft:the_end" + }, + "settings": "minecraft:end" + } + }, + "minecraft:the_nether": { + "type": "minecraft:the_nether", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "minecraft:multi_noise", + "preset": "minecraft:nether" + }, + "settings": "minecraft:nether" + } + } + } +} diff --git a/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_5.json b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_5.json new file mode 100644 index 0000000..5be9f70 --- /dev/null +++ b/mods/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary_5.json @@ -0,0 +1,45 @@ +{ + "dimensions": { + "minecraft:overworld": { + "type": "sanctuary:sanctuary_384", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "sanctuary:population_island", + "oak_forest": "sanctuary:population_oak_forest", + "birch_forest": "sanctuary:population_birch_forest", + "clearing": "sanctuary:population_clearing", + "dry_woodland": "sanctuary:population_dry_woodland", + "dark_grove": "sanctuary:population_dark_grove", + "bamboo_grove": "sanctuary:population_bamboo_grove", + "sulfur_depths": "sanctuary:population_sulfur_depths", + "rocky_heath": "sanctuary:population_rocky_heath", + "lush_caves": "sanctuary:population_lush_caves", + "dripstone_caves": "sanctuary:population_dripstone_caves" + }, + "settings": "sanctuary:population_5" + } + }, + "minecraft:the_end": { + "type": "minecraft:the_end", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "minecraft:the_end" + }, + "settings": "minecraft:end" + } + }, + "minecraft:the_nether": { + "type": "minecraft:the_nether", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "minecraft:multi_noise", + "preset": "minecraft:nether" + }, + "settings": "minecraft:nether" + } + } + } +} diff --git a/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/PopulationCapacitySmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/PopulationCapacitySmoke.java new file mode 100644 index 0000000..0ae29b1 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/PopulationCapacitySmoke.java @@ -0,0 +1,51 @@ +package fr.koka.sanctuary.worldgen; + +import com.mojang.serialization.JsonOps; +import com.google.gson.JsonPrimitive; + +/** Capacity and region boundaries are persistence contracts, including negative coordinates. */ +public final class PopulationCapacitySmoke { + public static void main(String[] args) { + require(IslandCapacity.values().length == 3, "Only the three validated sizes are exposed"); + for (int players : new int[]{5, 20, 100}) { + var capacity = IslandCapacity.fromPlayers(players); + double ratio = Math.pow(capacity.radius() / IslandCapacity.FIVE.radius(), 2); + require(Math.abs(ratio - players / 5.0) < 1e-10, "Area scales with planned population"); + var encoded = IslandCapacity.CODEC.encodeStart(JsonOps.INSTANCE, capacity).getOrThrow(); + require(encoded.getAsInt() == players, "Saved capacity is an explicit player count"); + require(IslandCapacity.CODEC.parse(JsonOps.INSTANCE, encoded).getOrThrow() == capacity, + "All capacities survive a codec round trip"); + require(capacity.areaExists(0, 0), "The central chunk intersects the envelope"); + int exterior = Math.floorDiv(capacity.terrainLimit() + 16, 16); + require(!capacity.areaExists(exterior, 0) && !capacity.areaExists(-exterior - 1, 0), + "Exterior chunks are rejected on both sides of the origin"); + require(!capacity.areaExists(1_875_000, 1_875_000), "Far-world arithmetic must not wrap"); + for (int x : new int[]{-1025, -769, -768, -257, -256, -255, -1, 0, 255, 256, 257, 767, 768, 1025}) { + for (int z : new int[]{-769, -768, -257, -256, -1, 0, 255, 256, 767, 768}) { + var region = capacity.regionAt(x, z); + require(x >= region.minX() && x <= region.maxX() + && z >= region.minZ() && z <= region.maxZ(), "Every block has one owner"); + require(region.maxX() - region.minX() + 1 == 512 + && region.maxZ() - region.minZ() + 1 == 512, "Region dimensions stay local"); + require(capacity.regionAt(region.maxX() + 1, z).x() == region.x() + 1, + "Adjacent regions have no gap or overlap"); + require(capacity.regionAt(region.minX() - 1, z).x() == region.x() - 1, + "Negative boundaries use floor division"); + } + } + } + for (int invalid : new int[]{0, 1, 4, 6, 10, 21, 99, 101, -5}) { + require(IslandCapacity.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(invalid)).error().isPresent(), + "Unsupported saved counts must fail, not silently become a different island"); + boolean rejected = false; + try { IslandCapacity.fromPlayers(invalid); } + catch (IllegalArgumentException expected) { rejected = true; } + require(rejected, "Unsupported configuration is rejected"); + } + System.out.println("Population capacity smoke passed: 5/20/100, codec, area and signed region ownership"); + } + + 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/PopulationRegionPlanSmoke.java b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/PopulationRegionPlanSmoke.java new file mode 100644 index 0000000..61ad651 --- /dev/null +++ b/mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/PopulationRegionPlanSmoke.java @@ -0,0 +1,126 @@ +package fr.koka.sanctuary.worldgen; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import fr.koka.sanctuary.worldgen.PopulationHydrology.*; + +/** Adversarial fixtures exercise rejection and signed translation without Minecraft or terrain searches. */ +public final class PopulationRegionPlanSmoke { + public static void main(String[] args) throws Exception { + water(); + lava(); + require(PopulationOwnership.contains(-240, 239, 16), "Hydrology includes its exact admission limits"); + require(!PopulationOwnership.contains(-241, 0, 16) && !PopulationOwnership.contains(240, 0, 16), + "Hydrology leaves both regional margins unused"); + require(PopulationOwnership.contains(-224, 223, 32), "Lava includes its exact admission limits"); + require(!PopulationOwnership.contains(-225, 0, 32) && !PopulationOwnership.contains(224, 0, 32), + "Lava leaves both regional margins unused"); + System.out.println("Population region plans smoke passed: atomic admission, dependency removal, signed multi-layer translation"); + } + + private static void water() throws Exception { + Map surface = new LinkedHashMap<>(); + for (Cell cell : List.of(cell(0, 100, 1), cell(10, 100, 2), + new Cell(240, 0, -1, 98, 100, ShoreMaterial.GRASS, 2, 3))) + surface.put(key(cell.x(), cell.z()), cell); + List surfaceFeatures = List.of(feature(1, Kind.LAKE, 0, 100), feature(2, Kind.POND, 10, 100)); + List lowerCells = List.of(cell(0, 80, 3), cell(0, 60, 4), cell(30, 80, 5), cell(30, 60, 6)); + List lowerFeatures = List.of(feature(3, Kind.TERRACE, 0, 80), feature(4, Kind.TERRACE, 0, 60), + feature(5, Kind.TERRACE, 30, 80), feature(6, Kind.TERRACE, 30, 60)); + var validSpill = new Spill(101, 3, 4, p(0, 80), p(1, 80), List.of(p(1, 80), p(0, 60)), b(0, 60, 1, 80)); + var invalidSpill = new Spill(102, 5, 6, p(30, 80), p(31, 80), List.of(p(31, 80), p(30, 60)), b(30, 60, 240, 80)); + var validSpring = new Spring(201, p(-20, 100), p(-19, 100), List.of(p(-19, 100), p(-19, 0)), b(-20, 0, -19, 100)); + // This spring is itself in bounds, but its prediction used the rejected pond's cut at x10. + var dependentSpring = new Spring(202, p(8, 100), p(9, 100), List.of(p(9, 100), p(10, 98)), b(8, 98, 10, 100)); + Set flow = Set.of(validSpring.source(), validSpring.outlet(), dependentSpring.source(), dependentSpring.outlet()); + var lower = new PopulationTerraces.Result(lowerCells, lowerFeatures, + List.of(new Terrace(301, List.of(3L, 4L)), new Terrace(302, List.of(5L, 6L))), + List.of(validSpill, invalidSpill), Set.of(validSpill.source(), validSpill.outlet(), invalidSpill.source())); + var ctor = Plan.class.getDeclaredConstructor(Map.class, List.class, List.class, Set.class, int.class, long.class, + PopulationTerraces.Result.class); + ctor.setAccessible(true); + var original = ctor.newInstance(surface, surfaceFeatures, List.of(validSpring, dependentSpring), flow, 123, 456L, lower); + var admitted = original.admitted(); + require(admitted.features().stream().map(Feature::id).toList().equals(List.of(1L, 3L, 4L)), + "Out-of-bounds pond and both members of an invalid terrace group disappear atomically"); + require(admitted.cells().stream().allMatch(c -> Set.of(1L, 3L, 4L).contains(c.featureId())), + "No water or dry bank belonging to a rejected feature remains"); + require(admitted.terraces().equals(List.of(new Terrace(301, List.of(3L, 4L)))) + && admitted.spills().equals(List.of(validSpill)), "A rejected outlet cannot leave orphan terrace basins"); + require(admitted.springs().equals(List.of(validSpring)), "A spring relying on a removed cut is rejected too"); + require(!admitted.allowsSpringFlow(8, 100, 0), "Rejected isolated spring prediction is absent"); + require(admitted.admitted().cells().equals(admitted.cells()) && admitted.admitted().spills().equals(admitted.spills()), + "Admission is idempotent"); + var moved = admitted.translated(512, -512); + require(moved.cellsAt(512, -512).size() == 3 && moved.cellsInChunk(32, -32).size() == 3, + "Surface and both lower layers share their translated world column and chunk"); + require(moved.cellsAt(0, 0).isEmpty() && moved.cellsInChunk(0, 0).isEmpty(), "No stale local indexes survive"); + require(moved.cellAt(512, 79, -512).featureId() == 3 && moved.cellAt(512, 59, -512).featureId() == 4, + "Vertical interval lookups still distinguish both lower pools"); + require(moved.springsInChunk(30, -32).getFirst().source().equals(new Position(492, 100, -512)), + "Spring chunk ownership follows signed world coordinates"); + require(moved.isSpillOpening(513, 80, -512) && !moved.isSpillOpening(1, 80, 0), "Spill-opening index moves"); + require(moved.allowsSpringFlow(492, 100, -512), "The flow prediction moves with its source"); + var restored = moved.translated(-512, 512); + require(restored.cells().equals(admitted.cells()) && restored.features().equals(admitted.features()) + && restored.springs().equals(admitted.springs()) && restored.spills().equals(admitted.spills()) + && restored.terraces().equals(admitted.terraces()), "Inverse translation restores paths, bounds, IDs and stacked cells exactly"); + require(moved.sampledColumns() == 123 && moved.densitySamples() == 456, "Survey work counts are not geometry"); + } + + private static void lava() throws Exception { + var pocket = List.of(new PopulationLavaDeposit.Cell(0, 100, 0, true), new PopulationLavaDeposit.Cell(0, 101, 0, false)); + Set pocketSupport = Set.of(lp(0, 99)); + var valid = new PopulationLavaFlows.Fall(401, lp(20, 100), lp(21, 100), List.of(lp(21, 100), lp(21, 0)), + b(20, 0, 22, 100), List.of(lp(19, 100))); + var invalid = new PopulationLavaFlows.Fall(402, lp(210, 100), lp(211, 100), List.of(lp(211, 100), lp(224, 0)), + b(210, 0, 224, 100), List.of(lp(209, 100))); + var ctor = PopulationLavaDeposit.Plan.class.getDeclaredConstructor(List.class, Set.class, + PopulationLavaDeposit.Position.class, List.class, List.class, Set.class); + ctor.setAccessible(true); + List cells = new ArrayList<>(pocket); + cells.add(new PopulationLavaDeposit.Cell(20, 100, 0, true)); + cells.add(new PopulationLavaDeposit.Cell(210, 100, 0, true)); + Set supports = new HashSet<>(pocketSupport); + supports.addAll(valid.supports()); supports.addAll(invalid.supports()); + var original = ctor.newInstance(cells, supports, lp(0, 102), List.of(valid, invalid), pocket, pocketSupport); + var admitted = original.admitted(); + require(admitted.falls().equals(List.of(valid)) && admitted.cells().size() == 3 && admitted.lavaCells().size() == 2, + "Rejecting a lava fall removes its source while preserving the complete pocket"); + require(!admitted.supports().contains(lp(209, 100)) && admitted.supports().contains(lp(0, 99)), + "A rejected fall leaves no support journal; the pocket keeps its own support"); + var moved = admitted.translated(-512, 1024); + require(moved.cellsInChunk(-32, 64).size() == 2 && moved.supportsInChunk(-32, 64).size() == 1, + "Pocket cells and supports use translated negative chunk coordinates"); + require(moved.fallsInChunk(-31, 64).size() == 1 && moved.access().orElseThrow().equals( + new PopulationLavaDeposit.Position(-512, 102, 1024)), "Fall ownership and the player access point move"); + var restored = moved.translated(512, -1024); + require(restored.cells().equals(admitted.cells()) && restored.supports().equals(admitted.supports()) + && restored.falls().equals(admitted.falls()) && restored.access().equals(admitted.access()), + "Lava inverse translation restores all sources, outlets, paths, bounds and supports"); + require(admitted.admitted().cells().equals(admitted.cells()), "Lava admission is idempotent"); + // One invalid pocket cell rejects its entire chamber and access, never the independent valid fall. + var badPocket = List.of(new PopulationLavaDeposit.Cell(224, 100, 0, true), new PopulationLavaDeposit.Cell(0, 101, 0, false)); + var badCombined = new ArrayList<>(badPocket); + badCombined.add(new PopulationLavaDeposit.Cell(20, 100, 0, true)); + var badSupports = new HashSet<>(pocketSupport); + badSupports.addAll(valid.supports()); + var pocketRejected = ctor.newInstance(badCombined, badSupports, lp(0, 102), List.of(valid), badPocket, pocketSupport).admitted(); + require(pocketRejected.cells().size() == 1 && pocketRejected.access().isEmpty() + && pocketRejected.supports().equals(valid.supports()), "Pocket admission is atomic and leaves no orphan opening or access"); + } + + private static Cell cell(int x, int waterY, long id) { return new Cell(x, 0, waterY, waterY - 2, waterY, ShoreMaterial.STONE, id, 3); } + private static Feature feature(long id, Kind kind, int x, int waterY) { + return new Feature(id, kind, waterY, List.of(new Point(x, 0)), 1, new Bounds(x, 0, x, 0)); + } + private static Position p(int x, int y) { return new Position(x, y, 0); } + private static PopulationLavaDeposit.Position lp(int x, int y) { return new PopulationLavaDeposit.Position(x, y, 0); } + private static FlowBounds b(int minX, int minY, int maxX, int maxY) { return new FlowBounds(minX, minY, 0, maxX, maxY, 0); } + private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } + 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 67945c1..2889c10 100644 --- a/packwiz/README.md +++ b/packwiz/README.md @@ -51,11 +51,14 @@ Pour produire un fichier importable par un lanceur compatible Modrinth : ```sh cd build/packwiz # depuis la racine du dépôt -packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.9.mrpack +packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.10.mrpack ``` L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API. Il ne publie rien sur Modrinth. +Les artefacts alpha.10 sont vérifiés localement. Les instances Sanctuary +Beta déjà raccordées continuent d’utiliser le canal stable et recevront sa +mise à jour après publication, sans nouvelle importation. L’alpha.9 est publiée et vérifiée. L’instance Sanctuary Beta existante suit le canal packwiz ; cet export reste destiné aux imports ponctuels. diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 541f4f1..93ade59 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "0.1.0-alpha.9" +version = "0.1.0-alpha.10" pack-format = "packwiz:1.1.0" [index]