Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef5d1eaad9 | ||
|
|
0a0034dd42 | ||
|
|
40b655517e | ||
|
|
bf84b37ea7 | ||
|
|
80e78e0421 | ||
|
|
d50d47e149 | ||
|
|
f1e48fa969 | ||
|
|
f621f921a1 | ||
|
|
6e94c1c539 | ||
|
|
40168e2054 | ||
|
|
ebcdb17a11 | ||
|
|
40463329f1 | ||
|
|
ddc43d716b | ||
|
|
2932411349 | ||
|
|
27df2da2a6 | ||
|
|
ae8c2235ef | ||
|
|
85320f3b1e | ||
|
|
624e7e6e60 | ||
|
|
e48de1b400 |
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{json,yml,yaml,toml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,9 @@
|
||||
* text=auto
|
||||
*.java text eol=lf
|
||||
*.json text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.md text eol=lf
|
||||
gradlew text eol=lf
|
||||
gradlew.bat text eol=crlf
|
||||
*.png binary
|
||||
*.jar binary
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Bug
|
||||
about: Signaler un comportement incorrect et le reproduire
|
||||
title: "[Bug] "
|
||||
---
|
||||
|
||||
## Résultat observé
|
||||
|
||||
Décrire le problème et son impact sur le jeu.
|
||||
|
||||
## Résultat attendu
|
||||
|
||||
Décrire le comportement correct.
|
||||
|
||||
## Étapes de reproduction
|
||||
|
||||
1. Ouvrir…
|
||||
2. Faire…
|
||||
3. Observer…
|
||||
|
||||
## Environnement
|
||||
|
||||
- Version de Minecraft :
|
||||
- Version de Sanctuary ou commit :
|
||||
- Version de Fabric Loader et Fabric API :
|
||||
- Client seul ou serveur dédié :
|
||||
- Autres mods pertinents :
|
||||
- Fréquence : toujours / parfois / une fois :
|
||||
|
||||
## Monde concerné
|
||||
|
||||
Pour un problème de terrain, d'entités ou de position :
|
||||
|
||||
- Seed :
|
||||
- Dimension et coordonnées :
|
||||
- Monde nouvellement créé ou sauvegarde existante :
|
||||
- Type de monde et paramètres Sanctuary :
|
||||
|
||||
## Logs et captures utiles
|
||||
|
||||
Ajouter uniquement les éléments nécessaires à la reproduction. Retirer les secrets et informations personnelles. Indiquer si le problème survient aussi dans une sauvegarde de développement neuve, si ce test a été fait.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Fonctionnalité
|
||||
about: Proposer un incrément jouable de Sanctuary
|
||||
title: "[Feature] "
|
||||
---
|
||||
|
||||
## Ce que le joueur doit pouvoir faire
|
||||
|
||||
Décrire le résultat souhaité et son utilité en quelques phrases.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Indiquer les systèmes concernés et les dépendances éventuelles. Faire un ticket distinct pour les idées qui peuvent être livrées séparément.
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] Un comportement observable et vérifiable.
|
||||
- [ ] Un cas limite important, si nécessaire.
|
||||
|
||||
## Contexte et références
|
||||
|
||||
Exemples, croquis, liens vers la vision, ancien fichier ou commit utile. Pour la génération : seed, coordonnées, dimensions et configuration si elles sont connues.
|
||||
|
||||
## Décisions encore ouvertes
|
||||
|
||||
Valeurs d'équilibrage, règles, interface ou contraintes qui doivent être fixées pendant le ticket.
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Build Sanctuary
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Verify and build
|
||||
run: ./gradlew --no-daemon check build assemblePack
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
.DS_Store
|
||||
.gradle/
|
||||
**/build/
|
||||
**/run/
|
||||
**/out/
|
||||
.idea/
|
||||
*.iml
|
||||
*.jar
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
*.mrpack
|
||||
logs/
|
||||
crash-reports/
|
||||
saves/
|
||||
screenshots/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,19 @@
|
||||
# Sanctuary Beta — travail par tickets
|
||||
|
||||
Ce dépôt contient le pack Sanctuary et ses mods. Lire `README.md`, puis le ticket concerné.
|
||||
La vision est dans `docs/vision.md` ; elle décrit aussi des fonctionnalités futures.
|
||||
|
||||
- Avant une modification, lire l'état Git et préserver les changements existants.
|
||||
- Un ticket correspond à un résultat jouable ou vérifiable et à une branche `codex/<sujet>`.
|
||||
- Garder les changements ciblés. Le dépôt voisin `26.2` est une référence historique, pas une cible de modification ou de déploiement.
|
||||
- Les sources Java sont dans `mods/<mod>/`, le manifeste du pack dans `packwiz/`.
|
||||
- Les règles de jeu et sauvegardes font autorité côté serveur. Les mods autonomes gardent leur propre responsabilité.
|
||||
- Ne pas modifier un monde existant, régénérer des chunks, changer un format de sauvegarde ou activer une expansion sans contrat de migration explicite.
|
||||
- Garder les identifiants `sanctuary:*` stables et documenter toute évolution de la génération avec graine et version.
|
||||
- Ajouter les libellés FR/EN des nouvelles interfaces. Vérifier les dépendances pour la version Minecraft exacte ; aucune compatibilité supposée à partir du nom d'un mod.
|
||||
- Lancer `./gradlew check build` pour livrer du code, et `./gradlew assemblePack` si la distribution change. Ajouter seulement les tests utiles au comportement touché.
|
||||
- Pour chaque nouvelle livraison du mod, incrémenter `mod_version` et `pack_version` dans `gradle.properties`, synchroniser `packwiz/pack.toml` et documenter le changement. Une simple modification documentaire n'incrémente pas les binaires.
|
||||
- Le pack Beta suit un canal packwiz stable et une seule instance Prism. Pour une mise à jour demandée, suivre `docs/packwiz.md` : publier un artefact vérifié et immuable, avancer le canal, puis synchroniser l'instance existante en conservant ses sauvegardes et réglages.
|
||||
- Ne pas versionner de secrets, mondes, JAR générés ou dépendances téléchargées. Le wrapper Gradle fait exception.
|
||||
- Ne pas déployer dans une installation de jeu ou un serveur personnel sans demande correspondante. Les serveurs de test restent dans les dossiers de développement ignorés.
|
||||
- Une livraison explique le résultat, les vérifications effectuées et les limites encore ouvertes. Ne pas marquer une intention comme implémentée.
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
# 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
|
||||
et géodes d’améthyste recherchés sous l’île, sur des supports naturels.
|
||||
- Province de soufre limitée dans les profondeurs ; geysers utilisant
|
||||
les blocs et conditions de fonctionnement de Minecraft 26.3-pre-2.
|
||||
- Forêts et rivière de surface conservées ; recherche de lacs plus profonds
|
||||
et volumineux et de groupes facultatifs de bassins en terrasses reliés par
|
||||
des cascades. Les « rizières » désignent le relief aquatique, sans culture
|
||||
de riz ni mécanique d’ascenseur ajoutée au joueur.
|
||||
- Nouvelle clé `sanctuary:sanctuary_cavern`, réservée aux nouveaux mondes.
|
||||
Les générations jusqu’à l’alpha.8 restent séparées ; aucune sauvegarde
|
||||
existante n’est convertie ou régénérée.
|
||||
|
||||
Validation : au moins dix tests requis réussis sur chacune des graines `0`,
|
||||
`42` et `8675309`, avec 1 800 ticks de fluides ; `check build assemblePack`
|
||||
réussi. Les preuves cumulées comprennent deux bassins en terrasses reliés,
|
||||
un geyser dormant puis éruptif, des baies lumineuses, des spéléothèmes et de
|
||||
l’améthyste bourgeonnante. Les mesures et leurs périmètres figurent dans
|
||||
[Validation](docs/testing.md). Release et canal packwiz publiés ; double
|
||||
synchronisation isolée puis dans la même instance Prism réussie, avec 260
|
||||
fichiers personnels et réglages conservés. Aucun site ni stock n’est garanti
|
||||
sur chaque graine.
|
||||
|
||||
## 0.1.0-alpha.8 — 2026-09-09
|
||||
|
||||
- Forêts de surface conservées, avec de nouvelles failles courbes qui retirent
|
||||
de la roche pour ouvrir l’intérieur de l’île.
|
||||
- Nappes de tuf, cobblestone, pierre moussue et boue compactée dans les étages
|
||||
humides ; ardoise des abîmes, roche noire et basalte plus bas. Les poches de
|
||||
soufre et de cinabre peuvent apparaître à des altitudes plus accessibles.
|
||||
- Davantage d’essais de petits filons de charbon, fer et cuivre, y compris
|
||||
au contact de l’air. Ajout de l’émeraude et adaptation des huit familles de
|
||||
minerais aux nouvelles roches, sans quota de remplissage.
|
||||
- Décoration des corniches revue avec de gros champignons, des petits
|
||||
champignons plus dispersés et des sols de mousse, podzol et mycélium.
|
||||
- Bassins plus grands et recherche d’une
|
||||
longue rivière calme au tracé arrondi, de largeur variable, soutenue par la
|
||||
roche, avec bassins terminaux, berges progressives et source en paroi.
|
||||
- Recherche prioritaire de la poche de lave couverte entre Y=64 et Y=160,
|
||||
avec recherche plus haute si aucun site profond ne convient. Les sources
|
||||
profondes supplémentaires produisent leurs coulées par les ticks vanilla.
|
||||
- Nouvelle clé `sanctuary:sanctuary_rift` ; les anciennes générations jusqu’à
|
||||
l’alpha.7 restent séparées. Cet incrément nécessite un **nouveau monde Sanctuary**.
|
||||
|
||||
Validation : tests serveur réussis sur chacune des graines `0`, `42` et
|
||||
`8675309`, avec les bassins terminaux, la continuité des champignons et
|
||||
1 800 ticks réels de fluides. `check build assemblePack` réussi sur la graine
|
||||
`42`. Les mesures et leurs limites figurent dans [Validation](docs/testing.md).
|
||||
Publication immuable et canal packwiz vérifiés, avec deux synchronisations
|
||||
dans un dossier neuf puis deux dans la même instance Prism ; sauvegardes et
|
||||
réglages conservés.
|
||||
Aucune rivière, coulée ou réserve de ressources n’est garantie pour chaque graine.
|
||||
|
||||
## 0.1.0-alpha.7 — 2026-09-08
|
||||
|
||||
- Forêts de chênes plus denses, y compris au cœur et sur les hauteurs de l’île,
|
||||
avec bouleaux, clairières fleuries et bois plus secs.
|
||||
- Vrais chênes noirs, champignons, podzol et mycélium sur les corniches intérieures
|
||||
humides ; mousse plus ponctuelle et rares poches de jungle/bambou.
|
||||
- Un emplacement facultatif pour un arbre remarquable (cerisier favorisé,
|
||||
épicéa, jungle, acacia, palétuvier ou chêne pâle), sans terrain ajouté.
|
||||
- Nappes de pierre, andésite, diorite et granite sur les affleurements ; dépôts
|
||||
épais de sable, gravier et argile associés aux bassins. Rares matières de
|
||||
soufre dans les profondeurs, sans grands bassins souterrains.
|
||||
- Petits filons de charbon, fer et cuivre autorisés à affleurer, avec des essais
|
||||
sur les roches de surface et les corniches pour les rendre repérables.
|
||||
- Nouvelle clé `sanctuary:sanctuary_woodland`, avec formes, hauteur, ciel et
|
||||
plans d’eau conservés. Créer un **nouveau monde Sanctuary** ; les sauvegardes
|
||||
alpha.6 et antérieures conservent leur génération.
|
||||
|
||||
Validation : dix tests serveur réussis sur chacune des graines `0`, `42` et
|
||||
`8675309`, arbres et minerais réellement observés, 1 800 ticks de fluides,
|
||||
et `check build assemblePack` réussi.
|
||||
|
||||
## 0.1.0-alpha.6 — 2026-09-08
|
||||
|
||||
- Palette tempérée plus douce, avec des couleurs communes pour l’herbe, le
|
||||
feuillage et l’eau. Dappled Forest quitte l’île initiale et reste réservé
|
||||
aux futurs continents ; les sauvegardes alpha.5 le conservent.
|
||||
- Prairies fleuries et affleurements rocheux plus étendus, avec une répartition
|
||||
des biomes selon l’altitude. Les corniches inférieures peuvent porter de la
|
||||
mousse, des fougères, des fleurs et des petits arbustes.
|
||||
- Sources recherchées aussi dans les parois des strates inférieures. Les
|
||||
cascades peuvent rejoindre un palier ou descendre dans le vide.
|
||||
- Retrait des courts ruisseaux à niveau constant : l’île conserve ses étangs
|
||||
et petits lacs. La grande rivière avec source, chute et bassin reste à
|
||||
développer ; elle n’est pas activée dans cette livraison.
|
||||
- Nouvelle clé `sanctuary:sanctuary_layered`, avec les anciennes générations
|
||||
conservées. Créer un **nouveau monde Sanctuary** pour cet incrément.
|
||||
|
||||
Validation : dix tests serveur réussis sur chacune des graines `0`, `42` et
|
||||
`8675309`, avec 1 800 ticks de fluides, contrôles des corniches et respect des
|
||||
modifications ultérieures aux sources. `check build assemblePack` réussi.
|
||||
|
||||
## 0.1.0-alpha.5 — 2026-09-08
|
||||
|
||||
- Plages plus larges et irrégulières, en nappes de sable, gravier et roche, avec
|
||||
des transitions herbeuses. Dépôts de 3 à 5 blocs au lieu d’une seule couche.
|
||||
- Étangs et lacs moins creusés, rives variées et petites terrasses ; la canne
|
||||
à sucre reste soumise à ses conditions vanilla.
|
||||
- Quelques sources sortent de niches rocheuses naturelles à différentes
|
||||
altitudes. Leurs cascades peuvent descendre dans le vide et servir à circuler
|
||||
verticalement ; seuls les blocs sources sont posés par la génération.
|
||||
- Petite réserve de lave dans une niche rocheuse accessible lorsque le relief
|
||||
fournit un fond, des parois et une voûte adaptés.
|
||||
- Mosaïque de cinq biomes tempérés, dont Dappled Forest de Minecraft 26.3.
|
||||
- Petits filons de charbon, fer, cuivre, or, redstone, lapis et diamant à des
|
||||
altitudes adaptées à l’île. Aucun quota ajouté après comptage des ressources.
|
||||
- Nouvelle clé de génération `sanctuary:sanctuary_natural` ; anciennes versions
|
||||
de terrain et d’hydrologie conservées pour les sauvegardes existantes.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour tester cette génération. La hauteur
|
||||
et le ciel validés en alpha.3/4 sont conservés. L’accès à l’End, les expansions
|
||||
et les grandes rivières en pente restent à développer.
|
||||
|
||||
Validation : dix tests serveur réussis sur chacune des graines 0, 42 et 8675309,
|
||||
avec 1 800 ticks réels de fluides. Les bassins et les réserves de lave restent
|
||||
stables ; les neuf cascades atteignent Y=0. `check build assemblePack` et les
|
||||
contrôles des couches de sédiments, des anciens générateurs et du pack réussissent.
|
||||
|
||||
## 0.1.0-alpha.4 — 2026-09-08
|
||||
|
||||
- Première hydrologie de surface : étangs, petits lacs et cours d’eau calmes
|
||||
adaptés au relief, avec vérification du fond et des berges.
|
||||
- Fonds de sable, gravier et argile, berges sableuses et canne à sucre
|
||||
placée après la végétation lorsque ses conditions vanilla sont réunies.
|
||||
- Hauteur, ciel et densité du terrain alpha.3 conservés ; nouveaux paramètres
|
||||
dédiés pour préserver la génération des anciennes sauvegardes.
|
||||
- Plan déterministe indépendant de l’ordre de génération des chunks, sans
|
||||
digue ajoutée, remplissage de cavité souterraine ni mer globale.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour tester l’hydrologie. Le pack continue
|
||||
à se mettre à jour dans la même instance Prism. Les grandes rivières en pente,
|
||||
cascades, océans et eaux des futurs continents restent à développer.
|
||||
|
||||
Validation : 10/10 tests serveur réussis sur chacune des graines 0, 42 et
|
||||
8675309, avec eau contenue après simulation réelle, continuité du cours d’eau
|
||||
et canne à sucre valide. Tests de forme et de déterminisme également réussis.
|
||||
Les cartes de blocs sont inspectées ; l’aspect dans le client reste à tester.
|
||||
|
||||
## 0.1.0-alpha.3 — 2026-09-08
|
||||
|
||||
- Nouveaux mondes Sanctuary sur 384 blocs de hauteur, avec l'île relevée de
|
||||
64 blocs et un dessous sculpté pour remplacer l'ancien fondu horizontal.
|
||||
- Nuages relevés et suppression du disque sombre de l'horizon et du
|
||||
noircissement d'altitude propres au vide dans ces nouveaux mondes.
|
||||
- Anciens paramètres de monde conservés : aucune translation de blocs ou
|
||||
modification de la hauteur des sauvegardes existantes.
|
||||
- Premier champ de recherche climatique documenté : nord froid, sud chaud,
|
||||
ouest sec, est humide et leurs combinaisons dans les quatre diagonales.
|
||||
- Relevé de développement des blocs et ressources après génération, avec
|
||||
emprise et complétude explicites. Aucun quota n'influence le terrain.
|
||||
|
||||
Les étangs, lacs de surface, rivière, berges à canne à sucre et plages de l'île
|
||||
tempérée restent à implémenter. Le contrat de cette hydrologie et de l'expansion
|
||||
est dans [Expansion et ressources](docs/expansion.md).
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la hauteur et le ciel de l'alpha.3.
|
||||
Le pack se met à jour dans la même instance Prism.
|
||||
|
||||
Validation : build et pack réussis, 8 tests serveur réussis sur chacune des
|
||||
graines 0, 42 et 8675309, injections de rendu vérifiées dans le vrai client.
|
||||
Le relevé complet de la graine 0 et les limites des essais sont documentés dans
|
||||
[Validation](docs/testing.md) et [Expansion et ressources](docs/expansion.md).
|
||||
|
||||
## 0.1.0-alpha.2 — 2026-09-08
|
||||
|
||||
- Suppression du noyau elliptique qui ajoutait une masse artificielle au centre.
|
||||
- Sculpture progressive du contour par érosion en trois dimensions ; retrait
|
||||
des faces hautes et basses pour réduire les grandes tranches verticales.
|
||||
- Spawn recherché sur le terrain naturel, avec une surface de 3 × 3 privilégiée.
|
||||
- Canal packwiz stable et release versionnée, pour mettre à jour la même
|
||||
instance Prism avant chaque lancement.
|
||||
|
||||
Validation : `./gradlew check build assemblePack`, tests de densité compilée,
|
||||
coupes du générateur et tests serveur sur plusieurs graines. Voir
|
||||
[Validation](docs/testing.md) et [Distribution Prism](docs/packwiz.md).
|
||||
|
||||
Tester la génération dans un **nouveau monde Sanctuary**. Les chunks déjà
|
||||
générés conservent leur ancien terrain ; les sauvegardes ne sont pas régénérées.
|
||||
Minecraft reste en 26.3-pre-2, avec Fabric Loader 0.19.5.
|
||||
|
||||
## 0.1.0-alpha.1 — 2026-09-08
|
||||
|
||||
Première base indépendante de Sanctuary Beta pour Minecraft 26.3-pre-2 / Fabric.
|
||||
|
||||
- Construction Java 25 / Gradle reproductible, CI Gitea et modèles de tickets.
|
||||
- Vision du projet conservée et audit du générateur 26.2 documenté.
|
||||
- Preset Sanctuary : île flottante finie issue du bruit historique, forêt de
|
||||
départ utilisant les ressources vanilla et extérieur vide.
|
||||
- Spawn initial près de l'origine, recherché sur une surface pleine et libre.
|
||||
- Tests de forme et tests sur un vrai serveur Minecraft de développement.
|
||||
- Manifeste packwiz avec Fabric API épinglé et assemblage du mod local.
|
||||
|
||||
Validation : `./gradlew check build assemblePack` réussit. Le serveur de test
|
||||
valide le spawn `(0, 118, 0)`, 12 chunks extérieurs entièrement vides et le
|
||||
déterminisme du bruit compilé. Voir [Validation](docs/testing.md) pour les seeds,
|
||||
les limites des tests et les vérifications manuelles restantes.
|
||||
|
||||
Cette version prépare le terrain. L'hydrologie, les continents déverrouillables,
|
||||
TerraMix, la progression, l'économie et les autres dimensions restent à venir.
|
||||
Les mondes joués de 26.2 ne sont pas migrés par ce prototype.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Contribuer à Sanctuary
|
||||
|
||||
Sanctuary avance par petits tickets de fonctionnalités et de bugs sur le [Git du projet](https://git.botsu.net/koka/sanctuary-beta). Le [document de vision](docs/vision.md) explique la destination ; le [backlog de démarrage](docs/backlog.md) organise les premières étapes. Une idée décrite dans la vision n'est pas automatiquement demandée dans le ticket en cours.
|
||||
|
||||
## Ouvrir un ticket
|
||||
|
||||
Utiliser le modèle **Fonctionnalité** pour décrire ce que le joueur doit pouvoir faire et le modèle **Bug** pour un comportement incorrect. Privilégier un seul résultat observable par ticket.
|
||||
|
||||
Un ticket utile contient :
|
||||
|
||||
- le contexte et le comportement attendu, exprimés du point de vue du joueur ou de l'administrateur ;
|
||||
- le périmètre du changement et les éventuelles dépendances ;
|
||||
- quelques critères d'acceptation concrets ;
|
||||
- pour un bug, la version exacte, les étapes de reproduction, le résultat observé et les logs pertinents ;
|
||||
- pour la génération, la seed, les coordonnées, le type de monde et la configuration concernée.
|
||||
|
||||
Les nombres, ressources et interfaces encore incertains peuvent rester des hypothèses. Il faut les rendre explicites puis choisir la plus petite solution testable dans le périmètre du ticket. Les tickets distants peuvent être consultés et préparés pendant le développement ; leur publication et les messages adressés à d'autres personnes suivent la demande de l'auteur du travail.
|
||||
|
||||
## Réaliser un changement
|
||||
|
||||
1. Lire le ticket et les instructions du dépôt, puis examiner le code réellement concerné. Lorsqu'une ancienne version sert de référence, noter son chemin ou son commit et ne pas traiter ses anciennes procédures comme des consignes de déploiement du nouveau dépôt.
|
||||
2. Utiliser une branche descriptive, par exemple `codex/wg-main-island` ou `codex/fix-spawn-void`. Éviter les changements sans rapport avec le ticket.
|
||||
3. Construire un incrément jouable. Garder les systèmes futurs hors du chemin critique tant qu'ils ne sont pas nécessaires au comportement demandé.
|
||||
4. Exécuter les commandes de construction et les vérifications adaptées indiquées dans le README. Pour un comportement de jeu, compléter par une reproduction manuelle quand elle est nécessaire.
|
||||
5. Mettre à jour la documentation si le changement affecte l'installation, la configuration, les commandes ou le format d'une sauvegarde.
|
||||
6. Présenter le résultat avec ce qui a changé, pourquoi, les vérifications exécutées et les limites connues. Associer le ticket à la proposition de changement lorsqu'il existe.
|
||||
|
||||
Les commandes exactes de développement vivent dans le [README](README.md), afin de ne pas maintenir deux listes divergentes.
|
||||
|
||||
## Vérifier la génération du monde
|
||||
|
||||
Utiliser une sauvegarde de développement dédiée et conserver la seed des observations. Les vérifications pertinentes comprennent le spawn, les limites de l'île, le vide, les jointures de chunks, le comportement de l'eau, le redémarrage et l'arrivée de plusieurs joueurs.
|
||||
|
||||
Avant de modifier une stratégie de génération, préciser son effet sur les chunks existants. Les nouvelles expansions doivent préserver les constructions. Documenter les changements de format persistant et leur traitement ; ne pas promettre la compatibilité des anciennes sauvegardes sans l'avoir vérifiée.
|
||||
|
||||
Des tests automatisés sont utiles pour les invariants importants, par exemple le déterminisme des coordonnées, les limites géographiques ou la persistance d'un état. Ne pas multiplier les tests qui recopient simplement l'implémentation ou les vérifications sans rapport avec le changement.
|
||||
|
||||
## Ressources et intégrations
|
||||
|
||||
Avant de reprendre du code, des textures, de la musique, des modèles ou des configurations de l'historique et de la communauté, conserver leur provenance et respecter leur licence. Une ressource installée localement n'est pas automatiquement redistribuable dans le modpack.
|
||||
|
||||
Sanctuary, son modpack, It's Alive !, Only Fun et Master Key ont des responsabilités distinctes. Une intégration commence par un ticket qui précise la version supportée, la dépendance réelle et le comportement en son absence. L'ajout d'une dépendance ou d'un module doit répondre à un besoin livré.
|
||||
|
||||
## Signaler les résultats
|
||||
|
||||
Une description de changement doit être compréhensible sans lire la conversation de développement. Pour un bug, donner si possible un exemple avant/après. Indiquer ce qui a été testé réellement ; une compilation réussie ne prouve pas à elle seule qu'une génération est agréable ni qu'une session multijoueur fonctionne.
|
||||
|
||||
Les captures, logs et sauvegardes partagées doivent être limités au contexte utile et ne pas inclure de jetons, données d'authentification ou informations personnelles inutiles. Ne pas ajouter les répertoires d'exécution, les caches ou les mondes complets au dépôt par défaut.
|
||||
@@ -0,0 +1,676 @@
|
||||
Sanctuary 26.2 is licensed under GPL-3.0-or-later.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,75 +1,236 @@
|
||||
# Sanctuary — historique des notes de livraison
|
||||
# Sanctuary
|
||||
|
||||
Publication rétrospective des livraisons locales après **alpha.30.7**, jusqu’à **beta.064**.
|
||||
Sanctuary est une extension gratuite de Minecraft qui transforme le jeu en un
|
||||
monde flottant d'exploration, de production et de progression collective.
|
||||
Les joueurs commencent ensemble sur Sanctuary Island et construisent les
|
||||
infrastructures qui permettront progressivement d'ouvrir de nouveaux continents.
|
||||
|
||||
Cette branche contient uniquement des documents. Les tags `notes/beta.xxx` sont des repères documentaires ; leurs archives Gitea ne sont pas les sources historiques du mod. Les posts beta.060 et beta.061 utilisent les tags de code exacts déjà publiés. Les tags alpha et les artefacts existants restent immuables.
|
||||
> Sanctuary is a free expansion of Minecraft that reshapes the game around a
|
||||
> floating world. Players begin together on Sanctuary Island, isolated in the
|
||||
> void. By exploring, building infrastructure and producing resources, they
|
||||
> progressively unlock new floating continents, each with its own geography.
|
||||
> Rather than a linear campaign, the server itself becomes a world that grows
|
||||
> through the action of its players.
|
||||
|
||||
**63 livraisons** sont recensées. Le chantier beta.021 a été finalisé en beta.023, sans livraison indépendante. L’alpha.30.7 possède déjà son [post de release](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.30.7).
|
||||
Ce dépôt démarre la nouvelle base Fabric. La [vision complète](docs/vision.md)
|
||||
conserve les intentions ; le [backlog](docs/backlog.md) prépare les premiers
|
||||
tickets. Les systèmes d'expansion, d'économie, de progression et les dimensions
|
||||
décrits dans la vision ne sont pas encore implémentés.
|
||||
|
||||
Chaque texte décrit sa version historique ; les livraisons suivantes ont pu modifier les mêmes règles. Le détail des vérifications et limites reste accessible depuis chaque post. La préparation de ces notes ne rejoue pas les tests de jeu et ne publie pas les archives locales.
|
||||
## Socle livré jusqu’à l’alpha.8
|
||||
|
||||
| Version | Notes |
|
||||
- Mod `sanctuary` indépendant et pack Sanctuary construit avec packwiz.
|
||||
- Preset **Sanctuary** sélectionnable à la création d'un monde, avec une île
|
||||
flottante finie, un terrain naturel et un extérieur vide.
|
||||
- Île tempérée aux teintes douces : forêts de chênes et de bouleaux, clairières
|
||||
fleuries et affleurements de pierre, andésite, diorite et granite. Des failles
|
||||
courbes ouvrent le terrain flottant issu du générateur 26.2. Les corniches
|
||||
humides accueillent chênes noirs et champignons ; les essences rares restent
|
||||
ponctuelles.
|
||||
- Géologie enrichie en profondeur : tuf, pierres moussues et boue compactée
|
||||
dans les intérieurs humides, puis ardoise des abîmes, roche noire, basalte et
|
||||
poches de soufre. Les volumes existants fournissent leur support.
|
||||
- Petits filons de charbon, fer, cuivre, or, redstone, lapis, diamant et émeraude
|
||||
à des altitudes adaptées au terrain. Les essais de charbon, fer et cuivre
|
||||
exposés sont plus fréquents. Les stocks se constatent après génération.
|
||||
- Spawn recherché sur le sol de l'île lors de la création. Les mondes ordinaires
|
||||
et les choix ultérieurs de spawn ne sont pas remplacés.
|
||||
- Nouveaux mondes sur 384 blocs (`Y=0..383`), île relevée de 64 blocs,
|
||||
dessous sculpté et nuages au-dessus du terrain. Les anciennes sauvegardes
|
||||
conservent leurs paramètres de génération.
|
||||
- Étangs, petits lacs, plages plus larges et dépôts de
|
||||
trois à cinq blocs de sable, gravier, pierre ou terre enherbée. La canne à
|
||||
sucre apparaît lorsque les rives respectent ses règles vanilla.
|
||||
- Recherche de bassins plus grands et d’une longue rivière calme au tracé
|
||||
arrondi, avec largeur variable et berges progressives. La liaison de deux
|
||||
bassins, sa source et sa cascade entrante sont contrôlées dans les mondes
|
||||
de test de l’alpha.8.
|
||||
- Quelques sources dans des niches rocheuses naturelles, y compris aux étages
|
||||
inférieurs : Minecraft fait couler leur eau en cascades, qui peuvent descendre jusque dans le vide.
|
||||
- Une petite poche de lave couverte est recherchée en priorité sur une corniche
|
||||
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.
|
||||
|
||||
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
|
||||
les autres fonctionnalités restent à construire par tickets.
|
||||
|
||||
La génération alpha.8, `sanctuary:sanctuary_rift`, passe les contrôles moteur
|
||||
sur les trois graines de référence, avec 1 800 ticks de fluides. Ces résultats
|
||||
ne valident pas par avance l’incrément suivant. Les observations et leurs
|
||||
limites figurent dans [Validation](docs/testing.md).
|
||||
|
||||
## Alpha.9 : cavités vivantes et terrasses d’eau
|
||||
|
||||
L’incrément ajoute des cavités luxuriantes et lumineuses sous l’île, des
|
||||
secteurs à spéléothèmes, des géodes d’améthyste et des poches de soufre moins
|
||||
envahissantes, avec de vrais geysers de soufre lorsque leurs conditions sont
|
||||
réunies. Les forêts de surface sont conservées ; la recherche de lacs plus
|
||||
profonds et volumineux accompagne des groupes facultatifs de bassins naturels
|
||||
étagés. Leurs cascades relient les paliers et permettent de circuler par la nage.
|
||||
L’image des « rizières » décrit ce paysage d’eau en terrasses : aucune culture
|
||||
de riz n’est ajoutée.
|
||||
|
||||
**La validation moteur alpha.9 passe sur les trois graines de référence.**
|
||||
Les preuves de terrasses, geysers et décor souterrain sont cumulées sur ces
|
||||
trois mondes, sans imposer chaque élément à chacun. La version est publiée
|
||||
et la même instance Prism est synchronisée ; voir [Distribution](docs/packwiz.md). Le
|
||||
nouveau réglage `sanctuary:sanctuary_cavern` nécessite de créer un **nouveau
|
||||
monde Sanctuary**. Les anciennes générations jusqu’à l’alpha.8 gardent leurs
|
||||
paramètres ; aucune sauvegarde n’est régénérée. Les cavités restent dans
|
||||
l’Overworld, sans créer la dimension des cavernes prévue par la vision.
|
||||
Dappled Forest reste réservé aux futurs continents et présent dans les
|
||||
anciennes sauvegardes alpha.5. Aucun lac, groupe de terrasses, geyser ou stock
|
||||
de ressources n’est garanti sur chaque graine. Voir le ticket local
|
||||
[WG-10](docs/backlog.md).
|
||||
|
||||
## 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
|
||||
en jeu reste à construire. Voir [Expansion et ressources](docs/expansion.md).
|
||||
|
||||
Les détails du terrain et de ses limites sont dans [Génération](docs/worldgen.md),
|
||||
et la provenance dans [Audit 26.2](docs/migration-26.2.md).
|
||||
|
||||
## Versions et prérequis
|
||||
|
||||
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
|
||||
pas cibler une version finale 26.3 déjà sortie. Le passage à la version finale
|
||||
sera une mise à jour explicite, avec vérification des API et des sauvegardes.
|
||||
|
||||
| Composant | Version fixée |
|
||||
| --- | --- |
|
||||
| beta.001 | [Les quatre anciennes expéditions](notes/beta.001.md) |
|
||||
| beta.002 | [Biomes boréaux, corail et temples](notes/beta.002.md) |
|
||||
| beta.003 | [Créer son habitant et commencer la progression](notes/beta.003.md) |
|
||||
| beta.004 | [Cadre Blocodex et sprint](notes/beta.004.md) |
|
||||
| beta.005 | [Carte native et noms de couleurs](notes/beta.005.md) |
|
||||
| beta.006 | [Brouillard, grille et Demeure](notes/beta.006.md) |
|
||||
| beta.007 | [Carte immersive](notes/beta.007.md) |
|
||||
| beta.008 | [Minage et vein mining](notes/beta.008.md) |
|
||||
| beta.009 | [Construction et vein building](notes/beta.009.md) |
|
||||
| beta.010 | [Inventaire jusqu'à six rangées](notes/beta.010.md) |
|
||||
| beta.011 | [Factions, prestige et New Game+](notes/beta.011.md) |
|
||||
| beta.012 | [Inventaire et progression lisible](notes/beta.012.md) |
|
||||
| beta.013 | [Progression, prestige et repères visuels](notes/beta.013.md) |
|
||||
| beta.014 | [Rangement, hotbar mobile et préfixe de faction](notes/beta.014.md) |
|
||||
| beta.015 | [Icônes de progression communes](notes/beta.015.md) |
|
||||
| beta.016 | [Les premiers gestes rapportent de l’XP](notes/beta.016.md) |
|
||||
| beta.017 | [Nage, S’allonger, Se reposer et fabrication manuelle](notes/beta.017.md) |
|
||||
| beta.018 | [Familiers, cape et cosmétique de tête](notes/beta.018.md) |
|
||||
| beta.019 | [Pouvoirs et déplacements des familiers](notes/beta.019.md) |
|
||||
| beta.020 | [Œufs singuliers, portage et plongeon](notes/beta.020.md) |
|
||||
| beta.022 | [Création de monde plus rapide](notes/beta.022.md) |
|
||||
| beta.023 | [Cosmétiques directement sur leur case](notes/beta.023.md) |
|
||||
| beta.024 | [Recettes progressives dans le Blocodex](notes/beta.024.md) |
|
||||
| beta.025 | [Barres d’XP des compétences](notes/beta.025.md) |
|
||||
| beta.026 | [Cache d’affichage Demeure sur la carte](notes/beta.026.md) |
|
||||
| beta.027 | [Piles de joueurs](notes/beta.027.md) |
|
||||
| beta.028 | [Plongeon jusqu’à collision](notes/beta.028.md) |
|
||||
| beta.029 | [Cri et recul des familiers](notes/beta.029.md) |
|
||||
| beta.030 | [Lumières portées et objets jetés](notes/beta.030.md) |
|
||||
| beta.031 | [Apparition galactique](notes/beta.031.md) |
|
||||
| beta.032 | [Refonte des 88 familiers](notes/beta.032.md) |
|
||||
| beta.033 | [Menus directs et profil de test rapide](notes/beta.033.md) |
|
||||
| beta.034 | [Fil de découvertes et hotbar agrandie](notes/beta.034.md) |
|
||||
| beta.035 | [Collections Minecraft](notes/beta.035.md) |
|
||||
| beta.036 | [Notifications et suivi choisi](notes/beta.036.md) |
|
||||
| beta.037 | [Temps réel et suivi commun](notes/beta.037.md) |
|
||||
| beta.038 | [Nourriture, têtes-tombes et factions](notes/beta.038.md) |
|
||||
| beta.039 | [Hello World, savoir alimentaire et inventaires de mort](notes/beta.039.md) |
|
||||
| beta.040 | [Mode opérateur, fiche Habitant et ciel partagé](notes/beta.040.md) |
|
||||
| beta.041 | [Portage des bébés](notes/beta.041.md) |
|
||||
| beta.042 | [Joueurs assis sur la tête](notes/beta.042.md) |
|
||||
| beta.043 | [Blocs portés et icône officielle](notes/beta.043.md) |
|
||||
| beta.044 | [Pouvoirs natifs des familiers](notes/beta.044.md) |
|
||||
| beta.045 | [Menu principal](notes/beta.045.md) |
|
||||
| beta.046 | [Constellations, vol et zoom](notes/beta.046.md) |
|
||||
| beta.047 | [Étoiles à la longue-vue et boutons](notes/beta.047.md) |
|
||||
| beta.048 | [Bateaux collectifs et moteurs animaux](notes/beta.048.md) |
|
||||
| beta.049 | [Bateaux rectangulaires et chapeaux actifs](notes/beta.049.md) |
|
||||
| beta.050 | [Bateaux compacts et redstone portée](notes/beta.050.md) |
|
||||
| beta.051 | [Distributeur porté orienté par le regard](notes/beta.051.md) |
|
||||
| beta.052 | [Nom du familier de départ](notes/beta.052.md) |
|
||||
| beta.053 | [Brume progressive et saturation](notes/beta.053.md) |
|
||||
| beta.054 | [Duels, mises et niveaux des familiers](notes/beta.054.md) |
|
||||
| beta.055 | [Force et portage de joueurs](notes/beta.055.md) |
|
||||
| beta.056 | [Étoiles dispersées et saturation](notes/beta.056.md) |
|
||||
| beta.057 | [Météo quotidienne et nouvelles ambiances](notes/beta.057.md) |
|
||||
| beta.058 | [Altitude de la carte](notes/beta.058.md) |
|
||||
| beta.059 | [Bannières et cartes au trésor](notes/beta.059.md) |
|
||||
| beta.060 | [Objets et blocs sur les mobs](notes/beta.060.md) |
|
||||
| beta.061 | [Musique d’arrivée et montures familières](notes/beta.061.md) |
|
||||
| beta.062 | [Soleil et chapeaux des familiers](notes/beta.062.md) |
|
||||
| beta.063 | [Chargement et règle de jeu Sanctuary](notes/beta.063.md) |
|
||||
| beta.064 | [Cache et étapes réelles de préparation](notes/beta.064.md) |
|
||||
| Minecraft Java | 26.3-pre-2 |
|
||||
| Java JDK | 25 |
|
||||
| Fabric Loader | 0.19.5 |
|
||||
| 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.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
|
||||
3.11–3.14 si `python3` désigne l'ancien Python fourni par macOS. Gradle est fourni
|
||||
par le wrapper. `packwiz` est utile pour modifier les dépendances ou servir le
|
||||
pack ; il n'est pas nécessaire pour le construire.
|
||||
|
||||
Références vérifiées : [Minecraft 26.3-pre-2](https://www.minecraft.net/en-us/article/minecraft-26-3-pre-release-2),
|
||||
[Fabric](https://fabricmc.net/develop/),
|
||||
[version Fabric API](https://modrinth.com/mod/fabric-api/version/o9uChmGq).
|
||||
|
||||
## Construire et lancer
|
||||
|
||||
```sh
|
||||
git clone https://git.botsu.net/koka/sanctuary-beta.git
|
||||
cd sanctuary-beta
|
||||
./gradlew check build assemblePack
|
||||
```
|
||||
|
||||
`check` inclut les tests de forme et le serveur de test automatisé de Fabric,
|
||||
qui charge le vrai preset Sanctuary dans un monde de développement neuf.
|
||||
Les scénarios couverts et les essais multijoueurs restant à effectuer sont
|
||||
décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `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).
|
||||
|
||||
Le pack peut aussi être exporté en `.mrpack` avec packwiz pour une importation
|
||||
dans un lanceur compatible ; la commande est indiquée dans ce même guide.
|
||||
|
||||
Pour les essais réguliers, la même instance Prism suit un canal packwiz stable
|
||||
et se synchronise avant chaque lancement. La procédure de publication et de
|
||||
raccordement est dans [Distribution Prism](docs/packwiz.md).
|
||||
|
||||
Lancer le client de développement :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runClient
|
||||
```
|
||||
|
||||
Créer un **nouveau monde**, puis sélectionner le type de monde **Sanctuary**.
|
||||
L'installation du mod seule ne transforme pas un monde Minecraft ordinaire.
|
||||
|
||||
Pour un serveur de développement, lancer `./gradlew :sanctuary:runServer`, puis
|
||||
suivre les indications du jeu concernant son EULA. Utiliser un dossier de monde
|
||||
neuf et choisir dans `mods/sanctuary/run/server.properties` :
|
||||
|
||||
```properties
|
||||
level-type=sanctuary:sanctuary
|
||||
level-name=sanctuary-dev
|
||||
level-seed=42
|
||||
```
|
||||
|
||||
Relancer ensuite la même commande. Ne pas remplacer le générateur d'une
|
||||
sauvegarde 26.2 : aucun outil de migration de ses chunks n'est livré ici.
|
||||
|
||||
## Organisation
|
||||
|
||||
| Chemin | Responsabilité |
|
||||
| --- | --- |
|
||||
| `mods/sanctuary/` | Code, ressources et tests du mod Sanctuary |
|
||||
| `packwiz/` | Manifeste source, dépendances distantes et hashes |
|
||||
| `ressources-pack/` | Sources graphiques existantes et futurs resource packs |
|
||||
| `shaders-pack/` | Sources et réglages de shaders à venir |
|
||||
| `docs/` | Vision, backlog, migration, génération et validation |
|
||||
| `.gitea/ISSUE_TEMPLATE/` | Modèles de tickets fonctionnalités et bugs |
|
||||
| `.gitea/workflows/` | Vérification sur un runner Gitea compatible |
|
||||
|
||||
It's Alive !, Only Fun et Master Key restent des modules autonomes prévus par la
|
||||
vision. Leurs sources 26.2 ne sont pas copiées dans ce socle. Fabric API est la
|
||||
seule dépendance de gameplay actuellement distribuée avec le mod.
|
||||
|
||||
## Travailler par tickets
|
||||
|
||||
Un ticket décrit un comportement à obtenir ou un bug à reproduire. On livre
|
||||
un petit incrément, on le vérifie en jeu si nécessaire, puis on ajuste avec
|
||||
le ticket suivant. Pour le terrain, joindre la seed et les coordonnées aide
|
||||
à retrouver exactement le problème. Voir [Contribuer](CONTRIBUTING.md).
|
||||
|
||||
Le [backlog initial](docs/backlog.md) contient des propositions locales, pas des
|
||||
issues déjà publiées. Le prochain travail après ce socle est le réglage de
|
||||
l'île, puis le placement de continents et leur ouverture sans écraser les
|
||||
constructions des joueurs.
|
||||
|
||||
## Licence et crédits
|
||||
|
||||
Sanctuary conserve la licence **GPL-3.0-or-later** du projet 26.2. Voir
|
||||
[LICENSE](LICENSE) et [provenance et crédits](THIRD_PARTY_NOTICES.md).
|
||||
Projet communautaire indépendant de Mojang Studios et Microsoft.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Provenance et crédits
|
||||
|
||||
Le code Sanctuary est distribué sous GPL-3.0-or-later, comme la version 26.2 dont
|
||||
la génération est reprise. Voir `LICENSE` et `docs/migration-26.2.md` pour la
|
||||
provenance détaillée et le périmètre du portage.
|
||||
|
||||
Minecraft appartient à Mojang Studios / Microsoft. Sanctuary est un projet
|
||||
communautaire indépendant. Le dépôt ne redistribue pas le jeu.
|
||||
|
||||
Les biomes de départ reprennent les configurations de forêt, plaine, forêt de
|
||||
bouleaux, forêt fleurie et Dappled Forest de Minecraft 26.3-pre-2, adaptées à
|
||||
une île finie. Les variantes alpha.6 réutilisent les végétations vanilla pour
|
||||
les prairies fleuries et les bosquets ; Dappled Forest reste disponible pour
|
||||
les sauvegardes alpha.5. L’alpha.7 réemploie les arbres vanilla (dont chêne noir,
|
||||
cerisier et essences rares), les champignons, le bambou et les blocs de soufre
|
||||
de cette version, avec des conditions propres à l’île. Les règles des petits minerais sont adaptées à partir des
|
||||
features vanilla de cette même version. Les références de blocs, végétation et
|
||||
minerais sont résolues par Minecraft ; leurs textures et modèles ne sont pas inclus.
|
||||
|
||||
Fabric Loader : Apache-2.0. Fabric API : Apache-2.0. Fabric Loom et le wrapper
|
||||
Gradle : Apache-2.0. Ces projets conservent leurs auteurs, notices et licences.
|
||||
Les dépendances sont résolues depuis leurs distributions officielles.
|
||||
|
||||
Le fichier préexistant `ressources-pack/helloworld/assets/minecraft_title.png`
|
||||
est conservé comme source graphique fournie par le propriétaire du dépôt.
|
||||
Il n'est pas encore installé dans le pack généré. Sa provenance et son adaptation
|
||||
à l'écran titre 26.3 seront précisées dans le ticket d'identité visuelle.
|
||||
|
||||
Les resource packs, shaders et mods communautaires envisagés dans la vision
|
||||
ne sont pas inclus automatiquement. Chaque ajout aura une version, une source,
|
||||
un hash et les crédits de sa distribution.
|
||||
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
id 'base'
|
||||
id 'net.fabricmc.fabric-loom' version "${loom_version}" apply false
|
||||
}
|
||||
|
||||
tasks.named('build') { dependsOn(':sanctuary:build') }
|
||||
tasks.named('check') { dependsOn(':sanctuary:check', 'verifyPack') }
|
||||
|
||||
tasks.register('verifyPack', Exec) {
|
||||
group = 'verification'
|
||||
description = 'Verify pinned pack dependencies, versions and index hashes.'
|
||||
commandLine('python3', 'scripts/pack.py', 'check')
|
||||
}
|
||||
|
||||
tasks.register('assemblePack', Exec) {
|
||||
group = 'distribution'
|
||||
description = 'Assemble a local packwiz pack including the built Sanctuary mod.'
|
||||
dependsOn(':sanctuary:build', 'verifyPack')
|
||||
commandLine('python3', 'scripts/pack.py', 'assemble')
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
# Backlog de démarrage
|
||||
|
||||
Ce fichier prépare les premiers tickets à publier sur le Git du projet. **Aucun identifiant ci-dessous n'est un numéro d'issue distante et aucun ticket n'est présumé publié.** Les identifiants `BOOT-*` et `WG-*` servent seulement à relier les travaux tant que les issues n'existent pas.
|
||||
|
||||
Le [document de vision](vision.md) conserve les idées à long terme. Ce backlog organise uniquement le socle et les premiers incréments de génération. Une fonctionnalité n'est considérée comme livrée que lorsque son résultat et sa validation figurent dans le dépôt ou dans son issue.
|
||||
|
||||
## Premier jalon : un monde Sanctuary jouable
|
||||
|
||||
État au 8 septembre 2026 : **BOOT-01 et WG-01 sont réalisés** dans le premier
|
||||
commit. Le prototype WG-02 et l'initialisation du spawn WG-03 sont implémentés
|
||||
et passent les tests serveur décrits dans [Validation](testing.md). Les essais
|
||||
visuels, multijoueurs et de redémarrage indiqués dans ce document restent à
|
||||
faire avant de clôturer tous leurs critères. WG-04 à WG-07 restent ouverts,
|
||||
avec les incréments de l’île initiale décrits ci-dessous. WG-08 est validé ;
|
||||
WG-09 est livré en alpha.8, avec mesures moteur et mise à jour Prism vérifiées.
|
||||
WG-10 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.
|
||||
|
||||
### BOOT-01 — Initialiser le dépôt et la construction Fabric
|
||||
|
||||
**But :** disposer d'une base clonable et vérifiable pour travailler par tickets.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Les versions réellement utilisées de Minecraft, Java, Fabric Loader, Fabric API et de l'outillage sont fixées et documentées.
|
||||
- La cible souhaitée 26.3 est distinguée de la version exécutée si sa disponibilité oblige à utiliser une version de développement ou à différer la migration.
|
||||
- Une commande reproductible construit le mod ; le README explique le lancement et les prérequis.
|
||||
- Les métadonnées identifient Sanctuary sans annoncer les fonctionnalités de la vision comme déjà présentes.
|
||||
- Le dépôt contient les consignes de contribution et des modèles de tickets ; les binaires, caches et sauvegardes de jeu ne sont pas suivis par accident.
|
||||
|
||||
### WG-01 — Auditer le générateur Sanctuary 26.2
|
||||
|
||||
**But :** identifier précisément le code et les ressources utiles avant de les adapter.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Les fichiers, commits ou références historiques consultés sont cités dans une note d'audit.
|
||||
- La note explique l'algorithme de l'île, le vide, le point d'apparition, les dépendances et les paramètres structurants.
|
||||
- Les éléments réutilisables, les défauts connus et les changements d'API de la cible sont séparés.
|
||||
- La réutilisation de ressources est accompagnée de leur origine et de leur licence connue ; les inconnues sont explicitement notées.
|
||||
- L'audit n'installe ni ne déploie l'ancienne version et ne modifie pas ses sauvegardes.
|
||||
|
||||
### WG-02 — Enregistrer un monde Sanctuary avec île principale et vide
|
||||
|
||||
**Dépendances :** BOOT-01, WG-01.
|
||||
|
||||
**But :** créer le premier terrain identifiable comme Sanctuary.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Une procédure documentée permet de créer un monde utilisant le générateur Sanctuary sur la version testée.
|
||||
- Une île principale est générée au point de départ prévu ; ses coordonnées, son altitude et ses dimensions sont explicites.
|
||||
- Au-delà de son emprise, les chunks de contrôle sont vides, sans fondation ni terrain vanilla inattendu.
|
||||
- Une même seed et une même configuration donnent les mêmes blocs aux positions de contrôle.
|
||||
- Les jointures entre chunks voisins ne créent pas de fissures ou de parois artificielles dues à une discontinuité du calcul.
|
||||
- Un monde Minecraft ordinaire reste créable sans sélectionner Sanctuary.
|
||||
|
||||
### WG-03 — Assurer une arrivée commune et un redémarrage sûr
|
||||
|
||||
**Dépendance :** WG-02.
|
||||
|
||||
**But :** éviter qu'un nouveau joueur apparaisse dans le vide et vérifier le comportement multijoueur.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Le spawn du monde se situe sur une surface stable de l'île, avec l'espace nécessaire au joueur.
|
||||
- Deux nouveaux joueurs arrivent dans la zone de départ commune, sans traverser le sol ni apparaître hors de l'île.
|
||||
- Le comportement après une mort sans lit est vérifié ; l'ajout ultérieur des Backrooms n'est pas requis pour ce ticket.
|
||||
- Après sauvegarde et redémarrage, le générateur, la seed et le spawn sont conservés.
|
||||
- Un scénario manuel reproductible couvre le client et le serveur dédié, avec la version et la seed utilisées.
|
||||
|
||||
## Deuxième jalon : des continents d'essai reproductibles
|
||||
|
||||
Ce jalon fournit un outil de développement du terrain. L'interface d'expansion et son coût collectif viendront dans des tickets distincts, lorsque la génération sera satisfaisante.
|
||||
|
||||
### WG-04 — Décrire et placer un continent d'essai
|
||||
|
||||
**Dépendance :** WG-03.
|
||||
|
||||
**But :** pouvoir générer une terre suspendue à une direction, une distance et une taille explicites.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Un schéma décrit au minimum l'identifiant, le centre ou la direction et la distance, les dimensions, l'altitude et la seed du continent.
|
||||
- Le premier champ de recherche utilise huit directions climatiques : nord froid,
|
||||
sud chaud, ouest sec, est humide ; les diagonales combinent ces tendances.
|
||||
Chaque candidat expose sa signature et sa distribution de température/humidité.
|
||||
- La sélection climatique ne force ni la forme du terrain ni des quotas de
|
||||
ressources. Un relevé après génération identifie les stocks réellement produits,
|
||||
son emprise, sa version et son niveau de complétude. Voir [le contrat](expansion.md).
|
||||
- Une configuration ou commande de développement documentée crée un continent reproductible.
|
||||
- Les paramètres invalides et les chevauchements interdits sont rejetés avec un message compréhensible.
|
||||
- Des limites de taille et de coût de génération sont définies à partir d'une mesure réelle.
|
||||
- Le continent et ses paramètres restent identiques après rechargement du monde.
|
||||
|
||||
### WG-05 — Ouvrir une expansion sans écraser l'existant
|
||||
|
||||
**Dépendance :** WG-04.
|
||||
|
||||
**But :** garantir que l'évolution du monde préserve les constructions et l'exploration.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- La politique envers les chunks déjà générés est décidée et documentée avant l'implémentation : refus, réservation préalable ou mécanisme explicite de modification.
|
||||
- Une nouvelle expansion ne remplace aucun bloc joueur silencieusement.
|
||||
- Répéter la même demande ne crée pas de duplicata et ne décale pas les continents existants.
|
||||
- L'état des expansions survit à un redémarrage et contient une version de format permettant de prévoir les migrations.
|
||||
- Une interruption entre réservation et génération est simulée ; la reprise ou le refus reste cohérent et expliqué.
|
||||
|
||||
### WG-06 — Ajouter reliefs, perforations et eaux retenues
|
||||
|
||||
**Dépendance :** WG-04 ; combiner avec WG-05 avant l'usage sur une sauvegarde jouée.
|
||||
|
||||
**But :** donner aux continents une géographie reconnaissable au-delà d'une simple masse de pierre.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Un jeu de seeds de référence montre des reliefs, montagnes, ravins et perforations traversantes.
|
||||
- Des bassins accueillent lacs ou océans, et un premier type de rivière flottante est démontré.
|
||||
- Pour l'île initiale tempérée et légèrement humide : privilégier petits étangs,
|
||||
lacs de surface, rivière et berges propices à la canne à sucre, avec quelques
|
||||
plages plus sèches. Pas de grands bassins souterrains remplis d'eau.
|
||||
- Les étangs et les lacs retiennent leur eau après les mises à jour de blocs.
|
||||
Des sources rocheuses déclarées peuvent former des cascades jusqu’au vide ;
|
||||
leurs écoulements restent attribuables à ces sources, sans inondation globale.
|
||||
- Les profils du dessous et les bords du continent sont inspectés visuellement depuis les airs.
|
||||
- La génération est mesurée sur une emprise et une machine indiquées ; les valeurs observées sont consignées, sans annoncer un objectif de performance non mesuré.
|
||||
|
||||
**Incrément île initiale — alpha.4 :** première hydrologie de surface retenue,
|
||||
conservée pour les sauvegardes de cette version.
|
||||
|
||||
**Incrément île initiale — alpha.5 :** plages plus larges,
|
||||
dépôts de trois à cinq couches sur support naturel, excavation limitée,
|
||||
rives variées et sources rocheuses pouvant former des cascades jusque dans le
|
||||
vide. Le contrat et l’isolation des anciennes générations sont décrits dans
|
||||
[Génération](worldgen.md). Ce travail prépare les eaux des continents sans clore
|
||||
WG-06 : océans, grandes rivières et ouverture des continents restent à développer.
|
||||
|
||||
**Incrément île initiale — alpha.6 :** retrait des courts ruisseaux, conservation
|
||||
des bassins, présence de roche élargie et sources de paroi dans les strates
|
||||
inférieures. Les écoulements sont contrôlés dans le moteur et les anciennes
|
||||
générations sont préservées.
|
||||
|
||||
**Suite suivie dans WG-09 — grande rivière facultative :** chercher un long parcours qui
|
||||
épouse l’île, avec source, chute et bassin, rives de sable, gravier et argile.
|
||||
Le démontrer sur la vraie densité Minecraft puis après décoration et ticks de
|
||||
fluide, avec une graine et des coordonnées reproductibles. Les essais du
|
||||
prototype alpha.6 n’ont fourni aucun parcours retenu sur les graines de référence ;
|
||||
ce prototype n’est pas distribué. Ne pas imposer de rivière si le relief ne s’y prête pas.
|
||||
|
||||
### WG-07 — Introduire un premier biome distinct et une structure
|
||||
|
||||
**Dépendance :** WG-06.
|
||||
|
||||
**But :** valider les points d'extension avant d'ajouter un grand catalogue de contenus.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Le ticket choisit un premier biome, par exemple le Black Desert, avec des règles de surface et d'ambiance explicites.
|
||||
- Une petite structure de référence se place sur un terrain compatible, sans flotter accidentellement ni détruire une construction existante.
|
||||
- Le lien entre biome, végétation, ressources et règles de placement est documenté.
|
||||
- La reprise du catalogue TerraMix natif d'Another World 26.2 est évaluée séparément ; « plus de cent biomes » reste une ambition tant que son adaptation n'est pas vérifiée.
|
||||
- Les limites des futures zones Lost Cities et des structures uniques sont identifiées sans imposer leur livraison dans ce ticket.
|
||||
|
||||
**Incrément île initiale — alpha.5 :** cinq variantes
|
||||
tempérées à dominante forestière, dont Dappled Forest vanilla 26.3, et petits
|
||||
filons de sept minerais à des altitudes adaptées. Leurs stocks sont observés
|
||||
après génération. Cela ne clôture pas WG-07 : aucune structure de référence ni
|
||||
reprise du catalogue TerraMix n’est encore fournie. La survie initiale doit
|
||||
permettre de produire et progresser au-delà du bois et de la pierre ; l’accès
|
||||
à toute la progression Minecraft n’est pas encore garanti pour chaque seed.
|
||||
|
||||
**Ticket palette — alpha.6 :** réserver les biomes très contrastés de la 26.3,
|
||||
dont Dappled Forest, aux futurs continents. L’île combine prairies fleuries,
|
||||
bosquets et zones rocheuses, avec des teintes cohérentes et une végétation
|
||||
effective sur les corniches inférieures. Cela ne livre pas encore les continents.
|
||||
|
||||
### WG-08 — Forêts de survie et minerais visibles — alpha.7
|
||||
|
||||
**Branche :** `codex/woodland-canopy`.
|
||||
|
||||
**But :** conserver les formes rocheuses appréciées en alpha.6 tout en donnant
|
||||
à l’île de vraies forêts de surface et des ressources de départ repérables.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Chênes majoritaires parmi les arbres de surface observés, bouleaux secondaires,
|
||||
avec des arbres au cœur de l’île et des clairières fleuries.
|
||||
- Chênes noirs réels et champignons sur des corniches intérieures naturelles ;
|
||||
jungle/bambou et matières de soufre rares et localisés.
|
||||
- Un éventuel arbre remarquable, choisi parmi les essences rares, sans ajout
|
||||
de terrain ni obligation de fournir chaque essence par graine.
|
||||
- Affleurements de pierre, andésite, diorite et granite ; sable et gravier
|
||||
associés aux eaux, avec des couches soutenues.
|
||||
- Charbon, fer et cuivre visibles au contact de l’air sur la roche, avec des
|
||||
petits filons sans quota corrigé après génération.
|
||||
- Démonstration dans le moteur, maintien des eaux après 1 800 ticks et
|
||||
conservation des anciennes générations. Publication packwiz et mise à jour
|
||||
de l’instance Prism existante en conservant ses données personnelles.
|
||||
|
||||
**Vérification alpha.7 :** les trois graines de référence passent les tests
|
||||
du moteur, dont les forêts, les minerais exposés et les eaux après 1 800 ticks.
|
||||
Les observations et leurs limites sont consignées dans [Validation](testing.md).
|
||||
|
||||
### WG-09 — Failles, rivière et profondeurs — alpha.8
|
||||
|
||||
**Branche :** `codex/rifts-rivers-and-depths`.
|
||||
|
||||
**But :** ouvrir l’intérieur de l’île, enrichir sa géologie et ses ressources
|
||||
de survie, tout en conservant les forêts de surface appréciées en alpha.7.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Une à trois traces de failles courbes, de longueur nominale 84 à 160 blocs
|
||||
et de largeur variable, retirent uniquement de la matière du relief initial.
|
||||
Leur intersection réelle avec la roche est montrée sur les graines testées.
|
||||
- Les forêts de chênes et de bouleaux restent présentes en surface. Des gros
|
||||
champignons, des petits champignons dispersés et des tapis de mousse, podzol
|
||||
et mycélium occupent les corniches compatibles de l’intérieur humide.
|
||||
- Les matières changent avec la profondeur, avec des nappes de tuf, cobblestone,
|
||||
pierre moussue et boue compactée, puis ardoise des abîmes, roche noire et basalte.
|
||||
Des poches de soufre réellement accessibles sont observées sur le terrain.
|
||||
- Charbon, fer et cuivre exposés sont plus faciles à repérer dans les relevés.
|
||||
L’émeraude est ajoutée ; or, diamant, redstone et lapis sont cherchés aux
|
||||
altitudes présentes. Aucun stock fixé n’est réinjecté après comptage.
|
||||
- Des bassins plus grands sont retenus. Une rivière facultative est démontrée
|
||||
par un chemin d’eau continu d’au moins 140 blocs, reliant deux bassins au
|
||||
même niveau avec source rocheuse et cascade entrante. Le tracé est arrondi
|
||||
à l’échelle de 16 à 32 blocs, avec largeur de lit variable d’environ 5 à 7
|
||||
blocs. L’incision du lit reste bornée à 16 blocs ; les berges de rivière
|
||||
s’étagent progressivement avec au plus 12 blocs retirés, contre 2 pour les
|
||||
autres rives. Le fond et les dépôts reposent sur le terrain existant.
|
||||
- La poche de lave couverte est recherchée en priorité sur une corniche basse
|
||||
entre Y=64 et Y=160 ; la recherche plus haute n’intervient que si aucun
|
||||
emplacement profond compatible n’est trouvé.
|
||||
- Une à deux sources profondes de lave sont recherchées lorsque le site est
|
||||
compatible ; leurs coulées viennent des ticks vanilla. Les supports, les
|
||||
eaux voisines et la végétation sont contrôlés après simulation réelle.
|
||||
- La nouvelle clé `sanctuary:sanctuary_rift` n’active ces traitements que pour
|
||||
les nouveaux mondes. Les données et comportements historiques restent séparés.
|
||||
- Les graines `0`, `42` et `8675309` disposent de mesures moteur explicites,
|
||||
avec fluides actifs pendant 1 800 ticks. Une absence de rivière ou de source
|
||||
est rapportée honnêtement ; un bassin isolé ne valide pas une rivière.
|
||||
- Après validation : build et pack vérifiés, publication immuable, deux
|
||||
synchronisations du canal packwiz et conservation de la même instance Prism,
|
||||
de ses sauvegardes et de ses réglages.
|
||||
|
||||
**Vérification moteur alpha.8 — 9 septembre 2026 :** les trois graines de
|
||||
référence passent les contrôles finaux, dont les deux bassins terminaux,
|
||||
les champignons connectés et 1 800 ticks de fluides. Les stocks et leurs
|
||||
périmètres sont consignés dans [Validation](testing.md). Le build complet est
|
||||
réussi. L’alpha.8 est publiée sur le canal stable et la même instance Prism
|
||||
est synchronisée deux fois, avec sauvegardes et réglages conservés. Le ticket
|
||||
est livré ; l’appréciation visuelle et l’équilibrage en partie restent à
|
||||
éprouver. Ce ticket local n’est pas une issue distante publiée.
|
||||
|
||||
### WG-10 — Cavités luxuriantes et terrasses d’eau — alpha.9
|
||||
|
||||
**Branche :** `codex/lush-caverns-and-water-terraces`.
|
||||
|
||||
**But :** rendre les cavités sous Sanctuary plus vivantes et lumineuses,
|
||||
diversifier leurs ressources et créer des eaux plus volumineuses, sans
|
||||
remplacer les forêts et la rivière de surface appréciées en alpha.8.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Des cavités luxuriantes portent une végétation réellement lumineuse et
|
||||
enracinée dans les surfaces et plafonds existants. Les observations doivent
|
||||
distinguer un biome déclaré, ses blocs décoratifs et leur lumière réelle.
|
||||
- Des secteurs à spéléothèmes et des géodes d’améthyste sont recherchés dans
|
||||
la roche compatible ; leurs supports, leurs volumes et leur accessibilité
|
||||
sont contrôlés dans les vrais chunks, sans géodes suspendues dans le vide.
|
||||
- Le soufre occupe moins de roche qu’en alpha.8, sur un périmètre de comparaison
|
||||
explicite. Les geysers éventuels utilisent le soufre actif et les conditions
|
||||
exactes de Minecraft 26.3-pre-2 ; leur fonctionnement doit être démontré,
|
||||
sans confondre un bloc jaune avec un geyser actif.
|
||||
- Les forêts, la rivière et les bassins de surface restent présents lorsque
|
||||
le relief le permet. Les lacs plus profonds et volumineux sont mesurés en
|
||||
blocs d’eau réels, avec fond et parois retenus après simulation.
|
||||
- Des groupes facultatifs de deux bassins naturels en terrasses
|
||||
peuvent relier plusieurs niveaux par des cascades déclarées. Chaque palier
|
||||
doit avoir ses supports et son propre volume retenu ; une cascade isolée
|
||||
ou des plans d’eau sans liaison ne valident pas un groupe de terrasses.
|
||||
Le second bassin peut avoir une sortie terminale vers les roches ou le vide,
|
||||
sans créer un troisième palier.
|
||||
- Ces cascades offrent un passage vertical selon les règles de nage de
|
||||
Minecraft. « Rizières » désigne seulement leur disposition paysagère :
|
||||
aucune culture de riz ni téléportation n’entre dans ce ticket.
|
||||
- Les sorties d’eau sont contrôlées après décoration et après 1 800 ticks,
|
||||
y compris aux frontières de chunks. Leur finition initiale doit préserver
|
||||
les aménagements du joueur aux rechargements suivants.
|
||||
- Le nouveau réglage `sanctuary:sanctuary_cavern` s’applique aux nouveaux
|
||||
mondes. Les identifiants et traitements alpha.1 à alpha.8 restent séparés ;
|
||||
aucune sauvegarde personnelle n’est modifiée par les essais.
|
||||
- Les graines `0`, `42` et `8675309` disposent de mesures identifiées par
|
||||
version. Les sites absents sont signalés ; aucun quota de ressources ni
|
||||
bassin de secours ne remplit un relief incompatible.
|
||||
- Après validation : `check build assemblePack`, artefacts immuables vérifiés,
|
||||
deux synchronisations isolées puis deux dans la même instance Prism,
|
||||
sauvegardes et réglages conservés.
|
||||
|
||||
**Validation moteur au 9 septembre 2026 :** les trois graines de référence
|
||||
passent les contrôles finaux. Les preuves cumulées démontrent un groupe de
|
||||
deux paliers reliés, un geyser actif et les décors souterrains demandés ; les
|
||||
absences locales et périmètres de mesure figurent dans [Validation](testing.md).
|
||||
Le build est réussi ; la release et le canal packwiz sont publiés. Les deux
|
||||
synchronisations isolées puis les deux passages dans la même instance Prism
|
||||
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.
|
||||
|
||||
| Thème | Contenus à découper plus tard |
|
||||
| --- | --- |
|
||||
| Distribution et apparence | packwiz, mods communautaires, resource packs, shaders, icône, chargement, crédits et attributions |
|
||||
| Expansion collective | deposit boxes, objectifs de production, ordinateur d'expansion, ouvertures persistantes |
|
||||
| Progression | capacités, XP, inventaire, prestiges débloquant des slots de factions, recettes et advancements, capes et familiers |
|
||||
| Économie | gemmes, mailbox, shop, offres horaires, bourse du navet (achat à la loterie du dimanche, revente au shop pendant la semaine), black market, catalogue, coffre-fort et drill |
|
||||
| Groupes et métiers | couleurs, équipes temporaires, factions et slots liés aux prestiges, cloches et bannières, villageois et copper golems |
|
||||
| Production et construction | convoyeurs, stockage, terminaux, ordinateur 8 bits, vein mining/building, plans et prefabs |
|
||||
| Dimensions | cavernes, Alpha, Backrooms, indoors, salles secrètes et récupération des objets perdus |
|
||||
| Faune et combats | zombies, fantômes, baleine, creepers, poules rares, Mooblooms, noms, armes et explosifs |
|
||||
| Mobilité | waystones, téléporteurs, ziplines, grappin, aéronefs, Magic Carpet et interactions physiques |
|
||||
| Temps et histoire | temps réel, calendrier, événements, loterie, étoiles, constellations, cube et sept boules |
|
||||
| Objets et surprises | caméra, œil d'araignée révélant les niveaux de lumière, disque blanc, chunky, particuleur, lucky blocks et lootboxes |
|
||||
| It's Alive ! | agriculture localisée, ustensiles, recettes, fermentation, affinage et pages secrètes |
|
||||
| Only Fun | interactions potaches, chanvre, anniversaires et intégration aux événements |
|
||||
| Master Key | permissions communes, configuration, diagnostic et réparation du serveur |
|
||||
|
||||
## Définition pratique d'un ticket terminé
|
||||
|
||||
Un ticket contient un résultat observable, un périmètre limité et des critères d'acceptation vérifiables. Sa conclusion indique le comportement livré, la version testée, les vérifications réellement exécutées et les limitations qui subsistent. Les nouveaux besoins découverts deviennent de nouveaux tickets plutôt que des ajouts implicites à tous les systèmes.
|
||||
@@ -0,0 +1,264 @@
|
||||
# Climats, expansion et ressources
|
||||
|
||||
Ce document fixe les décisions de conception pour la suite de Sanctuary. Le
|
||||
registre persistant des continents, leur déblocage et leur génération dans les
|
||||
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**.
|
||||
Le nord conduit vers le froid, le sud vers le chaud, l'ouest vers le sec et
|
||||
l'est vers l'humide. Les diagonales combinent ces tendances.
|
||||
|
||||
| Direction | Axe Minecraft depuis Sanctuary | Tendance climatique |
|
||||
| --- | --- | --- |
|
||||
| Nord | `−Z` | Froid |
|
||||
| Nord-est | `+X, −Z` | Froid et humide |
|
||||
| Est | `+X` | Humide |
|
||||
| Sud-est | `+X, +Z` | Chaud et humide |
|
||||
| Sud | `+Z` | Chaud |
|
||||
| Sud-ouest | `−X, +Z` | Chaud et sec |
|
||||
| Ouest | `−X` | Sec |
|
||||
| Nord-ouest | `−X, −Z` | Froid et sec |
|
||||
|
||||
Ces directions définissent des familles de climats, pas huit biomes uniformes.
|
||||
Le relief, l'altitude, les variations locales et la graine doivent conserver
|
||||
des géographies distinctes à l'intérieur de chaque continent. Les amplitudes
|
||||
de température et d'humidité, leur évolution avec la distance et les seuils
|
||||
de sélection des biomes feront partie du ticket de génération des continents.
|
||||
Les ressources et cultures futures, notamment celles d'It's Alive !, pourront
|
||||
ainsi demander l'exploration de plusieurs climats.
|
||||
|
||||
L’alpha.5 proposait cinq biomes tempérés dont Dappled Forest. L’alpha.6 réserve
|
||||
ce dernier aux futurs continents et donne à l’île une palette plus douce de
|
||||
prairies, prairies fleuries, bosquets, corniches moussues et zones rocheuses.
|
||||
La répartition tient compte de l’altitude, y compris dans les strates inférieures.
|
||||
La variation locale et l’humidité dépendent de la graine ; elles ne
|
||||
constituent pas encore le champ de recherche directionnel des continents.
|
||||
|
||||
L’alpha.7 densifie les forêts de chênes et de bouleaux en surface et donne
|
||||
aux corniches intérieures humides des chênes noirs et des champignons.
|
||||
Des champignons vanilla peuvent aussi se rencontrer en surface. Les
|
||||
clairières fleuries et les affleurements restent présents. Les essences rares
|
||||
et un éventuel arbre remarquable dépendent du terrain ; chaque graine n’est
|
||||
pas tenue de fournir tous les bois. Les minerais de départ peuvent affleurer,
|
||||
avec des petits filons et sans correction des stocks après comptage.
|
||||
|
||||
## Eau de surface et exploration
|
||||
|
||||
L'île de départ doit pouvoir accueillir naturellement des étangs et lacs de
|
||||
surface, des berges propices à la canne à sucre et, lorsque le relief s'y prête,
|
||||
des rivières. L'eau s'inscrit dans les creux et les chemins du terrain existant.
|
||||
Les bassins gardent un fond et des berges fermés. Une grande rivière reste
|
||||
facultative : elle doit suivre le relief et former un parcours lisible avec
|
||||
source, chute et bassin. Ses exutoires peuvent laisser couler l’eau dans le vide.
|
||||
Sans parcours suffisamment long, l’île conserve seulement ses petits bassins.
|
||||
L’alpha.6 livre cette variante à bassins seuls ; la grande rivière reste à développer.
|
||||
L’alpha.5 ajoute séparément quelques sources sortant d’une niche rocheuse
|
||||
naturelle : leurs cascades peuvent descendre dans le vide et servir à remonter
|
||||
verticalement. Cet écoulement est volontaire ; il ne doit pas entraîner des
|
||||
fuites sur toutes les rives.
|
||||
|
||||
Le contrat ne demande ni un bassin souterrain imposé pour obtenir un stock
|
||||
d'eau, ni une cuvette ajoutée systématiquement à une position fixe. Une surface
|
||||
d'eau doit reposer sur un fond naturel et disposer de berges cohérentes ; un
|
||||
volume rocheux fermé sous terre ne devient pas un lac de surface. Les plantations
|
||||
de canne à sucre doivent respecter leurs conditions de placement, sans modifier
|
||||
la carte pour atteindre une quantité prédéterminée.
|
||||
|
||||
Un étang, un lac, une rivière et un bassin souterrain sont donc des résultats
|
||||
différents à vérifier. Un prototype d'étangs ne valide pas automatiquement les
|
||||
rivières ou l'ensemble de l'hydrologie. Le détail effectivement livré à chaque
|
||||
version reste décrit dans [Génération](worldgen.md), avec ses limites.
|
||||
|
||||
## Observer les ressources après génération
|
||||
|
||||
La génération produit le monde ; l'économie apprend ensuite ce que le monde
|
||||
contient. On ne force pas un nombre de blocs de pierre, de bûches ou de minerais
|
||||
pour faire correspondre la géographie à un budget. Un relevé peut constater
|
||||
qu'une ressource est absente, rare ou abondante pour une graine donnée. Ce constat
|
||||
ne déclenche pas de remplissage ni de remplacement de blocs.
|
||||
|
||||
Le relevé initial servira de référence pour préparer l'économie et la progression
|
||||
collective. Il distingue les blocs présents, les ressources réellement accessibles
|
||||
aux joueurs, les ressources renouvelables et les objets obtenus en les récoltant.
|
||||
Un bloc de minerai n'est pas un nombre garanti d'objets : outils, enchantements,
|
||||
butins et transformations relèvent d'autres règles. Les blocs de bois ne comptent
|
||||
pas des arbres, et un comptage de blocs d'eau ne mesure pas un débit de rivière.
|
||||
|
||||
Une observation dans une petite zone reste une observation locale. On ne peut
|
||||
pas multiplier son résultat pour annoncer la quantité totale de l'île. Les
|
||||
rapports de continents devront conserver leur propre graine, version, emprise
|
||||
et état de génération, afin d'additionner seulement des régions connues et
|
||||
disjointes lorsque le besoin économique sera défini.
|
||||
|
||||
## Ressources de départ — depuis l’alpha.5
|
||||
|
||||
L’île vise les premières étapes de survie, les outils, l’enchantement et la
|
||||
redstone. Les sept minerais (charbon, fer, cuivre, or, redstone, lapis et diamant)
|
||||
utilisent des petites veines à des altitudes compatibles avec le terrain relevé.
|
||||
Le charbon et le fer ne reprennent plus leurs fréquences vanilla beaucoup trop
|
||||
généreuses dans cette géométrie. Une petite réserve de lave peut apparaître dans une niche rocheuse accessible
|
||||
pour les premières productions et l’accès au Nether. Le bois, la pierre, les animaux et les cultures
|
||||
renouvelables conservent leur fonctionnement Minecraft : ce réglage des minerais
|
||||
ne constitue pas une interdiction des grandes constructions.
|
||||
|
||||
Les placements restent probabilistes, sans quota de rattrapage après comptage.
|
||||
Une graine peut donc manquer d’une ressource ; la présence de tout le parcours
|
||||
Minecraft n’est pas garantie. Les structures donnant accès à l’End et leur lien
|
||||
avec l’expansion restent à concevoir.
|
||||
|
||||
## Relevé de développement disponible
|
||||
|
||||
`ResourceSurveyDiagnostics`, dans `src/gametest`, lit les chunks au statut
|
||||
`FULL`, après le terrain, les matériaux et les décorations. Il ne figure pas
|
||||
dans le JAR Sanctuary distribué et n'ajoute aucune commande joueur ou admin.
|
||||
La tâche repart d'un monde GameTest neuf, jamais d'une sauvegarde personnelle.
|
||||
|
||||
Le relevé standard inspecte **neuf chunks**, soit un carré de 48×48 blocs autour
|
||||
du chunk du spawn naturel, sur toute la hauteur de la dimension. Il prépare
|
||||
d'abord ces chunks et un halo voisin, puis compte exactement chaque cellule des
|
||||
palettes de leurs sections. La grille éloignée du framework est exclue et sa
|
||||
distance est vérifiée. Aucun objet posé, inventaire, contenu de coffre ou mob
|
||||
n'est inclus dans ces comptes de blocs.
|
||||
|
||||
```sh
|
||||
# Contrôle standard : neuf chunks inspectés, total local uniquement.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=sample --console=plain
|
||||
|
||||
# Relevé de toute l'enveloppe déclarée de Sanctuary, sur un monde jetable neuf.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
|
||||
|
||||
# Désactiver seulement le relevé pour un autre diagnostic.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=off --console=plain
|
||||
```
|
||||
|
||||
Les modes sont transmis à la propriété JVM de test
|
||||
`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-<capacité>-seed-<graine>-<mode>.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.
|
||||
|
||||
Il contient :
|
||||
|
||||
- La graine, la version Minecraft, le preset et les réglages de génération,
|
||||
ainsi que `generator.version = sanctuary@<version du mod>`.
|
||||
- Le spawn naturel, les bornes exactes en blocs et chunks, les hauteurs de la
|
||||
dimension et du bruit, les coordonnées de chaque chunk inspecté au statut
|
||||
`FULL`, les dates UTC et les durées de préparation et de comptage.
|
||||
- Le total de toutes les cellules, les cellules d'air, les blocs non vides,
|
||||
puis les comptes par identifiant de registre et par état complet, propriétés
|
||||
comprises, par exemple `minecraft:oak_log[axis=y]`.
|
||||
- Des agrégats explicites pour la pierre, les bûches, les feuilles, les minerais
|
||||
de charbon, fer, cuivre, or, redstone, lapis, diamant et émeraude, l'eau, la
|
||||
lave, le sable, le gravier, l'argile, la canne à sucre et les sols.
|
||||
- La complétude dans les bornes demandées, la couverture ou non de l'enveloppe
|
||||
déclarée de l'île et l'absence d'extrapolation. Aucun mode ne prétend inventorier
|
||||
tout le monde Minecraft, ses autres dimensions ou ses futures expansions.
|
||||
|
||||
Les variantes ordinaires et deepslate des minerais sont détaillées. Les agrégats
|
||||
fondés sur des tags indiquent leur définition et les membres observés. Certains
|
||||
groupes se recouvrent, comme `stone` et `base_stone_overworld`, ou les blocs
|
||||
d'eau et les blocs contenant un état d'eau : ils ne doivent pas être additionnés.
|
||||
Les sommes des identifiants et des états doivent chacune être exactement égales
|
||||
au volume inspecté, y compris l'air ; une incohérence fait échouer le GameTest.
|
||||
|
||||
Ce rapport est un instantané après génération, avec l'heure du jeu observée.
|
||||
Il ne prédit pas les pousses, les récoltes, les pertes ni la production future.
|
||||
Le format JSON est versionné pour l'outil de test ; ce n'est pas encore un
|
||||
format de sauvegarde serveur ou un registre persistant de l'économie.
|
||||
|
||||
## Référence mesurée — graine 0, alpha.3
|
||||
|
||||
Le **8 septembre 2026**, un relevé complet de l'enveloppe déclarée a été effectué
|
||||
sur un monde neuf de graine **0**, avec `sanctuary@0.1.0-alpha.3` et les réglages
|
||||
`sanctuary:sanctuary_384`. Il couvre **1 444 chunks FULL**, de **−304 inclus à
|
||||
304 exclu en X et Z**, et de **0 inclus à 384 exclu en Y**. Le volume inspecté
|
||||
est donc exactement **608 × 608 × 384 = 141 950 976 cellules**.
|
||||
|
||||
| Ressource observée | Nombre de blocs |
|
||||
| --- | ---: |
|
||||
| Pierre (`minecraft:stone`) | 7 355 184 |
|
||||
| Bûches, tag `minecraft:logs` | 10 946 |
|
||||
| Minerai de charbon | 101 399 |
|
||||
| Minerai de fer | 67 198 |
|
||||
| Minerai de cuivre | 2 352 |
|
||||
| Minerai de diamant | 6 |
|
||||
| Minerais d'or, redstone, lapis et émeraude | 0 chacun |
|
||||
| Gravier | 121 044 |
|
||||
| Terre et blocs d'herbe | 728 630 |
|
||||
| Feuilles | 117 735 |
|
||||
| Eau, y compris les blocs contenant de l'eau | 0 |
|
||||
| Canne à sucre | 0 |
|
||||
| Sable, argile et lave | 0 chacun |
|
||||
|
||||
Les bûches comprennent **8 765 bûches de chêne** et **2 181 de bouleau**.
|
||||
Les minerais présents sont ici leurs variantes ordinaires ; aucune variante
|
||||
deepslate n'a été observée. Toutes catégories confondues, le relevé compte
|
||||
**8 584 514 blocs non vides** et **133 366 462 cellules d'air**. Ce sont les
|
||||
quantités observées dans cette graine et cette version, sans quota ni correction
|
||||
du terrain. Les zéros d'eau et de canne à sucre confirment que l'hydrologie
|
||||
demandée reste à réaliser ; ils ne constituent pas une cible à conserver.
|
||||
|
||||
La source détaillée est le fichier local non versionné
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/resource-survey-seed-0-island.json`,
|
||||
terminé à **11:34:08 UTC**. Une lecture indépendante du JSON a confirmé les
|
||||
1 444 coordonnées de chunks distinctes, les sommes par chunk, les agrégats et
|
||||
l'égalité exacte entre le volume, le compte par identifiant et le compte par
|
||||
état complet. Les huit tests requis du serveur ont réussi pendant cette exécution.
|
||||
|
||||
La préparation des chunks a pris **782 659 ms**, soit **13 min 2,659 s** ; le
|
||||
comptage et la préparation des données à sérialiser ont pris **135 ms**. Cette
|
||||
mesure locale a été effectuée sur un **Apple M1 avec 8 Go de mémoire**, avec un
|
||||
essai client concurrent pendant environ 23 secondes. Elle décrit une machine
|
||||
chargée et cette exécution, pas une performance garantie sur d'autres machines.
|
||||
Le temps de préparation est distinct du coût du comptage par palettes.
|
||||
|
||||
## Contrat du prochain ticket d'expansion
|
||||
|
||||
Avant toute activation de continent, enregistrer côté serveur son identifiant,
|
||||
sa direction parmi les huit, son centre, son emprise, sa graine dérivée, sa
|
||||
version de génération, son profil climatique et son état de déblocage.
|
||||
Le relevé des ressources intervient après la génération effective de sa région,
|
||||
avec une complétude explicite, indépendamment des contributions qui l'ont ouverte.
|
||||
|
||||
Les zones déjà explorées dans le vide peuvent contenir des chunks sauvegardés
|
||||
ou des constructions. L'activation exige donc un contrat de réservation ou de
|
||||
matérialisation qui les protège ; elle ne peut pas régénérer silencieusement
|
||||
les chunks existants. Les règles de contribution, distances, tailles,
|
||||
réservations, migrations et prix ne sont pas décidées par ce diagnostic.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Réutiliser la génération Sanctuary 26.2
|
||||
|
||||
Audit de source du 8 septembre 2026, effectué en lecture seule dans le dépôt voisin
|
||||
`../26.2`, à la révision `da61a3b1ec4fe161a9b5ffaab0fa3111238a35b6`
|
||||
(pack `26.2.0-alpha.242`). Les chemins ci-dessous sont relatifs à ce dépôt historique.
|
||||
Ce document décrit ce qui existe dans 26.2 ; il ne prétend pas que ces éléments sont
|
||||
déjà portés dans Sanctuary Beta.
|
||||
|
||||
## Conclusion pour le premier lot
|
||||
|
||||
La base de terrain est réutilisable : du bruit Minecraft lié à la seed, une enveloppe
|
||||
verticale d'îles flottantes, puis des masques de densité. En revanche, la génération
|
||||
26.2 n'est pas un monde initialement vide avec des continents déverrouillables.
|
||||
L'archipel extérieur est généré automatiquement au-delà d'un petit anneau de vide.
|
||||
Le premier lot doit donc borner explicitement l'île centrale et garantir un départ
|
||||
sûr, avant de construire le système persistant d'expansion collective.
|
||||
|
||||
## Carte des sources récupérables
|
||||
|
||||
| Élément | Source 26.2 | Réutilisation |
|
||||
| --- | --- | --- |
|
||||
| Bruit de terrain | `sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/base_3d_noise.json` | `minecraft:old_blended_noise`, dépendant de la seed ; aucun bloc propre au mod. |
|
||||
| Enveloppe flottante | `sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/floating_archipelago.json` | Gradients verticaux combinés au bruit précédent. |
|
||||
| Composition finale | `sanctuary/src/main/resources/data/sanctuary/worldgen/density_function/final_density.json` | Référence pour l'interpolation et le mélange ; remplacer le comportement extérieur infini. |
|
||||
| Masques et relief | `sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/SanctuaryDensityFunctions.java` | `AnnularVoidMask`, `TectonicMassifDensity`, `TectonicVoidDensity` ; séparer les fonctions utiles du code inactif. |
|
||||
| Réglages de dimension | `sanctuary/src/main/resources/data/sanctuary/dimension_type/sanctuary.json` | Plage verticale Y=0–383, lumière et ciel ; vérifier les codecs de la version cible. |
|
||||
| Réglages de bruit | `sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary.json` | Air comme fluide par défaut, aquifères désactivés, niveau marin -64 ; ne pas importer ses règles de surface en bloc. |
|
||||
| Preset | `sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json` | Générateur `minecraft:noise`, biomes `minecraft:multi_noise` ; garder seulement les dimensions nécessaires au lot. |
|
||||
| Recherche du spawn | `sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/world/SanctuarySpawn.java` | Recherche d'un sol naturel autour de l'origine ; corriger les limites décrites plus bas. |
|
||||
| Lacs | `sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/SanctuaryWaterLakeFeature.java` | Extension de `LakeFeature`, avec quelques cannes à sucre ; utilisable dans un lot hydrologie. |
|
||||
| Ravins | `sanctuary/src/main/resources/data/sanctuary/worldgen/configured_carver/tectonic_ravine.json` | Configuration de carver ; revalider sur des îles finies. |
|
||||
| Prévisualisation | `sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/worldgen/SanctuaryBiomePreviewScreen.java` | Référence ultérieure pour visualiser la seed et la distribution des biomes. |
|
||||
|
||||
Le graphe actif 26.2 est approximativement :
|
||||
|
||||
```text
|
||||
old_blended_noise -> floating_archipelago + central_halo_mask
|
||||
|
|
||||
max(tectonic_massifs)
|
||||
|
|
||||
min(void_basins)
|
||||
|
|
||||
blend_density -> interpolated -> squeeze
|
||||
```
|
||||
|
||||
`IslandSpawnDensity` et `RadialVoidMask` restent enregistrés en Java mais ne font
|
||||
plus partie du graphe actif. L'ancienne forme sculptée utilise des bruits de hachage
|
||||
sans seed de monde passée à la fonction ; elle n'est pas un substitut fidèle au
|
||||
terrain procédural actuel.
|
||||
|
||||
## Terramix et Black Desert
|
||||
|
||||
Dans ce dépôt, **Terramix est le nom du catalogue natif de 100 biomes d'Another
|
||||
World**, répartis en dix familles de dix. Ce n'est pas une dépendance Fabric externe
|
||||
à ajouter automatiquement au pack.
|
||||
|
||||
- Catalogue auteur : `anotherworld/src/main/resources/data/anotherworld/sanctuary_biome_catalog.json`.
|
||||
- Lecture et familles climatiques : `anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/SanctuaryBiomeCatalog.java`.
|
||||
- Fabrication des biomes, végétations et géologies : `anotherworld/build.gradle`,
|
||||
notamment la section commençant par « Terramix is a native Sanctuary catalogue ».
|
||||
- Mélange avec les biomes vanilla :
|
||||
`anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java`.
|
||||
- Black Desert est un biome historique distinct, `anotherworld:black_desert`, avec
|
||||
du sable noir et une géologie sombre. Ses règles de surface se trouvent aussi dans
|
||||
`sanctuary/.../worldgen/noise_settings/sanctuary.json`.
|
||||
|
||||
Le port du seul JSON de bruit complet échouerait sans les blocs, tags et biomes
|
||||
`anotherworld:*` qu'il référence. Préférer d'abord une surface et des biomes vanilla,
|
||||
puis un lot dédié qui préserve les identifiants du catalogue. Le mixin climatique
|
||||
historique modifie le preset Overworld global : son périmètre doit être redéfini
|
||||
pour éviter d'affecter d'autres mondes par simple installation du mod.
|
||||
|
||||
Le changelog indique que Terralith a été retiré en alpha.143 et remplacé par le
|
||||
catalogue natif en alpha.145. Ne pas confondre ces deux noms ni restaurer les anciennes
|
||||
ressources Terralith lors d'un port de Terramix.
|
||||
|
||||
## Limites et défauts à traiter
|
||||
|
||||
1. **L'extérieur n'est pas vide.** Le masque annulaire actif protège un rayon nominal
|
||||
de 96 blocs, entre dans le halo sur 160 blocs et quitte celui-ci à partir de
|
||||
256 blocs sur 96 blocs, avec une distorsion de 32 blocs. Il retrouve donc
|
||||
l'archipel normal vers 352 blocs nominalement. Ce masque ne borne pas un monde.
|
||||
2. **Aucune activation de continents trouvée.** La recherche des sources Java
|
||||
Sanctuary, Another World et Master Key n'a trouvé ni registre de continents
|
||||
déverrouillés, ni commande d'expansion directionnelle. Les « continents » du
|
||||
changelog désignent le relief tectonique naturel. Un format de sauvegarde,
|
||||
des limites de taille et une politique pour les chunks déjà explorés restent
|
||||
à concevoir.
|
||||
3. **Spawn non garanti.** `SanctuarySpawn` ne cherche que dans un rayon de trois
|
||||
chunks autour de l'origine. En cas d'échec il conserve la suggestion vanilla,
|
||||
sans créer de sol sûr. Il sélectionne à nouveau le spawn global à chaque
|
||||
`SERVER_STARTED`, ce qui peut écraser le choix d'un administrateur.
|
||||
4. **Reliefs extérieurs très éloignés.** Les massifs et bassins de vide actifs ont
|
||||
`protected_radius=10000` et `protection_fade=2000`. Les copier autour d'une petite
|
||||
île ne produit donc pas de montagne proche du départ.
|
||||
5. **L'eau n'est pas une hydrologie continentale.** Le réglage emploie de l'air et
|
||||
désactive les aquifères. Les lacs sont des features ponctuelles. Aucun réseau
|
||||
de rivières flottantes ou d'océans suspendus contrôlés n'a été identifié dans
|
||||
les sources examinées.
|
||||
6. **La densité seule n'assure pas le vide final.** Les carvers, décorations et
|
||||
structures constituent des étapes distinctes. Vérifier les chunks terminés,
|
||||
y compris aux frontières de l'île, et borner aussi les placements qui pourraient
|
||||
traverser la limite.
|
||||
7. **Ordre des features sensible.** Le changelog alpha.105 rapporte un blocage
|
||||
« Loading terrain » corrigé en ordonnant la végétation lush de plafond avant
|
||||
celle au sol. Revalider les dépendances entre features lors du port des biomes.
|
||||
|
||||
Ces constats sont issus de la lecture du code et du changelog. Aucun serveur 26.2
|
||||
n'a été lancé ni modifié pendant cet audit.
|
||||
|
||||
## Historique exploitable
|
||||
|
||||
L'historique Git local n'est pas shallow, mais commence par l'import
|
||||
`94644a2` (« Import Sanctuary 26.2 collaboration workspace »). Le générateur principal
|
||||
actuel est déjà présent dans cet import : les étapes plus anciennes ne correspondent
|
||||
donc pas à des commits individuels disponibles dans ce dépôt. Leurs descriptions
|
||||
restent dans `CHANGELOG.md` et `pack/migrations/`.
|
||||
|
||||
| Repère | Intérêt |
|
||||
| --- | --- |
|
||||
| Alpha.104–106 | Introduction des massifs, lacs, ravins et hauteur de dimension 384. |
|
||||
| Alpha.108–111 | Expériences de relief extrême, puis réduction des plateaux et pentes. |
|
||||
| Alpha.116–120 | Protection centrale, passage à l'île procédurale et réduction du halo. |
|
||||
| Alpha.143–148 | Retrait de Terralith, Terramix natif, correction des transitions et prévisualisation. |
|
||||
| Alpha.189 | Provinces climatiques à deux échelles, points MultiNoise vanilla conservés. |
|
||||
| Commit `9c09664` | Port de l'île Indev finie pour la dimension Alpha ; sujet distinct du monde Sanctuary. |
|
||||
|
||||
Les profils extrêmes sont réellement présents dans
|
||||
`sanctuary/design/worldgen/alpha108_mega_tectonics/` et
|
||||
`sanctuary/design/worldgen/alpha110_steep_massifs/`. Les chemins `backups/` cités par
|
||||
le changelog sont des repères historiques ; leur disponibilité n'a pas été vérifiée.
|
||||
|
||||
## Provenance et licences déclarées
|
||||
|
||||
Le `LICENSE` racine et le manifeste Fabric Sanctuary déclarent
|
||||
`GPL-3.0-or-later`. Garder cette provenance avec les sources effectivement reprises.
|
||||
La dimension Alpha possède en plus une notice BSD 3-Clause pour l'algorithme Indev
|
||||
dans `sanctuary/THIRD_PARTY_NOTICES.md` ; elle concerne ce port spécifique.
|
||||
|
||||
`anotherworld/THIRD_PARTY_NOTICES.md` signale des textures Flower Cows dont la
|
||||
permission de redistribution publique est à clarifier. Elles ne sont pas nécessaires
|
||||
au générateur minimal et ne doivent pas être importées avec un lot de terrain.
|
||||
|
||||
## Lots proposés après l'initialisation
|
||||
|
||||
1. Île principale finie dépendante de la seed, vide extérieur, spawn durable et sûr,
|
||||
preset explicite ; validation sur plusieurs seeds et redémarrage serveur.
|
||||
2. Modèle persistant de continents : identifiant, seed, centre, orientation, taille,
|
||||
état verrouillé/déverrouillé et version de génération ; définir le traitement
|
||||
des chunks vides déjà visités avant toute commande d'activation.
|
||||
3. Hydrologie fermée et reliefs : bassins, lacs, rivières, cavités et ravins ; vérifier
|
||||
les bordures de chunks, l'écoulement et la conservation des constructions.
|
||||
4. Terramix et Black Desert avec leur géologie, leurs ressources et une distribution
|
||||
climatique mesurable ; puis structures procédurales et continents thématiques.
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Distribution packwiz et Prism
|
||||
|
||||
Sanctuary Beta utilise une seule instance Prism, synchronisée avant chaque
|
||||
lancement par packwiz. Le canal reste à l'adresse :
|
||||
|
||||
```text
|
||||
https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
|
||||
```
|
||||
|
||||
Les sources sont sur la branche de travail du ticket. La branche `packwiz`
|
||||
contient uniquement les manifestes de distribution produits par le build ; les
|
||||
JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
|
||||
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
|
||||
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
|
||||
Prism sont publiés et leurs téléchargements publics vérifiés. Le canal stable
|
||||
pointe sur cette version. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
150149852bf5afd0bb3096e6cd8953e69b23634859c10f294088a44aca538aff
|
||||
```
|
||||
|
||||
Le véritable installateur packwiz a réussi deux fois dans un dossier de
|
||||
développement neuf, puis deux fois dans l’instance Sanctuary Beta existante.
|
||||
Le second passage laisse les fichiers gérés identiques. Un seul JAR Sanctuary
|
||||
alpha.9 est actif ; les 260 fichiers personnels et réglages suivis conservent
|
||||
leurs hashes. L’ancien JAR et les manifestes locaux sont sauvegardés hors de
|
||||
`mods/`. Aucun monde existant n’a été ouvert, converti ou régénéré.
|
||||
|
||||
Après les vérifications, le commit et le push de la branche du ticket, le script
|
||||
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.10.mrpack
|
||||
```
|
||||
|
||||
Les options sont facultatives. Ce script **publie** sur le Git configuré dans
|
||||
`origin` ; il exige un arbre propre, un HEAD déjà poussé et un JAR dont la version
|
||||
correspond aux sources. Il utilise les identifiants du gestionnaire Git en
|
||||
mémoire, refuse de remplacer une release ou un artefact différent portant la
|
||||
même version, puis vérifie les téléchargements publics et les métadonnées du
|
||||
canal. Il préserve l'historique de `packwiz` et n'utilise pas de push forcé. Il
|
||||
ne lance pas les tests et ne modifie aucune instance de jeu : les vérifications
|
||||
de livraison restent à exécuter avant cette commande.
|
||||
|
||||
La procédure complète, également utilisable manuellement :
|
||||
|
||||
1. Modifier ensemble les versions de `gradle.properties` et `packwiz/pack.toml`.
|
||||
2. Exécuter `./gradlew check build assemblePack` et les vérifications du ticket.
|
||||
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.10/sanctuary-0.1.0-alpha.10.jar
|
||||
```
|
||||
|
||||
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
|
||||
commit et joindre le JAR construit, sans remplacer les artefacts d'une release
|
||||
antérieure. Vérifier que le téléchargement public possède le SHA-256 indiqué
|
||||
dans `build/packwiz-release/mods/sanctuary.pw.toml`.
|
||||
5. Publier le contenu de `build/packwiz-release/` à la racine de la branche
|
||||
`packwiz` dans un seul commit, puis vérifier les fichiers et hashes depuis
|
||||
l'URL publique. Préserver l'historique de ce canal et ne jamais y copier le
|
||||
dossier `build/packwiz/`, qui contient le JAR de développement.
|
||||
6. Tester une installation puis une seconde synchronisation avec le véritable
|
||||
`packwiz-installer` dans un dossier de développement neuf. Le second passage
|
||||
doit laisser le pack à jour sans dupliquer Sanctuary.
|
||||
|
||||
`mods/sanctuary.pw.toml` conserve le même chemin entre versions ; seul son nom de
|
||||
JAR, son URL et son hash changent. Packwiz peut ainsi retirer l'ancien JAR qu'il
|
||||
gère quand il installe le nouveau. Une première installation depuis un `.mrpack`
|
||||
n'a pas encore ce suivi : voir la procédure ci-dessous.
|
||||
|
||||
Un ZIP d'amorçage Prism se prépare avec :
|
||||
|
||||
```sh
|
||||
python3 scripts/pack.py prism https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
|
||||
```
|
||||
|
||||
Il contient les versions exactes Minecraft/Fabric, la commande de lancement et
|
||||
`packwiz-installer-bootstrap` v0.0.3. Le script vérifie le SHA-256 du bootstrap
|
||||
officiel avant d'écrire `build/Sanctuary-Prism-auto-update.zip`. Ce ZIP est réservé
|
||||
aux nouvelles installations ; les instances déjà branchées sur packwiz suivent
|
||||
le canal sans être réimportées.
|
||||
|
||||
## Raccorder l'instance Prism existante
|
||||
|
||||
Fermer Minecraft avant de modifier ses mods. Fermer également Prism avant de
|
||||
modifier directement `instance.cfg`, pour éviter que son état en mémoire écrase
|
||||
les changements. Dans l'instance Sanctuary Beta :
|
||||
|
||||
1. Sauvegarder `instance.cfg` et les anciens JAR Sanctuary dans un dossier de
|
||||
sauvegarde hors de `mods/`. Vérifier que l'instance utilise Minecraft
|
||||
`26.3-pre-2` et Fabric `0.19.5` ; ne pas convertir une instance 26.2.
|
||||
2. Mettre le bootstrap issu du ZIP dans son dossier `minecraft/` (ou `.minecraft/`
|
||||
selon l'installation).
|
||||
3. Dans **Modifier → Paramètres → Commandes personnalisées**, activer les
|
||||
commandes et renseigner la commande avant lancement ci-dessous.
|
||||
4. Pour une instance issue d'un ancien `.mrpack`, déplacer seulement l'ancien
|
||||
JAR Sanctuary non géré dans la sauvegarde préparée, puis faire la première
|
||||
synchronisation packwiz. Conserver les autres mods et données personnelles.
|
||||
Si la synchronisation échoue, remettre ce JAR et les réglages sauvegardés.
|
||||
5. Vérifier qu'il reste un seul mod Sanctuary actif et que `packwiz.json` est
|
||||
présent dans le dossier Minecraft. Les prochaines mises à jour sont suivies
|
||||
par ce manifeste local : ne pas le supprimer entre les versions.
|
||||
|
||||
Commande à coller dans l'interface Prism :
|
||||
|
||||
```text
|
||||
"$INST_JAVA" -jar "$INST_MC_DIR/packwiz-installer-bootstrap.jar" --bootstrap-main-jar "$INST_MC_DIR/packwiz-installer.jar" --pack-folder "$INST_MC_DIR" --multimc-folder "$INST_DIR" https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
|
||||
```
|
||||
|
||||
Une modification directe de `instance.cfg` doit échapper les guillemets au format
|
||||
Qt INI (`\"`) et activer `OverrideCommands=true`. Ces clés et
|
||||
`PreLaunchCommand` doivent être dans la section **`[General]`**, jamais ajoutées
|
||||
après la section `[UI]`. Le ZIP préparé le fait déjà. Rouvrir les commandes
|
||||
personnalisées dans Prism pour vérifier que la commande est effectivement lue.
|
||||
Une instance importée d'un `.mrpack` peut être marquée `ManagedPack=true` :
|
||||
dès que packwiz en assure les mises à jour, utiliser `ManagedPack=false` pour
|
||||
éviter deux mécanismes de gestion sur le même pack.
|
||||
|
||||
## Données locales et mondes
|
||||
|
||||
Le pack livré possède uniquement les fichiers déclarés dans son index. Il ne
|
||||
déclare ni `saves/`, ni `options.txt`, `servers.dat`, `screenshots/`, ni les packs
|
||||
graphiques personnels. L'installation de packwiz et une mise à jour des mods
|
||||
n'autorisent pas à supprimer ou régénérer un monde.
|
||||
|
||||
La hauteur de 384 blocs, le dessous sculpté et le ciel de l'alpha.3 se testent
|
||||
dans un **nouveau monde** avec le preset **Sanctuary**. Les anciens mondes gardent
|
||||
leurs identifiants de génération et leurs hauteurs. L’hydrologie de l’alpha.4
|
||||
nécessite elle aussi un nouveau monde Sanctuary ; la mise à jour de l’instance
|
||||
n’ajoute pas d’eau aux sauvegardes existantes. Les nouvelles plages, cascades,
|
||||
biomes et petits filons de l’alpha.5 utilisent à leur tour un identifiant
|
||||
séparé : créer un nouveau monde Sanctuary pour les découvrir. Les détails des versions de génération sont dans
|
||||
[Génération](worldgen.md). Les changements de version Minecraft ou de format
|
||||
de sauvegarde demandent leur propre ticket et contrat de migration.
|
||||
|
||||
L’alpha.6 utilise à son tour `sanctuary:sanctuary_layered` pour ses biomes par
|
||||
altitude et sa nouvelle hydrologie. Créer un nouveau monde Sanctuary ; les
|
||||
sauvegardes alpha.5 conservent notamment leur palette avec Dappled Forest.
|
||||
|
||||
L’alpha.7 sélectionne `sanctuary:sanctuary_woodland` pour ses forêts, ses
|
||||
roches variées et ses minerais affleurants. Les mondes alpha.6 gardent
|
||||
`sanctuary:sanctuary_layered` ; aucune régénération de chunks n’est effectuée.
|
||||
|
||||
L’alpha.8 sélectionne `sanctuary:sanctuary_rift` pour les failles, les grandes
|
||||
rivières, les champignons géants et la géologie des profondeurs. Créer un
|
||||
nouveau monde Sanctuary pour les découvrir. La même instance Prism reçoit
|
||||
le nouveau JAR par le canal stable ; ses sauvegardes et réglages sont conservés.
|
||||
Les mondes alpha.7 et antérieurs gardent leurs paramètres de génération.
|
||||
|
||||
L’alpha.9 utilise `sanctuary:sanctuary_cavern` pour les cavités luxuriantes,
|
||||
les géodes, les geysers et les terrasses d’eau. Un **nouveau monde Sanctuary**
|
||||
est nécessaire pour essayer cet incrément. Les mondes
|
||||
alpha.8 conservent `sanctuary:sanctuary_rift` ; leur contenu n’est pas retouché
|
||||
par une mise à jour de la même instance Prism. Les anciennes sauvegardes et
|
||||
les réglages 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).
|
||||
+936
@@ -0,0 +1,936 @@
|
||||
# Vérifier Sanctuary
|
||||
|
||||
Utiliser Java 25 et les dépendances épinglées dans `gradle.properties`.
|
||||
|
||||
```sh
|
||||
./gradlew check build assemblePack --console=plain
|
||||
```
|
||||
|
||||
`check` inclut les tests GameTest. Pour un diagnostic ciblé de la génération seule,
|
||||
utiliser `./gradlew :sanctuary:runGameTest --console=plain`.
|
||||
|
||||
Pour générer de vrais chunks et vérifier le spawn sur d'autres graines :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestSeed=42 --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestSeed=8675309 --console=plain
|
||||
```
|
||||
|
||||
La propriété ne s'applique qu'au mod de test, jamais au mod distribué ni à une
|
||||
sauvegarde de joueur. Chaque commande repart d'un monde de développement neuf.
|
||||
|
||||
## Tests de forme
|
||||
|
||||
La tâche `:sanctuary:worldgenSmoke`, incluse dans `check`, vérifie les invariants de
|
||||
l'enveloppe de l'île : absence de noyau imposé, conservation des trous naturels,
|
||||
sculpture du bord, limite extérieure, hauteur et stabilité de la fonction
|
||||
géométrique. Elle complète les tests en jeu ; elle ne charge pas à elle
|
||||
seule les ressources de génération de Minecraft.
|
||||
|
||||
La tâche `:sanctuary:hydrologySmoke`, également incluse dans `check`, vérifie
|
||||
le planificateur sur des reliefs contrôlés : reproductibilité, variation de la
|
||||
graine, limites de profondeur et d’excavation, support naturel, parois, ruisseau
|
||||
sur plateau compatible et partition indépendante de l’ordre des chunks. Les
|
||||
scénarios vides, percés ou trop minces ne reçoivent aucun bassin de secours.
|
||||
|
||||
Les tâches `:sanctuary:naturalHydrologySmoke` et `:sanctuary:starterLavaSmoke`
|
||||
complètent ces contrôles pour l’alpha.5 : dépôts de trois à cinq couches sur
|
||||
support naturel, terrasses peu creusées, niches de source, parcours de cascade
|
||||
et petite réserve de lave avec accès sec. Elles vérifient le déterminisme et
|
||||
l’absence de remplissage de secours lorsque la géométrie ne convient pas.
|
||||
|
||||
## Tests dans le moteur Minecraft
|
||||
|
||||
`runGameTest` démarre le serveur de test headless officiel de Fabric dans
|
||||
`mods/sanctuary/build/run/gameTest/`. La tâche `cleanGameTestWorld` supprime uniquement
|
||||
son monde jetable avant chaque exécution afin de générer des chunks neufs.
|
||||
Il ne déploie rien dans un serveur ou une installation de jeu personnels.
|
||||
La configuration garde `eula=false` : aucun fichier d'acceptation n'est écrit par
|
||||
Loom. Fabric dispose d'un chemin de démarrage propre à ses tests automatisés.
|
||||
|
||||
Le source set `gametest` est un mod de test séparé, exclu du JAR Sanctuary distribué.
|
||||
Deux adaptations y sont nécessaires, vérifiées contre les classes Minecraft
|
||||
`26.3-pre-2` :
|
||||
|
||||
- `GameTestServer` sélectionne normalement `minecraft:flat_all_dimensions`.
|
||||
Le mixin de test sélectionne directement le preset de production
|
||||
`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
|
||||
à la propriété JVM `sanctuary.test.seed` utilisée uniquement dans ce mixin.
|
||||
|
||||
Le framework déplace aussi le spawn vers une grille de tests située loin du centre.
|
||||
Le test capture donc le spawn juste avant ce déplacement, après l'initialisation
|
||||
normale du monde, et examine les coordonnées absolues de l'île.
|
||||
|
||||
Les scénarios suivants couvrent le socle et les générations historiques ;
|
||||
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
|
||||
blocs et deux blocs libres et secs pour les graines de régression.
|
||||
2. Douze chunks entièrement générés sont vides dans les quatre directions, juste
|
||||
après l'enveloppe de décoration puis à environ 512 et 4 096 blocs. Cela couvre
|
||||
notamment l'ancien retour automatique de l'archipel au loin.
|
||||
3. La densité du datapack réellement chargé, compilée par `RandomState`, est
|
||||
reproductible pour la seed `0` et change pour la seed `8675309`.
|
||||
4. La densité compilée des graines `0`, `42` et `8675309` ne crée aucune matière
|
||||
là où le terrain source est vide. Le relief supérieur reste celui de l'alpha.2
|
||||
translaté de 64 blocs et le dessous varie avec le terrain et les bruits.
|
||||
5. Le nouveau monde et son bruit utilisent bien 384 blocs, à partir de Y=0,
|
||||
tandis que les anciens paramètres restent à 256 blocs. Les nuages sont
|
||||
au-dessus de l'île, à Y=352,33.
|
||||
6. Aucun plancher solide n’apparaît sous l’île, à Y=0..15. Dans les générations
|
||||
alpha.5 à alpha.7, seules les eaux des cascades déclarées peuvent traverser cette marge.
|
||||
7. Le relevé après génération compte exactement tous les états des blocs des
|
||||
chunks demandés, avec des totaux cohérents et une complétude explicite.
|
||||
Voir [Expansion et ressources](expansion.md) pour le mode complet facultatif.
|
||||
8. Le plan hydrologique se retrouve dans les vrais chunks après décoration :
|
||||
profils épais de sédiments, bassins retenus, raccords entre chunks, canne à
|
||||
sucre valide, niches rocheuses et descente réelle des cascades. La palette
|
||||
active et les minerais sont inspectés sur le terrain. Une réserve de lave
|
||||
présente conserve ses 18 sources, ses supports et son accès après simulation.
|
||||
9. Les anciennes clés gardent leur traitement d’origine. Seuls les nouveaux
|
||||
paramètres activent le traitement ajouté pour leur génération.
|
||||
|
||||
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
|
||||
libres. Elle ne pose aucun bloc. Une graine pathologique sans emplacement sûr
|
||||
échantillonné produit une erreur explicite, sans plate-forme de secours.
|
||||
|
||||
Le passage avec la graine de serveur `0` produit aussi, pour les trois graines,
|
||||
des PNG et CSV dans `mods/sanctuary/build/run/gameTest/diagnostics/` :
|
||||
|
||||
- `island-density-seed-<graine>.png` : vue de dessus et coupes centrales X/Z,
|
||||
avant/après, calculées avec les fonctions de densité réellement compilées.
|
||||
La colonne « alpha.2 » utilise ses anciennes ressources conservées ; la colonne
|
||||
de droite utilise la densité alpha.3, sur toute la hauteur Y=0..383.
|
||||
- `island-heightmap-seed-<graine>.csv` : coordonnées et hauteurs échantillonnées
|
||||
des deux versions (`-1` indique une colonne vide dans cet échantillon).
|
||||
|
||||
La vue de dessus échantillonne tous les quatre blocs en X, Y et Z. Les coupes
|
||||
échantillonnent tous les deux blocs horizontalement et chaque bloc en hauteur.
|
||||
Ces diagnostics montrent les volumes ; ils n'affichent pas les arbres, matériaux,
|
||||
fluides ou structures et ne remplacent pas un essai visuel dans le client.
|
||||
|
||||
Le chargement du serveur valide également les codecs et les références des
|
||||
registres de biomes, densités, réglages et presets. Un échec de chargement ou un
|
||||
test obligatoire en échec doit faire échouer la tâche Gradle.
|
||||
|
||||
Le framework et les hooks de test sont spécifiques à la version épinglée. Lors
|
||||
d'une mise à jour Minecraft, vérifier ces hooks avant de conclure que les tests
|
||||
exercent toujours le preset de production. La vérification explicite du générateur
|
||||
dans le premier test empêche un résultat positif sur un simple monde plat.
|
||||
|
||||
Référence du workflow : [tests automatiques Fabric](https://docs.fabricmc.net/develop/automatic-testing).
|
||||
Les signatures propres à `26.3-pre-2` ont été vérifiées dans les dépendances locales,
|
||||
car la documentation publiée vise actuellement `26.2`.
|
||||
|
||||
## Diagnostic hydrologique
|
||||
|
||||
Les essais hydrologiques demandent les chunks contenant tous les sites du plan
|
||||
et un halo d’un chunk. Ils inspectent des chunks `FULL`, avec minerais, arbres
|
||||
et plantes déjà générés, et comparent tous les blocs d’eau observés au volume
|
||||
annoncé. Leur carte montre les vrais blocs de surface ; le feuillage et les
|
||||
troncs sont masqués pour lire les berges. Il ne s’agit pas d’une capture du jeu.
|
||||
|
||||
Pour l’alpha.5, les chunks de l’île sont maintenus actifs, puis le test attend
|
||||
`ServerLevel.isPositionTickingWithEntitiesLoaded` sur chaque chunk de fluide.
|
||||
Ce prédicat, réellement utilisé par le planificateur des ticks en 26.3, exige
|
||||
à la fois le statut de simulation et le chargement des entités. `FULL` et
|
||||
`shouldTickBlocksAt` seuls ne prouvent pas que les ticks programmés s’exécutent.
|
||||
Un témoin d’eau temporaire est vérifié dans chaque chunk de source et de lave,
|
||||
puis retiré entièrement. La simulation des sites dure **1 800 ticks** après cette
|
||||
activation. La graine 0 doit notamment démontrer une cascade qui atteint `Y=0`.
|
||||
|
||||
Les fichiers `natural-hydrology-seed-<graine>-generated.json/.png` et
|
||||
`natural-hydrology-seed-<graine>-after-1800-ticks.json/.png` sont écrits dans
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/`, avec des profils PNG des
|
||||
cascades. Ils décrivent les sites, dépôts, fluides, biomes et ressources des
|
||||
chunks inspectés, ainsi que la graine et la version. Ce relevé de ressources
|
||||
reste local à ces chunks et ne représente pas le stock total de l’île.
|
||||
|
||||
Le diagnostic alpha.4 est conservé séparément, avec ses fichiers
|
||||
`surface-hydrology-…-after-200-ticks` et ses eaux entièrement retenues.
|
||||
Le nombre de blocs d’eau ne mesure pas un débit ; un résultat sans site
|
||||
`STREAM` ne valide pas une rivière, même s’il contient des lacs.
|
||||
|
||||
Pour itérer sur l’eau sans recalculer les trois grandes cartes de densité :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryDensityMaps=false -PsanctuaryResourceSurvey=off --console=plain
|
||||
```
|
||||
|
||||
Ces options désactivent seulement les exports de densité et le relevé de
|
||||
ressources général (neuf chunks ou île entière). Le diagnostic hydrologique
|
||||
garde ses propres comptes locaux. Les options gardent les assertions de densité et tous les tests de
|
||||
comportement, y compris les cartes et contrôles hydrologiques. Le parcours
|
||||
standard conserve les exports complets de densité pour la graine 0.
|
||||
|
||||
## Vérification client facultative
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true --console=plain
|
||||
```
|
||||
|
||||
Ce test charge les vrais `SkyRenderer` et `FogRenderer` et vérifie l'application
|
||||
des deux mixins, puis ferme automatiquement le client sans ouvrir de sauvegarde.
|
||||
Il nécessite un environnement graphique ; `check` ne le lance pas. Ses sources
|
||||
dans `src/clientGametest/java` ne sont ajoutées au mod de test que lorsque la
|
||||
propriété est activée. Le test de transformation ne remplace pas l'essai visuel
|
||||
de l'horizon dans un nouveau monde Sanctuary.
|
||||
|
||||
Pour cet essai visuel en monde de développement créatif, comparer la même vue
|
||||
à Y=70, 62, 32, 8 puis −8. Il ne doit apparaître ni disque noir au passage de
|
||||
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-<capacité>-seed-0-engine.json`
|
||||
et `population-<capacité>-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
|
||||
de production `sanctuary:sanctuary_cavern`. Chaque journal termine au moins
|
||||
dix tests requis et sa tâche Gradle sans échec, avec 1 800 ticks de fluides.
|
||||
Les sources naturelles d’eau et de lave, les débordements et les geysers
|
||||
démarrent uniquement avec leurs ticks de production. Le test programme
|
||||
seulement un témoin artificiel indépendant pour vérifier que les ticks
|
||||
fonctionnent ; il ne réveille pas les sources naturelles.
|
||||
Le build complet `check build assemblePack` réussit en 6m 24s,
|
||||
avec 15 tâches de tests purs de comportement.
|
||||
Les rapports sont postérieurs au gel du code final ; les résultats de
|
||||
l’alpha.8 et les essais alpha.9 intermédiaires ne servent pas de preuve ici.
|
||||
|
||||
La présence d’un groupe de terrasses connecté, d’un geyser réellement actif,
|
||||
de baies lumineuses, de spéléothèmes et d’améthyste bourgeonnante est vérifiée
|
||||
sur l’ensemble des trois graines. Chaque élément n’est pas imposé à chaque
|
||||
graine ; leurs absences locales restent visibles dans les tableaux.
|
||||
|
||||
| Graine | Chunks FULL | Failles / témoins | Chênes / bouleaux supérieurs | Chênes noirs inférieurs | Gros champignons | Petits sous plafond | Minerais dehors : charbon / fer / cuivre |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 251 | 3 / 3 | 321 / 251 | 18 | 14 | 211 | 506 / 178 / 147 |
|
||||
| `42` | 246 | 2 / 2 | 362 / 177 | 11 | 8 | 69 | 617 / 269 / 193 |
|
||||
| `8675309` | 242 | 3 / 3 | 248 / 125 | 14 | 11 | 131 | 761 / 317 / 328 |
|
||||
|
||||
Les arbres sont des groupes de pieds de troncs sur sol naturel, avec feuilles
|
||||
observées ; les gros champignons ont une tige et un chapeau connectés.
|
||||
|
||||
| Graine | Blocs à baies lumineuses | Blocs émissifs | Spéléothèmes pointus | Améthyste bourgeonnante | Blocs d’améthyste | Geysers dormants + éruptifs | Sources d’eau de geyser |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 363 | 391 | 501 | 214 | 818 | 1 | 1 |
|
||||
| `42` | 280 | 308 | 592 | 210 | 945 | 4 | 4 |
|
||||
| `8675309` | 446 | 476 | 380 | 141 | 504 | 5 | 5 |
|
||||
|
||||
Le décor souterrain est compté sur les blocs initiaux réellement générés.
|
||||
Ses témoins sont revérifiés après les fluides : support, conservation de
|
||||
l’améthyste bourgeonnante et lumière du moteur pour les blocs émissifs.
|
||||
Ces nombres comptent des blocs, pas des cavités ni des géodes distinctes.
|
||||
L’améthyste bourgeonnante est reliée à une cavité naturelle par un parcours
|
||||
d’air vérifié avant et après les ticks ; les JSON donnent la position de
|
||||
chaque raccord et les exemples de blocs souterrains, limités à 64 témoins.
|
||||
Les geysers observés possèdent la vraie entité de bloc `potent_sulfur`,
|
||||
du magma dessous et une source d’eau retenue au-dessus. Un même geyser
|
||||
doit passer par les états dormant et éruptif pendant les ticks, sans
|
||||
activation artificielle par le test.
|
||||
|
||||
| Graine | Niveaux Y des bassins | Colonnes d’eau par bassin | Descente depuis la bouche | Nouveaux blocs écoulés accessibles | Deux paliers connectés |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 148 / 140 | 53 / 76 | 148 | 1 230 | Oui, après ticks |
|
||||
| `0` | 177 / 163 | 169 / 47 | 177 | 3 370 | Oui, après ticks |
|
||||
| `42` | 129 / 114 | 116 / 53 | 129 | 181 | Oui, après ticks |
|
||||
| `42` | 112 / 107 | 105 / 161 | 112 | 354 | Oui, après ticks |
|
||||
| `8675309` | 154 / 149 | 128 / 149 | 7 | 7 | Oui, après ticks |
|
||||
| `8675309` | 153 / 112 | 87 / 58 | 153 | 871 | Oui, après ticks |
|
||||
|
||||
Chaque groupe réunit deux bassins retenus à des niveaux différents. Le
|
||||
diagnostic suit l’eau réelle depuis chaque bouche, horizontalement ou en
|
||||
descendant, sans mouvement vers le haut, jusqu’au bassin cible ;
|
||||
un exutoire terminal sans bassin cible n’est pas compté comme cette liaison.
|
||||
La descente est mesurée sur ce parcours propre à la bouche. Les nouveaux
|
||||
blocs accessibles en sont les eaux écoulées qui n’étaient ni retenues dans
|
||||
les bassins ni présentes avant la simulation. Les exutoires terminaux
|
||||
facultatifs doivent descendre d’au moins douze blocs réels.
|
||||
|
||||
Les terrasses sont limitées à trois blocs de profondeur et cinq blocs
|
||||
d’incision ; les lacs, à huit blocs de profondeur et douze blocs d’incision.
|
||||
Pour les terrasses, le diagnostic exige aussi quatre blocs d’air naturel
|
||||
au-dessus de chaque colonne excavée, en plus des fonds et supports conservés.
|
||||
|
||||
| Graine | Longueur rivière | Déplacement | Niveau Y | Colonnes d’eau | Colonnes des bassins terminaux | Sources liées |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 409,8 | 199,7 | 243 | 2 863 | 240 / 241 | 1 |
|
||||
| `42` | 247,8 | 171,0 | 247 | 1 712 | 254 / 247 | 1 |
|
||||
| `8675309` | 340,7 | 251,3 | 239 | 2 247 | 265 / 244 | 1 |
|
||||
|
||||
La longueur additionne les déplacements du tracé, diagonales comprises. Les
|
||||
bassins terminaux et l’arrivée de leur cascade sont contrôlés après les ticks.
|
||||
|
||||
| Graine | Colonnes du lac | Blocs d’eau réels | Profondeur retenue maximale |
|
||||
| --- | --- | --- | --- |
|
||||
| `0` | 728 | 3 746 | 8 |
|
||||
| `42` | 722 | 3 027 | 8 |
|
||||
| `8675309` | 992 | 4 978 | 8 |
|
||||
|
||||
Chaque ligne décrit un site classé `LAKE`, après simulation. Les étangs,
|
||||
bassins de rivière et terrasses sont consignés dans leurs propres catégories.
|
||||
|
||||
| Graine | Eau retenue, geysers inclus | Dont sources de geyser | Eau hors retenues | Eau totale | Lave totale | Sources de lave | Coulées | Accès poche de lave |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 9 845 | 1 | 4 971 | 14 816 | 144 | 20 | 2 | `(96, 100, -45)` |
|
||||
| `42` | 7 267 | 4 | 903 | 8 170 | 144 | 20 | 2 | `(67, 133, -108)` |
|
||||
| `8675309` | 10 268 | 5 | 1 230 | 11 498 | 144 | 20 | 2 | `(-93, 156, -21)` |
|
||||
|
||||
Ces volumes sont mesurés après 1 800 ticks actifs ; ils ne mesurent pas un
|
||||
débit et ne signifient pas que les coulées ont atteint leur état final.
|
||||
L’eau retenue correspond au volume attendu après décoration et comprend
|
||||
les sources propres aux geysers. Les composantes extérieures sont rattachées
|
||||
aux sources rocheuses ou aux débordements déclarés, avec leurs raccords
|
||||
réels. La lave des coulées est distinguée de la poche couverte.
|
||||
|
||||
| Graine | Sols de mousse | Sols de podzol | Sols de mycélium |
|
||||
| --- | --- | --- | --- |
|
||||
| `0` | 13 175 | 10 936 | 5 074 |
|
||||
| `42` | 4 897 | 3 712 | 2 200 |
|
||||
| `8675309` | 10 058 | 6 714 | 3 293 |
|
||||
|
||||
Les stocks suivants portent sur les seuls chunks inspectés, avant les
|
||||
ticks de fluides. Les variantes ordinaires et d’ardoise de chaque minerai
|
||||
sont additionnées. Ce relevé ne couvre pas toute l’île et ne déclenche
|
||||
jamais de remplissage pour atteindre un quota.
|
||||
|
||||
| Minerai | Graine 0 | Graine 42 | Graine 8675309 |
|
||||
| --- | --- | --- | --- |
|
||||
| Charbon | 7 858 | 6 788 | 4 992 |
|
||||
| Fer | 3 224 | 2 700 | 2 172 |
|
||||
| Cuivre | 2 439 | 2 132 | 1 621 |
|
||||
| Or | 930 | 687 | 507 |
|
||||
| Redstone | 924 | 631 | 523 |
|
||||
| Lapis | 278 | 279 | 137 |
|
||||
| Diamant | 162 | 86 | 88 |
|
||||
| Émeraude | 64 | 41 | 42 |
|
||||
|
||||
| Matière | Graine 0 | Graine 42 | Graine 8675309 |
|
||||
| --- | --- | --- | --- |
|
||||
| Tuf | 657 813 | 491 736 | 239 434 |
|
||||
| Cobblestone | 285 441 | 172 282 | 162 791 |
|
||||
| Cobblestone moussue | 593 694 | 420 042 | 229 846 |
|
||||
| Boue compactée | 168 698 | 167 276 | 158 922 |
|
||||
| Ardoise des abîmes | 1 443 679 | 901 821 | 858 181 |
|
||||
| Roche noire | 539 269 | 275 994 | 324 101 |
|
||||
| Basalte | 605 828 | 390 889 | 307 652 |
|
||||
| Soufre | 87 811 | 57 838 | 82 209 |
|
||||
| Cinabre | 11 797 | 13 597 | 20 095 |
|
||||
|
||||
La sélection des chunks suit les sites d’eau et d’écologie ; elle n’est pas
|
||||
un échantillon représentatif de toute l’île. Ces stocks ne démontrent pas
|
||||
l’accessibilité de chaque bloc et ne prouvent pas, seuls, une réduction du
|
||||
soufre par rapport à une autre version sur un périmètre différent.
|
||||
Le tableau des minerais extérieurs sélectionne charbon, fer et cuivre ; les
|
||||
JSON gardent les autres familles effectivement observées avec une face d’air.
|
||||
|
||||
| Graine | Réentrée source d’eau | Réentrée source de lave | Réentrée débordement |
|
||||
| --- | --- | --- | --- |
|
||||
| `0` | 1 vérifiée(s) | 1 vérifiée(s) | 1 vérifiée(s) |
|
||||
| `42` | 1 vérifiée(s) | 1 vérifiée(s) | 1 vérifiée(s) |
|
||||
| `8675309` | 1 vérifiée(s) | 1 vérifiée(s) | 1 vérifiée(s) |
|
||||
|
||||
Après consommation des marqueurs, les rappels du post-traitement préservent
|
||||
les modifications témoins apportées aux bouches ; les blocs sont restaurés
|
||||
ensuite. Les 176 fichiers historiques suivis hors raccordement
|
||||
conservent leur SHA-256. Aucun monde personnel n’est ouvert par ces tests.
|
||||
|
||||
Preuves locales ignorées : `build/alpha9-check-build-final.log` (build et graine 0),
|
||||
`build/alpha9-seed42-final.log`, `build/alpha9-seed8675309-final.log` et les rapports
|
||||
`cavern-seed-<graine>-after-1800-ticks.json` et `-postprocessing-reentry.json`
|
||||
sous `mods/sanctuary/build/run/gameTest/diagnostics/`.
|
||||
`build/report-alpha9-validation.py` extrait les mesures et vérifie les preuves
|
||||
cumulées, le gel des sources et les journaux. Le hash final du JAR est lu
|
||||
dans `build/alpha9-jar-sha256.txt`, sans valeur codée dans l’extracteur.
|
||||
|
||||
Ces résultats ne garantissent aucune géode, terrasse, rivière, geyser ou
|
||||
ressource sur chaque graine. L’appréciation visuelle et l’équilibrage en
|
||||
partie restent ouverts. La release alpha.9 et le canal packwiz sont publiés
|
||||
depuis `40b6555`. Deux synchronisations avec le véritable installateur
|
||||
réussissent dans un dossier isolé, puis deux dans la même instance Prism.
|
||||
Un seul JAR Sanctuary alpha.9 est actif ; 260 fichiers personnels et réglages
|
||||
conservent leurs hashes. L’ancien JAR et les manifestes sont sauvegardés hors
|
||||
de `mods/`. Voir [Distribution](packwiz.md) pour le hash public du JAR.
|
||||
|
||||
## Contrôles et validation alpha.8 — 9 septembre 2026
|
||||
|
||||
Les trois essais utilisent des mondes neufs et le preset de production
|
||||
`sanctuary:sanctuary_rift`, avec Minecraft 26.3-pre-2, Java 25, Fabric Loader
|
||||
0.19.5 et Fabric API 0.160.0+26.3. Les journaux propres aux graines `0`, `42`
|
||||
et `8675309` terminent leurs tests requis et leur tâche Gradle sans échec.
|
||||
Le build complet `check build assemblePack` est exécuté sur la graine `42`.
|
||||
Il passe en 4m 57s,
|
||||
avec 12 tests purs de comportement et les tests serveur.
|
||||
Les exports généraux facultatifs sont désactivés ; les diagnostics ciblés et
|
||||
les assertions de comportement restent actifs.
|
||||
|
||||
Les nouveaux contrôles exigent de vrais bassins terminaux connectés à la
|
||||
rivière et la continuité physique entre chaque tige et son chapeau de gros
|
||||
champignon. Les rapports antérieurs à ces contrôles ne sont pas utilisés.
|
||||
|
||||
| Graine | Chunks FULL | Failles / colonnes témoins | Chênes / bouleaux supérieurs | Chênes noirs inférieurs | Gros champignons | Petits sous plafond | Minerais dehors : charbon / fer / cuivre |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 246 | 3 / 3 | 331 / 249 | 19 | 22 | 273 | 488 / 170 / 124 |
|
||||
| `42` | 216 | 2 / 2 | 361 / 178 | 17 | 15 | 140 | 379 / 209 / 148 |
|
||||
| `8675309` | 231 | 3 / 3 | 236 / 125 | 31 | 17 | 202 | 633 / 261 / 262 |
|
||||
|
||||
Les mêmes échantillons montrent la coexistence de chênes noirs et de gros
|
||||
champignons sur les graines `0`, `42`, `8675309`.
|
||||
|
||||
Les arbres sont comptés par groupes de pieds de troncs avec sol et feuilles
|
||||
observés. Les gros champignons ont une tige enracinée, un chapeau connecté et
|
||||
une voûte naturelle ; leurs blocs sont de nouveau contrôlés après les fluides.
|
||||
Les petits champignons sous plafond ont également leurs supports naturels.
|
||||
Ils ne sont pas confondus avec le sous-ensemble `actual_lower_terrace_vegetation`.
|
||||
Les failles sont vérifiées sur des colonnes témoins à l’intérieur des traces :
|
||||
ce nombre ne mesure pas la longueur complète des canyons.
|
||||
|
||||
| Graine | Longueur rivière | Déplacement | Niveau Y | Colonnes d’eau | Colonnes des deux bassins | Sources liées | Descente liée observée |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 409,8 | 199,7 | 243 | 2 863 | 240 / 241 | 1 | 8 |
|
||||
| `42` | 247,8 | 171,0 | 247 | 1 712 | 254 / 247 | 1 | 6 |
|
||||
| `8675309` | 340,7 | 251,3 | 239 | 2 247 | 265 / 244 | 1 | 8 |
|
||||
|
||||
La longueur vient de `river_routes[].length`, somme des déplacements successifs
|
||||
du chemin, diagonales comprises. Le déplacement est la distance entre ses
|
||||
extrémités. Les deux bassins ont des élargissements mesurés au-delà du lit ;
|
||||
chaque raccord local et l’arrivée réelle de la cascade sont contrôlés. Le
|
||||
tracé est arrondi à l’échelle de 16 à 32 blocs, avec un lit d’environ 5 à 7
|
||||
blocs de large. Les limites sont de 16 blocs retirés pour le lit, 12 pour ses
|
||||
berges progressives et 2 pour les autres rives, sur support naturel.
|
||||
|
||||
| Graine | Eau retenue | Eau hors bassins | Eau totale | Lave totale | Sources de lave | Coulées déclarées | Accès à la poche de lave |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `0` | 6 463 | 363 | 6 826 | 144 | 20 | 2 | `(-5, 106, 80)` |
|
||||
| `42` | 4 899 | 358 | 5 257 | 144 | 20 | 2 | `(-35, 108, 6)` |
|
||||
| `8675309` | 5 841 | 345 | 6 186 | 144 | 20 | 2 | `(-55, 132, 0)` |
|
||||
|
||||
Ces fluides sont lus après 1 800 ticks dans des chunks actifs, avec témoins
|
||||
indépendants du fonctionnement des ticks. Les volumes retenus correspondent
|
||||
exactement aux volumes attendus. L’eau extérieure reste reliée aux sources
|
||||
déclarées ; la lave des coulées est distinguée des sources de la poche couverte.
|
||||
La poche contient 18 sources lorsqu’elle est présente ; ses coordonnées
|
||||
d’accès ne sont pas les niveaux des blocs de lave. Sa recherche privilégie
|
||||
Y=64..160, avec un site plus haut possible si la recherche basse échoue.
|
||||
Les coulées supplémentaires partent de sources entre Y=40 et Y=160. Ce relevé
|
||||
à 1 800 ticks ne signifie pas que tout écoulement a atteint son état final.
|
||||
|
||||
| Graine | Sols de mousse | Sols de podzol | Sols de mycélium |
|
||||
| --- | --- | --- | --- |
|
||||
| `0` | 16 268 | 15 441 | 6 855 |
|
||||
| `42` | 7 197 | 6 684 | 3 502 |
|
||||
| `8675309` | 12 519 | 10 616 | 5 185 |
|
||||
|
||||
Les sols sont des colonnes inférieures naturelles réellement revêtues de ces
|
||||
matières. Les proportions de placement ne sont pas imposées au relevé.
|
||||
|
||||
Les stocks suivants comptent les blocs dans les seuls chunks inspectés,
|
||||
**avant les ticks de fluides**. Ils incluent les variantes ordinaires et
|
||||
d’ardoise de chaque minerai. Ils ne représentent pas toute l’île et ne sont
|
||||
jamais complétés pour atteindre un quota.
|
||||
|
||||
| Minerai | Graine 0 | Graine 42 | Graine 8675309 |
|
||||
| --- | --- | --- | --- |
|
||||
| Charbon | 7 291 | 5 738 | 4 295 |
|
||||
| Fer | 2 899 | 2 330 | 1 860 |
|
||||
| Cuivre | 2 229 | 1 804 | 1 416 |
|
||||
| Or | 829 | 533 | 425 |
|
||||
| Redstone | 819 | 534 | 432 |
|
||||
| Lapis | 237 | 229 | 122 |
|
||||
| Diamant | 126 | 80 | 71 |
|
||||
| Émeraude | 55 | 34 | 30 |
|
||||
|
||||
| Matière | Graine 0 | Graine 42 | Graine 8675309 |
|
||||
| --- | --- | --- | --- |
|
||||
| Tuf | 562 892 | 439 519 | 264 881 |
|
||||
| Cobblestone | 288 921 | 140 182 | 73 681 |
|
||||
| Cobblestone moussue | 452 727 | 381 339 | 220 767 |
|
||||
| Boue compactée | 192 049 | 145 758 | 101 652 |
|
||||
| Ardoise des abîmes | 1 437 063 | 712 417 | 807 405 |
|
||||
| Roche noire | 379 527 | 194 967 | 248 247 |
|
||||
| Basalte | 549 851 | 293 938 | 137 965 |
|
||||
| Soufre | 462 657 | 313 445 | 401 190 |
|
||||
| Cinabre | 118 411 | 74 761 | 114 313 |
|
||||
|
||||
Ces stocks ne prouvent pas seuls l’accessibilité de chaque bloc. Les cartes
|
||||
de surfaces, les positions des minerais exposés et les témoins de corniches
|
||||
complètent le relevé. Les minerais « dehors » ont une vraie face d’air ouverte
|
||||
vers le ciel ou une ouverture horizontale naturelle inspectée sur douze blocs.
|
||||
Le tableau présente le charbon, le fer et le cuivre. Les JSON conservent aussi
|
||||
les autres familles effectivement recensées dehors, comme l’émeraude lorsqu’elle
|
||||
est observée ; le choix des colonnes ne limite pas le périmètre du diagnostic.
|
||||
L’échantillon couvre les eaux et leurs voisins, des cibles écologiques et le
|
||||
site facultatif d’arbre remarquable ; il est volontairement orienté par ces
|
||||
sites, sans prétendre donner un pourcentage représentatif de l’île.
|
||||
|
||||
| Graine | Réentrée bouche d’eau | Réentrée bouche de lave |
|
||||
| --- | --- | --- |
|
||||
| `0` | 1 vérifiée(s) | 1 vérifiée(s) |
|
||||
| `42` | 1 vérifiée(s) | 1 vérifiée(s) |
|
||||
| `8675309` | 1 vérifiée(s) | 1 vérifiée(s) |
|
||||
|
||||
Après consommation des marqueurs de génération, le rappel du post-traitement
|
||||
conserve les modifications temporaires appliquées aux bouches testées ; les
|
||||
blocs témoins sont ensuite restaurés. Les 113 fichiers historiques
|
||||
suivis conservent leurs SHA-256. Aucun monde personnel n’est ouvert par ces tests.
|
||||
|
||||
Preuves locales ignorées par Git : `build/alpha8-build.log` (build complet sur
|
||||
42), `build/alpha8-seed0.log`, `build/alpha8-seed42.log`,
|
||||
`build/alpha8-seed8675309.log`, les JSON `rift-seed-<graine>-after-1800-ticks`
|
||||
et `-postprocessing-reentry`, les cartes de canopée et de corniches et les
|
||||
profils de cascades sous `mods/sanctuary/build/run/gameTest/diagnostics/`.
|
||||
`build/report-alpha8-validation.py` extrait leurs champs sans remplacer les
|
||||
données absentes par des résultats supposés. Le hash du JAR vérifié est
|
||||
conservé dans `build/alpha8-jar-sha256.txt`.
|
||||
|
||||
Ces résultats portent sur les graines testées. Ils ne garantissent aucune
|
||||
rivière, essence rare ou quantité de ressources sur toutes les graines.
|
||||
L’appréciation visuelle du paysage et l’équilibrage en partie restent ouverts.
|
||||
La distribution est également vérifiée : deux synchronisations packwiz dans
|
||||
un dossier de développement neuf, puis deux dans la même instance Prism.
|
||||
Le second passage ne change aucun fichier géré ; un seul JAR Sanctuary
|
||||
alpha.8 reste actif. Les SHA-256 des 161 fichiers personnels et réglages
|
||||
suivis sont identiques avant et après. La livraison et le hash du JAR figurent
|
||||
dans [Distribution Prism](packwiz.md).
|
||||
|
||||
## Contrôles et validation alpha.7 — 8 septembre 2026
|
||||
|
||||
`WoodlandDiagnostics` reprend les preuves hydrologiques et ajoute les forêts,
|
||||
les champignons sous plafond, les roches exposées et les minerais visibles.
|
||||
Les arbres sont comptés par pieds de troncs sur le sol naturel, avec au moins
|
||||
trois bûches verticales et des feuilles réellement observées. Le chêne noir
|
||||
requiert un pied principal de 2×2 et trois couches de support par pied ; ses
|
||||
branches retombantes ne comptent pas comme de nouveaux arbres. Podzol et
|
||||
mycélium sont des sols explicites : le tag `minecraft:dirt` de cette version
|
||||
ne les contient pas.
|
||||
|
||||
Les essais utilisent des mondes neufs, le vrai preset de production et
|
||||
Minecraft 26.3-pre-2 / Java 25 / Fabric Loader 0.19.5. Les dix tests requis
|
||||
passent sur chacune des trois graines. `check build assemblePack` passe en
|
||||
3 min 41 s sur la graine 0, avec les huit tests purs de forme, hydrologie,
|
||||
lave, écologie et nouveaux bois. Les exports de densité et le relevé général
|
||||
facultatifs sont désactivés ; les contrôles de densité restent actifs.
|
||||
|
||||
| Graine | Chunks FULL inspectés | Chênes de surface | Bouleaux de surface | Chênes à rayon ≤128 | Chênes noirs inférieurs | Minerais exposés dehors : charbon / fer / cuivre |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| `0` | 167 | 292 | 263 | 218 | 30 | 4 / 19 / 5 |
|
||||
| `42` | 154 | 313 | 125 | 250 | 13 | 8 / 3 / 13 |
|
||||
| `8675309` | 167 | 336 | 99 | 272 | 36 | 7 / 6 / 9 |
|
||||
|
||||
Ces nombres décrivent un échantillon borné : les eaux et leur voisinage, des
|
||||
cibles écologiques et l’emplacement de l’arbre remarquable. Ce n’est pas un
|
||||
inventaire complet de l’île ni un pourcentage représentatif de toutes ses
|
||||
surfaces. Les sols sont mesurés sous les canopées. Les minerais recensés dans
|
||||
la dernière colonne ont une face d’air réelle, ouverte vers le ciel ou vers
|
||||
une paroi dégagée sur douze blocs ; les faces dans de petites cavités sont
|
||||
comptées séparément. Les trois roches présentent des nappes contiguës exposées.
|
||||
|
||||
La graine 42 démontre un cerisier remarquable en `(-88, 251, -88)`, avec son
|
||||
tronc, ses feuilles et 21 bûches dans le volume prévu. Le site sélectionné sur
|
||||
la graine 0 ne produit pas d’arbre rare : l’espace et les conditions après
|
||||
décoration restent déterminants. Aucun terrain n’est ajouté pour le forcer.
|
||||
Les autres essences rares et le soufre ne sont pas garantis sur chaque graine.
|
||||
|
||||
Les eaux sont vérifiées après décoration puis pendant 1 800 ticks réels dans
|
||||
des chunks actifs, avec témoins indépendants du fonctionnement des fluides.
|
||||
Les bassins et les dix-huit sources de chaque niche de lave sont conservés ;
|
||||
les cascades restent reliées à leurs sources et dans l’emprise observée.
|
||||
Les pieds des arbres et les champignons observés gardent leur support. Les
|
||||
sédiments sont comparés à leur matériau prévu sur toutes leurs couches ; une
|
||||
roche peut être remplacée seulement par les roches, minerais ou dépôts de
|
||||
terre effectivement configurés. Les berges de sable et gravier sont comptées
|
||||
sur les vrais blocs, y compris par profondeur.
|
||||
|
||||
Les anciens générateurs restent isolés : les 77 fichiers historiques suivis
|
||||
gardent leur SHA-256. La réentrée au post-traitement après consommation des
|
||||
marqueurs respecte une modification ultérieure de bouche de source sur les
|
||||
trois graines. Aucun monde de joueur n’est ouvert par ces tests.
|
||||
|
||||
Preuves locales ignorées par Git : `build/alpha7-build.log`,
|
||||
`build/alpha7-seed42.log`, `build/alpha7-seed8675309.log`, et les fichiers
|
||||
`woodland-seed-<seed>-<phase>.json`, `-canopy.png`, `-lower-terraces.png` et
|
||||
profils de sources dans `mods/sanctuary/build/run/gameTest/diagnostics/`.
|
||||
Les cartes montrent des lectures de blocs ; l’appréciation du paysage dans
|
||||
le client reste à faire lors du prochain essai.
|
||||
|
||||
## Contrôles alpha.6
|
||||
|
||||
`layeredHydrologySmoke` vérifie les bassins sans ruisseau de secours, les
|
||||
supports des dépôts, leur déterminisme et les sources sur les faces des
|
||||
strates inférieures. Les reliefs vides, trop minces ou percés ne sont pas comblés.
|
||||
`layeredEcologySmoke` vérifie les sols et le dégagement des corniches,
|
||||
l’exclusion du sommet et du vide, ainsi que la variation verticale des biomes.
|
||||
|
||||
`LayeredHydrologyDiagnostics` inspecte les nouveaux paramètres et refuse
|
||||
Dappled Forest dans leur palette. Il vérifie les couleurs communes, les biomes
|
||||
des surfaces hautes et basses, la végétation réelle et les surfaces rocheuses.
|
||||
Les plantes vanilla sur une corniche mince sont relevées séparément : elles
|
||||
ne constituent pas une preuve des trois supports exigés par la nouvelle
|
||||
décoration moussue. Leurs coordonnées et leurs supports sont exportés.
|
||||
|
||||
Les tickets de toute l’emprise sont posés avant le chargement des chunks `FULL`.
|
||||
Le test appelle ensuite `ServerLevel.waitForEntities(chunk, 0)` sur cette même
|
||||
emprise : le serveur GameTest accélère ses ticks et un délai en ticks seul ne
|
||||
prouve pas que les lectures asynchrones d’entités sont terminées. Le prédicat
|
||||
`isPositionTickingWithEntitiesLoaded` et les témoins d’écoulement restent requis.
|
||||
|
||||
Le diagnostic simule 1 800 ticks dans les chunks actifs. Les bassins doivent
|
||||
retenir leur eau. Les écoulements extérieurs doivent être reliés à une source
|
||||
rocheuse déclarée par des connexions d’eau réelles, sans eau orpheline ni voisin
|
||||
horizontal en dehors de l’emprise inspectée. Les seuls sites de surface sont
|
||||
des étangs et des lacs ; aucune rivière n’est activée dans cette livraison.
|
||||
Les ouvertures des sources sont vérifiées après toute la décoration ; une
|
||||
source bouchée par du lichen ne peut pas satisfaire le contrôle de descente.
|
||||
Après ces mesures, un test de réentrée pose temporairement un lichen à une
|
||||
bouche dont les marqueurs de génération ont été consommés. Un nouvel appel au
|
||||
post-traitement doit le conserver ; le test restaure ensuite le bloc d’origine.
|
||||
La preuve est dans `layered-hydrology-seed-<seed>-postprocessing-reentry.json`.
|
||||
|
||||
Les cartes `layered-hydrology-seed-<seed>-<phase>*.png` montrent les surfaces,
|
||||
les corniches et les profils de sources. Les JSON associés contiennent les
|
||||
résultats et leur emprise. Ces lectures de blocs ne remplacent pas l’examen
|
||||
du paysage dans le client.
|
||||
|
||||
## Validation alpha.6 — 8 septembre 2026
|
||||
|
||||
Les dix tests serveur passent sur chacune des graines `0`, `42` et `8675309`,
|
||||
dans des mondes neufs Minecraft 26.3-pre-2 avec Java 25, sur Apple M1 à 8 Go.
|
||||
Chaque emprise est réellement active pendant 1 800 ticks mesurés. La commande
|
||||
finale `check build assemblePack`, avec les exports de densité et le relevé
|
||||
ressources général facultatifs désactivés, passe en **3 min 30 s** sur la graine 0.
|
||||
Les six tests de forme, hydrologie, lave et écologie passent également.
|
||||
|
||||
| Graine | Spawn naturel | Eau des bassins | Eau des cascades | Canne | Lave | Chunks FULL | Plantes sur corniches épaisses |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `0` | `(0, 252, 0)` | 1 533 | 414 | 20 | 18 | 151 | 11 021 |
|
||||
| `42` | `(0, 249, 0)` | 1 376 | 913 | 4 | 18 | 149 | 6 334 |
|
||||
| `8675309` | `(0, 247, 0)` | 1 590 | 2 350 | 10 | 18 | 160 | 9 532 |
|
||||
|
||||
Ces nombres décrivent uniquement les chunks inspectés, après décoration et
|
||||
simulation. Les plantes incluent la végétation vanilla et la nouvelle décoration,
|
||||
sur des corniches avec trois supports naturels ; les plantes de corniches
|
||||
plus minces sont comptées séparément. Les cinq variantes de biome sont observées
|
||||
sur les surfaces inférieures, avec respectivement cinq, trois et deux changements
|
||||
verticaux contrôlés. Les surfaces inspectées comportent 5 805, 4 765 et 10 045
|
||||
colonnes de pierre au sommet ; il ne s’agit pas d’un quota pour l’île entière.
|
||||
|
||||
Sur la graine 0, les sources `(-112, 103, 0)` et `(-108, 227, -96)` atteignent
|
||||
Y=0 ; celle de `(-110, 148, -46)` rejoint un palier à Y=130. La première traverse
|
||||
une frontière de chunks. Sur la graine 42, la source `(-62, 151, -82)` dégage
|
||||
correctement son ouverture autrefois obstruée par du lichen et descend de
|
||||
49 blocs ; les deux autres descendent de 103 et 236 blocs. Les trois cascades
|
||||
de la graine 8675309 atteignent Y=0. Tous les écoulements observés sont reliés
|
||||
à leur source, sans eau orpheline ni dépassement horizontal de l’emprise.
|
||||
|
||||
Les bassins conservent leur eau ; les sédiments et leurs supports résistent aux
|
||||
ticks de gravité. Les trois niches de lave gardent leurs 18 sources, leur coque
|
||||
et leur accès sec. Le test de réentrée après consommation des marqueurs réussit
|
||||
sur les trois graines : une modification ultérieure de la bouche reste intacte.
|
||||
Les 52 fichiers historiques de génération suivis pour cette livraison gardent
|
||||
leur SHA-256 d’avant modification.
|
||||
|
||||
Les cartes de surface, de corniches et les profils de cascade ont été examinés.
|
||||
Ce sont des lectures de blocs ; le rendu dans le client reste à apprécier en
|
||||
jeu. La grande rivière traversante n’est pas activée : cette livraison adopte
|
||||
les petits bassins et les sources de paroi. Les ressources restent observées
|
||||
après génération, sans garantie de toute la progression Minecraft pour chaque graine.
|
||||
|
||||
## Validation alpha.5 — 8 septembre 2026
|
||||
|
||||
Les essais utilisent Minecraft 26.3-pre-2, Java 25 et le preset de production
|
||||
sur des mondes neufs, sur Apple M1 à 8 Go. Le diagnostic attend les chunks
|
||||
réellement simulables avant de mesurer 1 800 ticks de fluides.
|
||||
|
||||
Les dix tests serveur réussissent sur chacune des graines `0`, `42` et
|
||||
`8675309`. La vérification finale `check build assemblePack`, avec les cartes de
|
||||
densité et le relevé général facultatifs désactivés, réussit en **3 min 11 s**
|
||||
sur la graine 0. Les contrôles ciblés de ressources, de biomes et de fluides
|
||||
restent actifs. Les tâches de forme, d’hydrologie et de lave réussissent aussi.
|
||||
|
||||
| Graine | Spawn naturel | Eau des bassins | Eau des cascades | Canne à sucre | Lave | Chunks FULL inspectés |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| `0` | `(0, 252, 0)` | 1 930 | 1 649 | 52 | 18 | 189 |
|
||||
| `42` | `(0, 249, 0)` | 1 859 | 1 058 | 25 | 18 | 177 |
|
||||
| `8675309` | `(0, 247, 0)` | 1 830 | 3 960 | 16 | 18 | 195 |
|
||||
|
||||
Sur la graine 0, les trois sources partent de `(-108, 227, -96)`,
|
||||
`(-26, 203, -141)` et `(-82, 176, 141)` et atteignent toutes **Y=0**. Les trois
|
||||
sources des graines 42 et 8675309 atteignent également Y=0. Les bassins conservent leur
|
||||
volume ; la niche de lave conserve 18 sources et son accès praticable, sans
|
||||
incendie détecté. Le cours d’eau reste continu à travers les frontières de chunks.
|
||||
|
||||
Les dépôts ont trois à cinq couches et deux supports naturels en dessous.
|
||||
La rive sèche la plus éloignée de l’eau atteint une distance de Manhattan de
|
||||
20 blocs sur la graine 0, 28 sur la graine 42 et 27 sur la graine 8675309. Les cartes montrent des plages
|
||||
larges et asymétriques, des bandes de gravier et des interruptions par la roche
|
||||
ou l’herbe. Quelques petites poches restent anguleuses à l’échelle du bloc.
|
||||
Les profils montrent les cascades réelles ; ces lectures de blocs ne remplacent
|
||||
pas l’appréciation du relief et des couleurs dans le client.
|
||||
|
||||
Les cinq biomes sont vérifiés dans des chunks réels. Dans les **189 chunks
|
||||
inspectés de la graine 0**, le relevé compte 754 minerais de charbon, 464 de fer,
|
||||
197 de cuivre, 215 d’or, 210 de redstone, 23 de lapis et 14 de diamant. Ce sont
|
||||
des blocs présents, pas des rendements de minage ni un total pour l’île entière.
|
||||
Le bois, la pierre et les ressources renouvelables n’ont pas de plafond de
|
||||
construction ajouté. La progression complète et l’équilibrage coopératif restent
|
||||
à éprouver en partie.
|
||||
|
||||
Le calcul initial du plan hydrologique prend 23 623 ms dans la vérification
|
||||
finale de la graine 0 et 24 475 ms dans l’essai de la graine 42 ; la recherche
|
||||
de lave ajoute respectivement 1 075 ms et 1 568 ms. Le plan est mis en cache par état aléatoire de monde,
|
||||
sans charger de chunks voisins. Ces durées sont distinctes du chargement des
|
||||
chunks de diagnostic.
|
||||
|
||||
Le premier essai court a révélé un défaut du banc de test : un chunk FULL muni
|
||||
d’un ticket pouvait encore attendre ses entités et ne pas exécuter ses ticks de
|
||||
fluide. Le contrôle utilise désormais le prédicat exact du moteur et un témoin
|
||||
indépendant par chunk de source. Un autre essai a rencontré trois blocs de chêne couché vanilla au niveau
|
||||
supérieur d’un étang. Le diagnostic conserve cette décoration et distingue le
|
||||
volume d’eau prévu des sources restantes : seuls des troncs horizontaux de
|
||||
surface jouxtant une source, avec de l’eau dessous ou le fond peu profond
|
||||
plein, sont acceptés. Leur position et leur état exacts, l’appui ou l’eau sous
|
||||
le bois, les parois et le reste des sources sont vérifiés
|
||||
après simulation. Le contrôle de continuité du cours d’eau reste strict.
|
||||
Le dernier essai de la graine 8675309 conserve les 1 830 sources prévues sans
|
||||
tronc dans l’étang ; le décor vanilla peut varier avec l’ordre de génération
|
||||
des chunks, tandis que le plan Sanctuary reste identique.
|
||||
|
||||
Deux arbres sur des paliers de la troisième graine dévient aussi les cascades.
|
||||
Le trajet sur densité nue ne prévoit pas ces feuilles. Le diagnostic final
|
||||
compare cette prévision aux blocs réels, puis vérifie les composantes d’eau
|
||||
par connexions de face : toute composante hors bassin doit rejoindre une source
|
||||
déclarée. Aucune eau ne doit toucher un chunk horizontal non inspecté. L’emprise
|
||||
reste celle prévue initialement avec son halo d’un chunk, sans élargissement
|
||||
après observation des écoulements. Sur la graine 8675309, les trois composantes
|
||||
comptent 2 445, 1 152 et 363 blocs d’eau après simulation ; toutes rejoignent
|
||||
leur source et restent entièrement dans les chunks inspectés.
|
||||
Les anciennes classes et ressources de terrain et d’hydrologie sont également
|
||||
comparées octet par octet avec l’alpha.4.
|
||||
|
||||
Journaux : `build/alpha5-build.log`, `build/alpha5-seed42-final.log` et
|
||||
`build/alpha5-seed8675309-provenance.log`. Les exports JSON/PNG sont dans le dossier
|
||||
`diagnostics` décrit plus haut. Le dernier contrôle par composantes d’eau est
|
||||
exécuté sur les graines 8675309 et 0 ; l’essai réussi de la graine 42 utilisait
|
||||
encore le contrôle plus restrictif du trajet prévu sur densité nue. Le code de
|
||||
génération est identique entre ces essais.
|
||||
|
||||
## Validation alpha.4 — 8 septembre 2026
|
||||
|
||||
Les tests serveur utilisent le preset de production et des mondes neufs. Sur
|
||||
la graine `0`, le scénario exige un véritable site `STREAM` ; les autres graines
|
||||
vérifient la continuité de chaque cours d’eau qu’elles produisent, sans exiger
|
||||
qu’une graine arbitraire soit artificiellement complétée.
|
||||
|
||||
| Graine | Spawn naturel | Eau après 200 ticks | Canne à sucre | Chunks FULL inspectés | Raccords d’eau aux frontières |
|
||||
| --- | --- | ---: | ---: | ---: | ---: |
|
||||
| `0` | `(0, 251, -4)` | 1 938 | 15 | 94 | 253 |
|
||||
| `42` | `(-4, 249, -4)` | 1 501 | 24 | 94 | 213 |
|
||||
| `8675309` | `(0, 247, 0)` | 1 551 | 12 | 90 | 190 |
|
||||
|
||||
Pour la graine `0`, le cours d’eau est à **Y=250**, de **X=−48, Z=48** à
|
||||
**X=−20, Z=−20**. Son chemin suit 18 points et relie deux petites poches d’eau.
|
||||
Deux petits lacs et quatre étangs sont également présents, en comptant la poche
|
||||
de départ du cours d’eau. La graine `42` produit elle aussi un cours d’eau, à
|
||||
Y=221, de `(−72, 24)` à `(−60, −40)` en X/Z. Ce sont des observations de ces
|
||||
graines, pas un minimum promis pour toutes les îles.
|
||||
|
||||
**10/10 tests requis réussissent sur chacune des trois graines**, avec un cours
|
||||
d’eau observé sur chacune. Les contrôles comparent chaque bloc d’eau avant et après une simulation réelle
|
||||
confirmée par un témoin d’écoulement et l’activité de tous les chunks concernés.
|
||||
Les quantités d’eau restent identiques. Les cartes des vrais blocs ont été
|
||||
inspectées : les lacs sont arrondis et les chenaux restent continus. Le tracé
|
||||
des cours d’eau comporte encore des segments géométriques ; l’appréciation du
|
||||
relief, des berges et de leur aspect dans le client reste à faire par le joueur.
|
||||
Les cartes masquent la canopée et ne remplacent pas une vue en jeu.
|
||||
|
||||
Le plan initial prend **12 060 ms** pour la graine `0` et **8 743 ms** pour `42`
|
||||
et **9 755 ms** pour `8675309`, sur le poste Apple M1 à 8 Go utilisé pendant ces
|
||||
essais. Il est calculé une seule
|
||||
fois par état aléatoire de monde, sans charger les chunks voisins. La préparation
|
||||
des 94 chunks FULL de chaque diagnostic est distincte du calcul du plan et
|
||||
ne représente pas le travail requis à chaque lancement de partie.
|
||||
|
||||
Les trois exécutions serveur réussies ont été suivies de
|
||||
`./gradlew check build assemblePack -x :sanctuary:runGameTest` : le serveur déjà
|
||||
validé séparément n’est pas relancé à l’identique. Les tests de forme et
|
||||
d’hydrologie pure, la compilation et l’assemblage du pack réussissent en 2 s.
|
||||
Le JAR ne contient aucune classe de test, et le `.mrpack` contient exactement
|
||||
ce même JAR, avec les versions Minecraft et Fabric attendues.
|
||||
|
||||
Journaux : `build/alpha4-hydrology-v2-seed0.log` et
|
||||
`build/alpha4-validation-seed42.log`, `build/alpha4-validation-seed8675309.log`
|
||||
et `build/alpha4-build.log`. Les exports correspondants sont dans
|
||||
le dossier `diagnostics` décrit plus haut. Les cartes de densité, inchangées
|
||||
par l’hydrologie, ont été exportées lors des premières exécutions de ce ticket ;
|
||||
les assertions de densité restent actives pendant chaque validation.
|
||||
|
||||
## Validation alpha.3 — 8 septembre 2026
|
||||
|
||||
Sur Java 25 / Minecraft 26.3-pre-2 / Fabric Loader 0.19.5 :
|
||||
|
||||
| Vérification | Résultat |
|
||||
| --- | --- |
|
||||
| `check build assemblePack` avec relevé complet, graine `0` | 8/8 tests requis réussis ; 14 min 49 s, dont le relevé complet |
|
||||
| Serveur neuf, graine `42` | 8/8 réussis ; 1 min 3 s |
|
||||
| Serveur neuf, graine `8675309` | 8/8 réussis ; 1 min 4 s |
|
||||
| Test client facultatif | Réussi en 23 s ; les deux injections de rendu sont appliquées |
|
||||
|
||||
La graine `0` place le spawn naturel en `(0, 251, -4)` ; la graine `42` en
|
||||
`(0, 249, 0)` et la graine `8675309` en `(0, 247, 0)`. Les tests confirment les 384 blocs du nouveau monde, les nuages
|
||||
relevés, le vide inférieur et extérieur et la conservation des anciens réglages.
|
||||
Les coupes des trois graines ont été inspectées : l'ancienne silhouette est
|
||||
relevée et le dessous devient irrégulier. Une comparaison supplémentaire de
|
||||
1 039 155 échantillons de l'ancienne géométrie refactorée donne des densités
|
||||
identiques bit à bit à l'alpha.2.
|
||||
|
||||
Le relevé complet constate **8 584 514 blocs non vides** dans les 1 444 chunks
|
||||
de l'enveloppe définie. Sa préparation prend 13 min 2,659 s, son comptage et sa
|
||||
préparation JSON 135 ms, sur un Apple M1 avec 8 Go de mémoire. Ce poste exécute
|
||||
d'autres applications et a aussi exécuté le test client pendant le relevé :
|
||||
c'est une mesure locale, pas une promesse de performance. Les quantités exactes
|
||||
et les exclusions figurent dans [la référence de ressources](expansion.md).
|
||||
|
||||
Les journaux sont `build/alpha3-validation-seed0.log`,
|
||||
`build/alpha3-validation-seed42.log`, `build/alpha3-validation-seed8675309.log`
|
||||
et `build/alpha3-client-test.log`. Le JAR alpha.3 a été inspecté : aucune classe
|
||||
de test n'est distribuée. Le `.mrpack` contient exactement le JAR vérifié.
|
||||
|
||||
Le test client valide le chargement et l'application des mixins. Il n'ouvre
|
||||
aucun monde et ne remplace donc pas l'essai visuel en vol décrit plus haut.
|
||||
|
||||
## Historique : validation alpha.2 — 8 septembre 2026
|
||||
|
||||
Commande `./gradlew check build assemblePack --console=plain` réussie en **1 min 10 s**
|
||||
sur Java **25**, Minecraft **26.3-pre-2**, Fabric Loader **0.19.5**, Fabric API
|
||||
**0.160.0+26.3** et Loom **1.17.20**, pour Sanctuary **0.1.0-alpha.2**.
|
||||
Les deux commandes supplémentaires de graines ont ensuite été exécutées
|
||||
séquentiellement, chacune sur un monde de développement neuf.
|
||||
|
||||
| Graine | Spawn naturel initial | Tests requis | Durée des tests | Durée Gradle |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0` | `(-4, 187, -4)` | 5/5 réussis | 51,40 s, diagnostics compris | 1 min 10 s, build et pack compris |
|
||||
| `42` | `(0, 185, 0)` | 5/5 réussis | 1,843 s | 20 s |
|
||||
| `8675309` | `(0, 183, 0)` | 5/5 réussis | 1,681 s | 17 s |
|
||||
|
||||
Les cinq tests requis comprennent les quatre tests Sanctuary et le test du
|
||||
framework. Pour chacune des trois graines, le spawn possède naturellement une
|
||||
zone sèche de 3×3 avec deux blocs libres ; les douze chunks extérieurs inspectés
|
||||
au statut `FULL` sont vides. Aucun repli sur une colonne seule n'a été nécessaire.
|
||||
|
||||
- Les invariants `WorldgenSmoke`, le chargement des codecs et registres, la
|
||||
reproductibilité des densités et l'absence de matière ajoutée par la sculpture
|
||||
ont été vérifiés.
|
||||
- Les trois diagnostics PNG et CSV ont été produits avec la densité actuelle.
|
||||
Leur calcul synchrone explique la durée plus longue du test de graine `0` et
|
||||
son avertissement serveur « Can't keep up » ; ce diagnostic est absent du mod
|
||||
distribué.
|
||||
- La compilation du JAR, l'assemblage dans `build/packwiz/` et la vérification des
|
||||
versions et empreintes du pack ont réussi.
|
||||
- Le JAR `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.2.jar` ne contient aucune
|
||||
classe ni configuration de mixin GameTest.
|
||||
|
||||
Les journaux de cette validation sont conservés localement dans
|
||||
`build/alpha2-validation-seed0.log`, `build/alpha2-validation-seed42.log` et
|
||||
`build/alpha2-validation-seed8675309.log`. Les journaux courants de Minecraft sont
|
||||
dans `mods/sanctuary/build/run/gameTest/logs/` et sont remplacés au prochain test.
|
||||
Ces preuves et les diagnostics restent ignorés par Git. Les vérifications
|
||||
manuelles ci-dessous restent à effectuer ; les tests n'ont ouvert ni modifié
|
||||
aucune sauvegarde de joueur.
|
||||
|
||||
La fondation alpha.1 avait été validée le même jour sur la graine `0`, avec un
|
||||
spawn `(0, 118, 0)` reposant sur l'ancien noyau imposé. Ce résultat historique
|
||||
ne décrit plus la génération alpha.2.
|
||||
|
||||
## Vérifications manuelles restantes
|
||||
|
||||
- Créer un monde avec le preset Sanctuary dans le client et évaluer visuellement
|
||||
la côte, les trous, les surplombs, la végétation et la lecture du vide.
|
||||
- Rejoindre à plusieurs joueurs, mourir et réapparaître ; les tests headless ne
|
||||
simulent pas de connexion client ni les offsets de réapparition des joueurs.
|
||||
- Modifier le spawn administrateur, arrêter puis recharger le monde et vérifier
|
||||
sa conservation. Le test automatique couvre le premier démarrage, pas un cycle
|
||||
de sauvegarde et de redémarrage complet.
|
||||
- Explorer plusieurs seeds dans le client. Les tests compilent trois seeds et
|
||||
chaque invocation génère des chunks complets pour la seed demandée ; leur
|
||||
échantillonnage extérieur ne constitue pas une inspection exhaustive de toutes
|
||||
les coordonnées.
|
||||
- Tester les resource packs, shaders et mods communautaires après leur ajout et
|
||||
vérification de compatibilité avec la version exacte du pack.
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
# Sanctuary — vision du projet
|
||||
|
||||
Ce document conserve les intentions exprimées au démarrage de Sanctuary Beta. Il décrit une **destination de conception**, pas une liste de fonctionnalités déjà livrées. Le code, les tests et les notes de version font foi pour l'état réel du mod. Les valeurs d'équilibrage ci-dessous sont des propositions initiales à éprouver en jeu.
|
||||
|
||||
La cible demandée est **Minecraft 26.3 avec Fabric**. Au démarrage du dépôt, le 8 septembre 2026, la base disponible retenue est **26.3-pre-2** ; les versions effectivement utilisées restent indiquées dans la configuration de construction. La première étape est d'initialiser proprement le projet, puis de retrouver et d'adapter les idées pertinentes du générateur de l'ancienne version 26.2.
|
||||
|
||||
## Le projet en quelques mots
|
||||
|
||||
> Sanctuary is a free expansion of Minecraft that reshapes the game around a floating world. Players begin together on Sanctuary Island, isolated in the void. By exploring, building infrastructure and producing resources, they progressively unlock new floating continents, each with its own geography. Rather than a linear campaign, the server itself becomes a world that grows through the action of its players.
|
||||
|
||||
Sanctuary transforme Minecraft en un monde flottant d'exploration, de production et de progression collective. Tous les joueurs commencent sur l'île de Sanctuary, isolée dans le vide. Leurs constructions, leurs explorations et leurs chaînes de production permettent d'ouvrir de nouveaux continents suspendus.
|
||||
|
||||
L'objectif mythologique est de progresser d'une condition très limitée, proche du hardcore, vers les possibilités du mode créatif, par la coopération. Le serveur conserve l'histoire de cette transformation : les continents ouverts, les ressources produites, les événements, les étoiles et les constellations. Battre l'Ender Dragon devient un événement parmi d'autres, et non la conclusion obligatoire d'une campagne.
|
||||
|
||||
## Principes de conception
|
||||
|
||||
- **Un monde construit par ses habitants.** Les efforts collectifs doivent avoir des effets durables et visibles sur le serveur.
|
||||
- **Des objets et des gestes dans le monde.** Favoriser panneaux, ordinateurs, cloches, bannières, télescopes et infrastructures pour accéder aux systèmes. Ajouter un menu quand il sert réellement l'interaction.
|
||||
- **Coopération et initiative individuelle.** Permettre les projets communs sans effacer les possessions, les collections, les voisinages ni les constructions personnelles.
|
||||
- **Une progression par l'expérience et la production.** L'XP sert aux capacités, aux transports et aux actions créatives ; les ressources et les métiers soutiennent l'expansion.
|
||||
- **Un monde aux ressources localisées.** Il faut partir explorer pour trouver certains biomes, cultures, structures et matériaux.
|
||||
- **Humour, surprise et souvenirs.** Les noms d'entités, les événements, les animaux rares et les objets insolites comptent autant que les systèmes économiques.
|
||||
- **Un développement par tickets.** Livrer une petite mécanique jouable et vérifiable, puis l'ajuster avec des tickets de fonctionnalités et de bugs. Ne pas construire tous les systèmes avant de pouvoir jouer.
|
||||
|
||||
## Périmètre de départ
|
||||
|
||||
Le premier incrément porte sur le socle Fabric et la génération du monde : retrouver le générateur 26.2, produire une île principale dans un vrai vide, assurer une arrivée commune et sûre, puis poser une manière reproductible de générer des continents à une distance, une direction et une taille choisies.
|
||||
|
||||
La progression, l'économie, les nouveaux monstres, les interfaces de pack et les autres dimensions restent des intentions futures. Ils ne constituent pas des dépendances du premier générateur. Le [backlog](backlog.md) précise les premiers tickets et leurs critères d'acceptation.
|
||||
|
||||
## Mod, modpack et modules autonomes
|
||||
|
||||
| Ensemble | Rôle souhaité |
|
||||
| --- | --- |
|
||||
| **Sanctuary, le mod** | Monde flottant, expansion, progression collective et systèmes propres à cet univers. |
|
||||
| **Sanctuary, le modpack** | Distribution avec packwiz, configurations, mods communautaires, resource packs et shader packs. |
|
||||
| **It's Alive !** | Mod de cuisine et d'agriculture autonome, raccordable aux biomes, événements et déblocages de Sanctuary. |
|
||||
| **Only Fun** | Mod autonome consacré aux interactions humoristiques, aux anniversaires et à des activités sociales. |
|
||||
| **Master Key** | Bibliothèque d'outils et de permissions d'administration destinée aux différents mods, afin d'administrer et de réparer sans devoir jouer en créatif. |
|
||||
|
||||
L'apparence du pack peut inclure les mentions affichées, les crédits, l'icône, l'écran de chargement et des adaptations visuelles. Les licences et attributions des composants restent attachées à leurs ayants droit ; choisir une licence pour le travail original est une décision distincte de personnaliser son habillage.
|
||||
|
||||
Les intégrations communautaires envisagées comprennent Fabric API, Sodium, Iris, Vitrail, Indium, Just Enough Items, Simple Voice Chat, Xaero's Minimap et Dynamic Torches. Golden Days est envisagé pour l'apparence du monde Alpha, et Litematica pour les plans de construction. Ce sont des pistes d'intégration : la disponibilité sur la version cible, les noms exacts, les dépendances et les conditions de distribution doivent être vérifiés au moment de chaque ticket.
|
||||
|
||||
**TerraMix** désigne ici le catalogue natif de 100 biomes d'**Another World**, retrouvé dans l'historique 26.2. Sa reprise et son extension sont une piste pour les continents ; ce nom ne désigne pas une nouvelle dépendance externe obligatoire.
|
||||
|
||||
## Monde flottant et expansion
|
||||
|
||||
### Sanctuary Island et continents
|
||||
|
||||
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
|
||||
peuvent accueillir de la canne à sucre. Lorsque le relief le permet, une grande
|
||||
rivière suit la forme de l’île, depuis une source jusqu’à une cascade et un
|
||||
bassin, avec des rives de sable, de gravier et d’argile. Ce parcours doit former
|
||||
un lieu reconnaissable ; il reste facultatif. À défaut, l’île conserve ses
|
||||
petits bassins, sans imposer de courts ruisseaux. Le terrain est traité près de la surface,
|
||||
avec des dépôts épais et des rives variées. De rares sources sortent de niches
|
||||
rocheuses naturelles et leurs cascades peuvent descendre jusque dans le vide ;
|
||||
les bassins restent retenus et ne doivent pas provoquer une inondation globale.
|
||||
Une rivière peut avoir des échappements et des chutes déclarés. Les eaux
|
||||
intérieures doivent rester localisées et retenues, sans aquifère qui remplit
|
||||
globalement les cavités. Des bassins naturels étagés, évoquant des rizières,
|
||||
peuvent se relier par des cascades et offrir des passages verticaux par la nage.
|
||||
Cette image ne prévoit aucune culture de riz dans le mod Sanctuary.
|
||||
Le terrain doit laisser voir un dessous sculpté et dégagé, dans un monde de
|
||||
**384 blocs de hauteur**, avec des
|
||||
nuages relevés et sans disque noir artificiel apparaissant sous un seuil d'altitude.
|
||||
|
||||
L’ambiance de départ doit rester douce et tempérée, avec de véritables forêts
|
||||
et des clairières fleuries, quelques zones plus sèches et de larges masses
|
||||
rocheuses mêlant pierre, andésite, diorite et granite. Les nappes de sable et
|
||||
de gravier accompagnent les eaux. Charbon, fer et cuivre doivent aussi pouvoir
|
||||
affleurer sur la roche exposée pour commencer la survie, avec du charbon assez
|
||||
repérable pour fournir les premières expériences. Les failles doivent ouvrir
|
||||
des passages courbes et des vues vers les profondeurs, sans reconstruire une
|
||||
masse artificielle au centre de l’île.
|
||||
|
||||
La géologie change en descendant : tuf, cobblestone, pierres moussues et boue
|
||||
compactée dans les intérieurs humides, puis ardoise des abîmes, roche noire et
|
||||
basalte. Des poches de soufre doivent être réellement explorables et rester
|
||||
ponctuelles, avec de vrais geysers actifs lorsque les conditions s’y prêtent.
|
||||
Quelques sources profondes peuvent former des coulées de lave ; elles ne doivent pas
|
||||
transformer les profondeurs en grands bassins remplis. L’émeraude rejoint les
|
||||
minerais à rechercher, avec l’or, le diamant, la redstone et le lapis.
|
||||
|
||||
Les bois dominants sont le chêne, le chêne noir et le bouleau : chênes et
|
||||
bouleaux en surface, chênes noirs et gros champignons dans l’intérieur plus
|
||||
humide, avec de petits champignons dispersés et des tapis variés de mousse,
|
||||
podzol et mycélium. Épicéa, jungle, acacia, palétuvier et chêne pâle restent rares
|
||||
mais possibles ; du bambou peut se rencontrer dans les profondeurs. Un arbre remarquable isolé peut être un
|
||||
cerisier rose ou une autre de ces essences rares, lorsque le terrain convient.
|
||||
Les biomes très contrastés de Minecraft 26.3, dont Dappled Forest, restent
|
||||
réservés aux futurs continents.
|
||||
|
||||
Les cavités sous l’île doivent aussi accueillir des ambiances luxuriantes
|
||||
avec de la végétation lumineuse, des secteurs à stalactites et stalagmites et
|
||||
des géodes d’améthyste. Leur décor doit utiliser les sols et plafonds du relief,
|
||||
sans reconstruire l’île ni déposer de structures flottantes sans support.
|
||||
L’alpha.9 introduit ces ambiances et recherche des lacs plus profonds et
|
||||
volumineux ; les résultats et leurs limites sont documentés dans les contrôles moteur. Ces cavités appartiennent à
|
||||
l’Overworld de Sanctuary et ne remplacent pas le futur projet de dimension
|
||||
des cavernes.
|
||||
|
||||
L’île doit offrir les ressources nécessaires aux premières étapes de Minecraft,
|
||||
en quantités modestes pour pousser à explorer les expansions lors des grands
|
||||
projets. Les minerais doivent être trouvables aux altitudes du terrain. Les
|
||||
stocks sont observés après génération et les ressources renouvelables gardent
|
||||
leurs règles Minecraft. Le réglage précis de ces quantités reste à éprouver en
|
||||
partie coopérative. La recherche de l’alpha.8 porte notamment sur une rivière
|
||||
calme continue reliant deux bassins au même niveau, alimentée par une source
|
||||
en paroi et sa cascade. Son tracé doit former de larges courbes, avec une
|
||||
largeur variable et des berges progressives. La petite réserve de lave est
|
||||
recherchée d’abord dans les étages profonds ; un site plus haut reste possible
|
||||
si la roche ne fournit pas de niche compatible en dessous. Ces formes restent
|
||||
facultatives selon le relief ; leur existence doit être démontrée en jeu.
|
||||
L’état de chaque incrément et ses limites
|
||||
figurent dans [Génération](worldgen.md).
|
||||
|
||||
Le premier champ de recherche des expansions associe les **huit directions**
|
||||
autour de Sanctuary à des signatures de température et d'humidité. En regardant
|
||||
la carte avec le nord en haut : **nord froid, sud chaud, ouest sec et est humide**.
|
||||
Les diagonales combinent ces tendances. La recherche porte sur des climats et
|
||||
leurs distributions, sans imposer la même forme de continent à chaque direction.
|
||||
|
||||
L'économie naturelle d'une île doit être connue **après sa génération** : nombre
|
||||
de blocs de pierre, bûches, minerais de charbon, de fer et autres ressources,
|
||||
avec une emprise et un état de référence précis. Ce relevé constate ce que la
|
||||
génération a produit ; il ne force pas des quotas de ressources et ne choisit pas
|
||||
une seed pour atteindre un stock prédéterminé. Un relevé partiel doit rester
|
||||
identifié comme tel. Le contrat de recherche et de relevé est détaillé dans
|
||||
[Expansion et ressources](expansion.md).
|
||||
|
||||
Leur géographie doit rester variée : perforations et passages de vide, océans et lacs retenus dans le terrain, rivières flottantes, montagnes, ravins et reliefs lisibles depuis les airs. Le système doit pouvoir conserver les constructions et les chunks déjà explorés lorsqu'une nouvelle expansion est ouverte.
|
||||
|
||||
L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue TerraMix d'Another World, avec des biomes originaux comme le **Black Desert**. Cette diversité sera introduite après la validation du terrain de base et de son adaptation technique.
|
||||
|
||||
### Structures et territoires
|
||||
|
||||
- **Lost Cities** : des zones traitées comme des biomes, où des bâtiments en ruine remplacent la végétation dominante. Le nom exprime ici un type de territoire souhaité, sans décider encore s'il sera produit par une intégration ou par du code Sanctuary.
|
||||
- **Structures contemporaines** : parcs, parkings et autres vestiges. Les parkings sont souterrains et peuvent s'étendre comme une infestation. Ils n'apparaissent pas dans la dimension des cavernes.
|
||||
- **Structures uniques et donjons** : d'abord sur Sanctuary Island, puis dans les continents. Ils peuvent contenir des clés de chunk et d'autres ressources rares.
|
||||
- **Secret rooms et challenge rooms** : des accès présents dans le monde mènent à des indoors aménagés, avec des équipements, des épreuves ou des gisements de fer, d'or, d'argent ou de titane.
|
||||
|
||||
### Dimensions
|
||||
|
||||
| Dimension | Intention |
|
||||
| --- | --- |
|
||||
| **Cavernes** | Monde destiné au minage, avec des biomes souterrains, sans mineshafts ni parkings. Des cités anciennes particulièrement difficiles accueillent plusieurs types de squelettes et un nécromancien illageois qui les invoque. Il détient une boule de cristal. |
|
||||
| **Alpha** | Monde inspiré de Minecraft Indev et des premières versions, avec les anciennes textures de Golden Days actives uniquement dans cette dimension. Notch y est un boss. |
|
||||
| **Backrooms** | Monde sombre à plusieurs niveaux, exploré avec une source de lumière. Les joueurs y arrivent dans un lit. Il reçoit dans des coffres les objets perdus dans le vide ou brûlés ; c'est aussi un territoire d'exploration et un lieu de quête. |
|
||||
| **Indoors** | Petits espaces associés à des objets, déclinés en classes de volume, avec une taille indicative allant de 5 × 5 × 5 à 100 × 100 × 100 blocs. Ils peuvent servir d'intérieurs, de salles secrètes ou de lieux d'événements. |
|
||||
|
||||
La géométrie des indoors reste à décider : certains peuvent être des volumes précisément délimités, d'autres des espaces fermés qui se rebouclent en trois dimensions, de type tore 3D. Leurs objets d'accès sont liés à la mailbox de leur propriétaire et doivent être récupérables s'ils sont perdus. Les règles de propriété, de partage, de transfert et de sortie sûre seront définies avant leur implémentation.
|
||||
|
||||
## Progression personnelle et collective
|
||||
|
||||
### Capacités initiales
|
||||
|
||||
Le joueur commence avec trois cœurs, trois icônes de nourriture, une capacité respiratoire réduite, une vitesse de minage très faible et l'impossibilité de courir. L'armure conserve son fonctionnement habituel.
|
||||
|
||||
L'XP et les niveaux permettent d'acheter des améliorations. L'exemple de courbe souhaitée double les coûts : **1, 2, 4, 8, 16, 32, 64 niveaux**. Les valeurs doivent être testées séparément pour chaque capacité ; il reste notamment à confirmer si chaque achat consomme des niveaux entiers ou une quantité d'XP équivalente.
|
||||
|
||||
Les capacités peuvent atteindre les valeurs usuelles de dix cœurs, dix icônes de nourriture et une respiration complète par la progression normale en XP. Le nombre d'étapes et les plafonds seront arrêtés dans les tickets concernés.
|
||||
|
||||
### Inventaire, prestige et déblocages
|
||||
|
||||
L'inventaire commence à une rangée et peut s'étendre jusqu'à six par la progression normale. La place de la barre rapide dans ce comptage reste à préciser.
|
||||
|
||||
Les **prestiges débloquent des slots de factions**. Ils ne donnent pas de capacités bonus ni de rangées d'inventaire supplémentaires. Le nombre de slots accordés par prestige et leurs règles d'utilisation seront précisés dans les tickets de factions.
|
||||
|
||||
La progression peut débloquer une table de fabrication dans l'inventaire, des capacités de minage et de construction, et l'accès à certaines fonctions de mods communautaires. Les joueurs peuvent également porter des capes et des familiers ; les spawn eggs correspondent à différents pouvoirs de familier.
|
||||
|
||||
Les recettes de Just Enough Items sont révélées par paliers liés aux advancements. Les advancements Minecraft donnent accès aux recettes Minecraft ; ceux de Sanctuary ouvrent les blocs décoratifs, équipements et systèmes correspondants. Il faudra distinguer dans chaque ticket l'affichage des recettes, leur connaissance et l'autorisation réelle de fabriquer.
|
||||
|
||||
### Construction et production
|
||||
|
||||
- **Mining** améliore la vitesse de minage et ouvre le vein mining : extraction de blocs d'une même veine ou sur un plan défini par l'orientation du joueur.
|
||||
- **Building** ouvre des outils de pose et de remplissage de surfaces, dont le vein building.
|
||||
- **Plans et préfabriqués** : conserver et copier ses constructions, puis accéder à un catalogue de prefabs. Une intégration adaptée de Litematica est envisagée. Un ancien concept de « voxelier » dans un ordinateur permettait de créer des modèles en cubes ; cette piste est considérée comme complexe et n'est pas prioritaire.
|
||||
|
||||
## Monnaies, propriétés et échanges
|
||||
|
||||
### Les trois gemmes
|
||||
|
||||
Les trois gemmes forment la palette et la symbolique triangulaire de Sanctuary.
|
||||
|
||||
| Gemme | Fonction souhaitée |
|
||||
| --- | --- |
|
||||
| **Émeraude** | Économie locale des villageois, échanges et travail dans le monde. |
|
||||
| **Rubis** | Monnaie des échanges passant par les services du serveur et ses boutiques. Nouveau minerai à extraire. |
|
||||
| **Saphir** | Réservation et sauvegarde de quantités limitées d'objets dans le catalogue. Nouveau minerai à extraire. |
|
||||
|
||||
### Mailbox, dépôts et boutiques
|
||||
|
||||
Chaque joueur a une **mailbox** et le serveur dispose de **deposit boxes** pour les apports collectifs. Les achats autorisés sont livrés dans la mailbox. Les échanges doivent pouvoir être reliés à la progression du serveur et aux ressources qu'il conserve.
|
||||
|
||||
Le shop propose des offres flash renouvelées toutes les heures. Le joueur peut débloquer jusqu'à neuf emplacements avec son XP. Le black market accueille les offres des joueurs ; le catalogue permet de réserver une quantité limitée d'un objet contre des saphirs. Des extensions et emplacements supplémentaires peuvent apparaître comme récompenses d'événements.
|
||||
|
||||
Le shop pourrait devenir une infrastructure coûteuse à construire, accessible physiquement et située en fin de progression. Sa forme exacte reste ouverte : bâtiment joueur, service du serveur ou combinaison des deux. Des casinos événementiels peuvent être installés dans de grands indoors où les joueurs se retrouvent.
|
||||
|
||||
### Bourse du navet
|
||||
|
||||
Les **navets** s'achètent **uniquement le dimanche, lors de la loterie**. Pendant la semaine, les joueurs peuvent les revendre au shop au cours variable de la **bourse du navet**. Aucun achat de navets n'est proposé les autres jours.
|
||||
|
||||
### Coffre-fort, équipes et braquage
|
||||
|
||||
Chaque joueur peut posséder un seul coffre-fort. Il contient plus de gemmes qu'un inventaire et permet un porte-monnaie utilisable en jeu. Le coffre doit pouvoir être caché et peut être percé avec une **drill en titane**.
|
||||
|
||||
Chaque joueur a une couleur unique, qui sert de couleur d'équipe. Des joueurs peuvent se fédérer en équipes temporaires, factions ou voisinages et partager les gains d'une action, y compris d'un braquage. Les slots de factions se débloquent grâce aux prestiges. Les conditions d'accès, les protections et l'équilibrage entre coopération et conflit devront être explicités dans ces tickets.
|
||||
|
||||
## Villageois et automatisation
|
||||
|
||||
Une bannière placée au-dessus d'une cloche associe un village à une faction. Faire sonner la cloche permet de mettre à jour l'appartenance des villageois concernés à l'équipe associée. Le rayon, le choix de la bannière et les conflits de cloches restent à spécifier.
|
||||
|
||||
Les villageois peuvent être payés en émeraudes pour réaliser des tâches cohérentes avec leur métier vanilla. Trois secteurs se complètent : **récolte**, **transformation** et **services**. Ils peuvent, par exemple, récolter du blé, le déposer dans une boîte ou le moudre. Les copper golems et la redstone participent au transport et aux chaînes de production.
|
||||
|
||||
Les **conveyor belts**, fabriqués notamment avec du cuir de vache, déplacent les objets sous forme de drops dans le sens de pose du bloc. Ils se combinent avec les droppers pour acheminer les ressources sur des distances importantes.
|
||||
|
||||
## Machines, équipements et redstone
|
||||
|
||||
- **Ordinateur 8 bits** : bloc programmable avec six ports d'entrée et de sortie, permettant de construire ses propres comportements de redstone. Des évolutions en contrôleur et en ordinateur d'interface donnent accès, dans le monde, aux systèmes d'expansion.
|
||||
- **Terminal et storage en titane** : stockage de fin de progression, avec jusqu'à 128 coffres connectés consultables par un terminal commun, afin de déposer et retrouver ses objets sans tri manuel constant.
|
||||
- **Chunky et clé de chunk** : bloc fabriquable maintenant un chunk actif. Son activation requiert une clé trouvable dans les donjons et structures, afin que le chargement permanent ait une valeur d'exploration et d'échange. L'orthographe des noms sera fixée à partir de l'historique.
|
||||
- **Particuleur** : émetteur de particules dont l'effet dépend de l'objet inséré et dont l'intensité dépend du signal de redstone.
|
||||
- **Caméra** : photographie le jeu et transforme les images en cartes Minecraft utilisables dans les item frames.
|
||||
- **Œil d'araignée (spider eye)** : révèle les niveaux de lumière dans le monde, pour visualiser l'intensité de l'éclairage.
|
||||
- **Disque blanc** : renommé avec une référence à une vidéo YouTube, il permet d'en jouer le contenu dans un jukebox. La forme exacte de la référence et le comportement audiovisuel restent à décider.
|
||||
- **Équipements en titane d'anomaly** : équipements impossibles à fabriquer, indestructibles, avec des enchantements exceptionnellement forts. Les machines fabriquables en titane forment une catégorie distincte.
|
||||
|
||||
### Armes et explosifs
|
||||
|
||||
L'argent, appelé aussi **silver**, est le métal des armes de précision et de certaines technologies :
|
||||
|
||||
- Le **creeper lock** consomme de la poudre à canon pour une attaque explosive de courte portée.
|
||||
- La **mitraillette** repousse les monstres à moyenne portée. Elle emploie des munitions métalliques de puissance variable ; le choix précis entre pépites et lingots reste à fixer.
|
||||
- Le **mining rifle** est une arme de longue portée utilisant des minerais.
|
||||
|
||||
Ces armes peuvent recevoir des enchantements adaptés. Les nouveaux explosifs comprennent une **Weather TNT** qui dissipe la pluie, une **Fragment TNT** qui libère plusieurs TNT, une **Randomizer TNT** qui redistribue les blocs autour d'elle, et une **Mega TNT** plus puissante.
|
||||
|
||||
Les **lucky blocks** déclenchent volontairement un événement imprévisible, parfois très favorable, parfois franchement nuisible : récompenses rares, mort, poison, sorcières, lave ou dégâts causés par des explosifs. Leur risque et leur injustice font partie du concept ; la zone et les conditions de ces effets seront fixées dans le ticket avant leur ajout à un serveur partagé.
|
||||
|
||||
## Déplacements et interactions
|
||||
|
||||
| Système | Comportement souhaité |
|
||||
| --- | --- |
|
||||
| **Waystones horizontales** | Voyager dans une même dimension vers une waystone connue, avec un coût d'XP croissant selon la distance. |
|
||||
| **Waystones verticales** | Ascenseurs : sauter pour monter, se baisser pour descendre. L'étage de destination n'a pas besoin d'avoir été découvert. |
|
||||
| **Téléporteur longue distance** | Préparation plus lente, mais grandes distances possibles, avec une arrivée approximative. |
|
||||
| **Zipline** | Corde reliant deux installations, jusqu'à une distance indicative de 512 blocs. Un clic droit lance le trajet, moins rapide qu'un minecart à pleine vitesse. |
|
||||
| **Grappling rod** | Viser un point pour s'y tirer, avec un risque réel de chute. Consomme des leads qui ne sont pas récupérés. |
|
||||
| **Bateau avec poule** | Aéronef simple obtenu en plaçant une poule dans un bateau, permettant notamment le transport de villageois. |
|
||||
| **Biplan** | Aéronef à deux places, plus rapide que le bateau volant, mais plus lent que les elytras. |
|
||||
| **Happy Ghast** | Monture volante à quatre places, avec une vitesse doublée. « Happy gust » a été prononcé dans la description ; l'identifiant exact est à vérifier. |
|
||||
| **Totem de Notch / Magic Carpet** | En tenant le totem, faire apparaître une plateforme de verre sous ses pieds pour marcher dans les airs et faciliter la construction. La taille de la plateforme, évoquée comme trois blocs, reste à fixer. |
|
||||
|
||||
Un sneak + clic droit permet de porter un mob sur sa tête, avec une limite d'un mob porté. Sur un autre joueur, le même geste permet de se placer sur sa tête. Un double sneak sans déplacement permet de s'asseoir.
|
||||
|
||||
## Faune, créatures et personnages
|
||||
|
||||
### Hostiles et fantômes
|
||||
|
||||
Une famille de zombies inspirée de Left 4 Dead comprend le **Hunter**, le **Charger**, le **Spitter**, le **Boomer** et l'**Infecté**. Le **Tank** et le **zombie géant** sont des boss futurs.
|
||||
|
||||
Le système d'insomnie et les phantoms vanilla disparaissent lorsque le monde passe au temps réel. Ils laissent place à d'autres fantômes, dont une **baleine volante, fantomatique et apaisante**, qui passe autour des joueurs et peut s'inviter dans le paysage comme un photobomb.
|
||||
|
||||
Un **Happy Creeper**, bienveillant, porte une fleur sur la tête et un visage différent. Des creepers rares ont également des apparences variées.
|
||||
|
||||
### Poules rares
|
||||
|
||||
Une poule ordinaire peut très rarement pondre un œuf en or. Les quatre variantes rares sont classées ainsi :
|
||||
|
||||
Les facteurs se cumulent d'une variété à la suivante. Si **T** est l'intervalle moyen entre deux pontes d'une poule ordinaire :
|
||||
|
||||
| Poule | Production | Fréquence de ponte | Intervalle moyen |
|
||||
| --- | --- | --- | --- |
|
||||
| Émeraude | Émeraudes | 2 fois moins souvent qu'une poule ordinaire | 2 × T |
|
||||
| Rubis | Rubis | 4 fois moins souvent que la poule émeraude | 8 × T |
|
||||
| Saphir | Saphirs | 8 fois moins souvent que la poule rubis | 64 × T |
|
||||
| Or | Œufs en or | 16 fois moins souvent que la poule saphir | 1 024 × T |
|
||||
|
||||
Ces quatre variantes **ne peuvent pas se reproduire**. Leur rareté doit préserver la valeur de la monnaie. Les joueurs les protègent avec des armures de poule inspirées des armures de loup, colorables, et peuvent les nommer. L'objectif est de créer de l'attachement et de l'attention à ces animaux.
|
||||
|
||||
### Mooblooms et noms d'entités
|
||||
|
||||
Les **Mooblooms** sont des variantes de vaches très rares associées aux fleurs, avec de petites et grandes versions. La liste initiale comprend hibiscus, narcisse, tulipes roses, orange, rouges et blanches, marguerite/oxeye daisy, tournesol, allium, houstonie/azure bluet, orchidée bleue, bleuet/cornflower, pissenlit, muguet, lilas, rose, rose bleue et coquelicot/poppy. Les libellés exacts, la variante « moobloom » générique et les équivalences linguistiques devront être alignés sur les ressources existantes.
|
||||
|
||||
La progression peut débloquer l'affichage de noms pour toutes les familles d'entités. Chaque famille a un registre humoristique : pseudos « kikoolol Xbox 360 » pour les zombies, alphabet galactique Minecraft pour les Endermen, et des noms variés pour les animaux.
|
||||
|
||||
### Végétation propre à Sanctuary
|
||||
|
||||
Nouveaux bois envisagés : **lavande, ébène, mossy et blueberry**. De nouvelles fleurs comprennent les roses et les roses bleues. Les formes finales des arbres et les noms de registre restent à définir.
|
||||
|
||||
## Temps réel, calendrier, événements et ciel
|
||||
|
||||
L'heure du jeu suit l'heure réelle : à 8 h du matin sur le serveur, il est 8 h dans le monde. L'administrateur choisit le fuseau ou le décalage horaire. Le sommeil ne sert donc plus à faire avancer la nuit, ce qui libère un rôle pour le lit comme entrée dans les Backrooms.
|
||||
|
||||
Un calendrier expose des nombres de jours simples : âge du serveur depuis sa création et nombre de jours écoulés depuis une origine historique de Minecraft. La date exacte de cette origine reste à choisir. Cette chronologie fait partie de la mythologie de Sanctuary.
|
||||
|
||||
Des panneaux d'événements permettent de proposer une activité et de s'inscrire, en lien avec les factions. Des anniversaires issus d'Only Fun peuvent y être reliés. Des événements suivent un cycle évoqué comme six jours actifs et un septième jour de repos ou de fête ; la durée et l'ancrage hebdomadaire doivent être confirmés.
|
||||
|
||||
Une **loterie du dimanche** permet de collecter des tickets pendant la semaine. Le nombre de tickets augmente les chances de récompense : lootboxes anomaly, emplacements du catalogue ou extensions du shop. Les probabilités et le financement des récompenses doivent préserver la rareté des ressources.
|
||||
|
||||
La loterie du dimanche accueille aussi l'unique occasion hebdomadaire d'acheter les navets de la [bourse du navet](#bourse-du-navet), revendables ensuite au shop pendant la semaine.
|
||||
|
||||
### Étoiles et constellations
|
||||
|
||||
Les premières nuits n'ont pas d'étoiles. Les advancements Minecraft et Sanctuary font naître progressivement des familles d'étoiles communes à tout le serveur. Avec une spyglass, le joueur peut observer une étoile, son nom et l'accomplissement qui l'a fait apparaître.
|
||||
|
||||
Il peut tracer des constellations depuis son point de vue, directement dans le ciel, sans ouvrir une interface séparée. Ajouter ou retirer un point coûte un niveau dans l'intention initiale ; il faudra préciser la différence entre point et segment lors de l'implémentation. Les constellations sont visibles par tous. Le ciel tourne plus lentement que le cycle quotidien, de sorte que les nuits évoluent au fil des jours.
|
||||
|
||||
## Quêtes, récompenses et cosmologie
|
||||
|
||||
### Panneaux et lootboxes
|
||||
|
||||
Les panneaux de quêtes utilisent les trois gemmes et leurs couleurs : vert émeraude, rouge rubis, bleu saphir, pour trois paliers de difficulté. Le nombre de quêtes réalisables est limité par heure. Le joueur voit les récompenses avant de choisir ; les quêtes donnent notamment de l'XP et des lootboxes.
|
||||
|
||||
Quatre catégories de lootboxes sont prévues : **normales**, **capes**, **spawn eggs** et **anomaly**. Les anomaly peuvent contenir les équipements en titane impossibles à fabriquer.
|
||||
|
||||
### Le cube originel et les sept boules
|
||||
|
||||
Sanctuary naît d'un **cube originel**, auquel le joueur reste secrètement lié et dont découle sa progression. Une mythologie cachée, inspirée d'une alchimie et d'une cabale minecraftiennes, relie ce cube à sept boules de cristal : six directions ou pointes et un centre. La référence visuelle évoque à la fois une figure à six pointes et les sept Dragon Balls.
|
||||
|
||||
Les objectifs déjà évoqués sont :
|
||||
|
||||
- **Boule de la fortune** : réunir une grande quantité de richesse, à calibrer pour un serveur pensé, par exemple, pour un mois de jeu.
|
||||
- **Boule du cauchemar** : accomplir dans les Backrooms une action consistant à enfermer un mob particulier.
|
||||
- **Boule de Notch** : vaincre Notch dans le monde Alpha ; cette rencontre donne aussi le totem qui active la Magic Carpet.
|
||||
- **Boule de la cité ancienne** : vaincre ou résoudre la rencontre du nécromancien dans les cavernes.
|
||||
|
||||
Les trois autres boules, les noms définitifs et la manière de les réunir restent à concevoir. L'Ender Dragon, Notch, le zombie géant, le premier burger et le premier sushi peuvent tous devenir des événements de l'histoire commune, à des échelles différentes.
|
||||
|
||||
## It's Alive ! — cuisine et agriculture
|
||||
|
||||
It's Alive ! est un mod autonome de cultures, de préparation et de transformation alimentaire. Son intégration à Sanctuary apporte des raisons d'explorer, de commercer et de produire ensemble.
|
||||
|
||||
Les cultures et ingrédients se trouvent dans des biomes appropriés à leur température et à leur humidité. Tout ne pousse pas et ne se trouve pas partout. Certaines variations de récolte suivent une logique comparable à la recherche de baies dans Cobblemon.
|
||||
|
||||
### Contenus envisagés
|
||||
|
||||
| Famille | Exemples issus de la vision initiale |
|
||||
| --- | --- |
|
||||
| Fruits et légumes | Fraise, tomate, ananas, maïs, concombre, poivron, aubergine, laitue, chou, raisin, haricot, piment, olive, citron, oignon, ail |
|
||||
| Céréales, herbes et arômes | Riz, soja, basilic, menthe, coriandre, café, vanille, thé |
|
||||
| Ingrédients transformés | Lait, tofu, beurre, crème, fromage, vinaigre, poudre — nature de cette dernière à préciser |
|
||||
| Fermentations et conserves | Vin, bière, kimchi, cornichons, yaourt, saucisson |
|
||||
| Plats et recettes | Chili, veloutés, soupes, omelettes, sautés, currys, salades, sushis, pain, sandwichs, burgers, gâteaux, préparations fourrées et pizzas |
|
||||
| Outils | Poêle, moulin, marmite, cuisinière, tonneau de macération et séchoir |
|
||||
|
||||
Le vin peut vieillir pour gagner en qualité. Le fromage et le saucisson peuvent également être affinés ; ce vieillissement n'est pas prévu pour la bière dans l'intention actuelle.
|
||||
|
||||
Des **pages culinaires** reconstituent le livre de recettes perdu de Steve. Il ne contient pas toutes les recettes : des sorcières gardent des **secret pages**, obtenues en les combattant, qui révèlent notamment certaines techniques de crème, beurre, vin et bière.
|
||||
|
||||
## Only Fun — interactions sociales et absurdes
|
||||
|
||||
Only Fun rassemble des interactions volontairement potaches : faire pipi, faire caca, vomir, fumer, cultiver du chanvre et célébrer les anniversaires de ses amis. Il peut fonctionner seul et se raccorder au calendrier, aux événements et aux factions de Sanctuary. Son contenu exact doit rester séparé du socle nécessaire pour jouer à Sanctuary.
|
||||
|
||||
## Master Key — administration
|
||||
|
||||
Master Key fournit des permissions et des outils d'administration partagés aux différents mods. L'objectif est qu'un administrateur puisse configurer le serveur, diagnostiquer et réparer une situation sans devoir activer le mode créatif pour jouer. Les actions de maintenance doivent avoir un périmètre clair, être contrôlées par le serveur et laisser des traces exploitables pour comprendre un incident.
|
||||
|
||||
## Décisions à prendre au fil des tickets
|
||||
|
||||
Les inconnues ne bloquent pas l'initialisation ni le terrain de base. Elles sont traitées au moment où elles influencent une fonctionnalité :
|
||||
|
||||
- Taille, altitude, relief, réserve de ressources et identité visuelle de Sanctuary Island.
|
||||
- Forme des continents, distances d'expansion, coûts collectifs et contrôle de leur ouverture.
|
||||
- Adaptation du catalogue TerraMix historique et compatibilité des mods communautaires avec la cible Fabric.
|
||||
- Courbe d'XP, lignes d'inventaire et distinction entre rangée et barre rapide ; conditions de passage des prestiges, nombre et fonctionnement des slots de factions débloqués.
|
||||
- Conditions de perte et de récupération des objets, durée de conservation dans les Backrooms et garantie d'unicité.
|
||||
- Nature et propriété des indoors, partage des accès et comportement à la déconnexion.
|
||||
- Règles des boutiques, du coffre-fort, du braquage et des équipes temporaires.
|
||||
- Identifiants définitifs, noms hérités, ressources réutilisables et licences associées.
|
||||
- Géométrie du ciel partagé et comportement des constellations selon la position du joueur.
|
||||
- Date d'origine du calendrier, rythme des événements et trois boules de cristal encore non décrites.
|
||||
|
||||
La prochaine décision concrète reste la même : obtenir un monde flottant stable et intéressant, puis l'éprouver en jeu avant d'ajouter les systèmes qui le feront grandir.
|
||||
@@ -0,0 +1,831 @@
|
||||
# Premier monde Sanctuary
|
||||
|
||||
Le preset `sanctuary:sanctuary` crée l'île principale à l'origine de l'Overworld,
|
||||
entourée de vide sans répétition d'îles à grande distance. C'est une première
|
||||
tranche de génération pour Minecraft **26.3-pre-2 / Fabric**, pas encore le
|
||||
système d'expansion collective.
|
||||
|
||||
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 :
|
||||
|
||||
```properties
|
||||
level-type=sanctuary:sanctuary
|
||||
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.
|
||||
|
||||
## Socle de génération conservé
|
||||
|
||||
Les points suivants décrivent le socle livré jusqu’à l’alpha.8. Les évolutions
|
||||
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`
|
||||
avec `xz_scale=0.25`, `y_scale=0.25`, `xz_factor=80`, `y_factor=160`, `smear=4`,
|
||||
ainsi que son gradient supérieur. Il est échantillonné **64 blocs plus bas**
|
||||
pour relever l'île entière de 64 blocs. Reliefs, surplombs, trous et fragments
|
||||
dépendent de la seed Minecraft. L’alpha.8 y soustrait des failles courbes,
|
||||
sans ajouter de matière. L'ancien gradient inférieur uniforme est
|
||||
remplacé par la sculpture irrégulière décrite ci-dessous.
|
||||
- Le terrain naturel reste libre de laisser un trou à l'origine, de former des
|
||||
surplombs ou de se séparer en fragments. **Aucun noyau ellipsoïdal, cylindre ou
|
||||
plateforme de départ n'est ajouté.** Le relief relevé de 64 blocs reste le
|
||||
champ source ; les failles de l’alpha.8 en retirent de la matière et
|
||||
l’hydrologie traite ensuite les lits et les rives près de la surface.
|
||||
- Le pourtour est sculpté par retrait de matière à partir du rayon **128 blocs**.
|
||||
Le contour de référence de 256 blocs est déformé par les bruits, entre 204 et
|
||||
288 blocs ; il indique la fin de l'érosion, pas un mur de roche.
|
||||
La côte réelle apparaît plus tôt, selon la densité du terrain et la seed.
|
||||
Aucun terrain ne réapparaît au-delà de **288 blocs**, sans répétition d'îles.
|
||||
Les arbres peuvent dépasser la côte rocheuse de quelques blocs.
|
||||
- Les nouveaux mondes utilisent **384 blocs de hauteur**, de `Y=0` à `Y=383`,
|
||||
pour la dimension et le générateur. La roche disparaît avant leurs limites.
|
||||
Il n'y a ni plancher de bedrock,
|
||||
ni mer globale, ni reprise du terrain à distance. Le Nether et l'End restent
|
||||
ceux de Minecraft.
|
||||
- Les nouveaux mondes utilisent une palette tempérée de forêts de chênes et de
|
||||
bouleaux, de clairières fleuries et de zones rocheuses. Les strates intérieures
|
||||
portent des chênes noirs et des champignons sur leurs corniches naturelles.
|
||||
Dappled Forest est réservé aux futurs continents. Leur végétation et leurs animaux proviennent
|
||||
de Minecraft 26.3-pre-2. Les nouveaux identifiants ne reprennent pas les tags
|
||||
de structures vanilla, afin de ne pas placer leurs structures dans le vide.
|
||||
- Jusqu’à l’alpha.8, les carvers, géodes, donjons, grands lacs de lave et sources
|
||||
vanilla sont retirés. L’alpha.9 ajoute une intégration bornée de géodes et
|
||||
de geysers, sans réactiver l’ensemble de la décoration souterraine vanilla.
|
||||
L’eau de Sanctuary suit son propre plan de surface et de niches
|
||||
rocheuses. Les arbres et les petits filons ont besoin du terrain existant.
|
||||
Aucun mod de biomes externe n’est nécessaire.
|
||||
|
||||
L’alpha.8 associe ces biomes aux plages, aux eaux et aux filons décrits
|
||||
ci-dessous. Les ressources de survie ne se limitent pas au bois et à la pierre,
|
||||
mais cet incrément ne garantit pas encore une partie Minecraft complète sur
|
||||
chaque seed. L’accès à chaque progression vanilla et les ressources renouvelables
|
||||
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
|
||||
`sanctuary:cavern_island` et la règle de matière `sanctuary:cavern_island`.
|
||||
La densité réutilise `sanctuary:final_density_rift` sans changer cet identifiant
|
||||
historique. Ses nouvelles données et traitements
|
||||
doivent rester distincts de `sanctuary:sanctuary_rift` et de toutes les
|
||||
générations précédentes. Le preset sélectionne la nouvelle génération à la
|
||||
création ; une mise à jour du JAR ne convertit pas une sauvegarde ni ne
|
||||
régénère ses chunks. Les cavités restent dans l’Overworld sur 384 blocs de
|
||||
hauteur ; la dimension des cavernes décrite dans la vision n’est pas ajoutée.
|
||||
|
||||
### Ambiances et ressources
|
||||
|
||||
Le ticket conserve les forêts de surface et ajoute des cavités luxuriantes
|
||||
éclairées par leur végétation, des secteurs à spéléothèmes et des géodes
|
||||
d’améthyste sur des emplacements compatibles. Les sols, plafonds, coquilles
|
||||
et ouvertures doivent être observés après la décoration réelle : une valeur
|
||||
de biome ne prouve ni le décor ni son éclairage. L’objectif est d’utiliser
|
||||
les volumes naturels de l’île, sans remplir ses trous pour accueillir une
|
||||
structure.
|
||||
|
||||
Les dix biomes ont leurs propres identifiants : `cavern_oak_forest`,
|
||||
`cavern_birch_forest`, `cavern_clearing`, `cavern_dry_woodland`,
|
||||
`cavern_rocky_heath`, `cavern_dark_grove`, `cavern_bamboo_grove`,
|
||||
`cavern_sulfur_depths`, `cavern_lush_caves` et `cavern_dripstone_caves`, dans
|
||||
le namespace `sanctuary`. Les deux nouvelles ambiances luxuriantes et à
|
||||
spéléothèmes sont limitées à Y<184 par la palette ; leur décoration recherche
|
||||
des sols entre Y=40 et Y=182, avec trois blocs naturels de support et un
|
||||
plafond situé de 6 à 48 blocs au-dessus.
|
||||
|
||||
Dans les cavités luxuriantes, la mousse remplace le sol compatible et de
|
||||
petites plantes l’accompagnent. Les vraies lianes `cave_vines` et
|
||||
`cave_vines_plant` pendent sur 3 à 8 blocs ; leurs baies émettent de la lumière
|
||||
et restent récoltables. Ce décor n’ajoute pas d’eau. Les spéléothèmes utilisent
|
||||
les blocs `pointed_dripstone` orientés, sur un appui de `dripstone_block`
|
||||
remplaçant de la roche existante, avec une longueur de 1 à 4 blocs selon
|
||||
l’espace disponible.
|
||||
|
||||
La géode utilise une configuration vanilla compacte avec améthyste,
|
||||
améthyste bourgeonnante, calcite et basalte lisse. Son enveloppe doit tenir
|
||||
dans la roche préexistante et un passage de deux blocs de haut la relie à
|
||||
la cavité, en préservant l’améthyste bourgeonnante et les cristaux attachés.
|
||||
Un chunk sur huit est candidat ; au plus une pose réussie est retenue par
|
||||
chunk candidat. Cela ne garantit pas de géode sur une graine.
|
||||
|
||||
Le soufre devient admissible dans une bande climatique plus étroite sous
|
||||
Y=176. La réduction de cette sélection peut être testée indépendamment du
|
||||
terrain ; seule une comparaison des vrais blocs sur un périmètre commun
|
||||
mesure sa diminution dans l’île. Un geyser éventuel creuse un seul bloc de
|
||||
sol pour sa source d’eau, avec `potent_sulfur` dessous et `magma_block` encore
|
||||
en dessous. Le fond, les quatre parois et la hauteur libre sont contrôlés.
|
||||
Le cycle du geyser reste celui de Minecraft ; ce décor est distinct des
|
||||
bassins hydrologiques et son eau doit être comptée séparément.
|
||||
|
||||
Leur présence, leur activité, l’éclairage et la cohabitation avec les eaux
|
||||
et la végétation sont contrôlés dans les mondes de test. Les preuves de ces
|
||||
éléments sont cumulées sur les trois graines. Un site incompatible peut
|
||||
être rejeté ; aucun geyser ni stock n’est garanti pour chaque graine.
|
||||
|
||||
### Lacs et bassins étagés
|
||||
|
||||
Le plan hydrologique conserve la recherche de rivière de
|
||||
l’alpha.8 et vise de plus grands lacs de surface. Les cibles actuelles sont
|
||||
de l’ordre de **700 à 1 100 colonnes** par grand lac, jusqu’à **7 à 8 blocs**
|
||||
de profondeur intérieure et une incision limitée à **12 blocs**. Ce sont des
|
||||
bornes de recherche ; les dimensions réelles sont relevées séparément, sans quota d’eau à
|
||||
remplir. Les fonds, les parois et les dépôts exigent du terrain naturel.
|
||||
|
||||
Les terrasses sont recherchées dans des cavités entre **Y=56 et Y=208**,
|
||||
avec deux paliers par groupe et jusqu’à deux groupes facultatifs.
|
||||
Un palier vise environ **40 à 180 colonnes**, une profondeur de **1 à 3 blocs**
|
||||
et une incision d’au plus **5 blocs**. Le fond doit conserver trois couches
|
||||
de sédiments et deux couches de support naturel ; le plafond et l’espace
|
||||
disponible sont vérifiés. Les mesures moteur précisent les sites effectivement retenus.
|
||||
|
||||
La recherche suit le choix de pente de l’eau vanilla : parmi les passages
|
||||
libres, elle privilégie ceux qui rejoignent la chute la plus proche. Un
|
||||
bassin inférieur doit intercepter ce trajet réel, sans détour creusé pour
|
||||
le rejoindre. Les sorties entre paliers sont déclarées et les cascades sont formées par les
|
||||
ticks de fluides Minecraft. Le premier débordement doit atteindre le second
|
||||
bassin. Celui-ci peut avoir un exutoire terminal qui s’écoule sur les roches
|
||||
ou vers le vide, sans troisième bassin ajouté. Le générateur ne doit ni peindre une colonne
|
||||
d’eau jusqu’au vide ni colmater un trou pour retenir un bassin. Les volumes
|
||||
retenus et les liaisons réelles sont vérifiés séparément après décoration
|
||||
et simulation, y compris aux frontières de chunks. Les cascades peuvent
|
||||
servir à monter ou descendre selon la nage vanilla ; aucune mécanique
|
||||
d’ascenseur supplémentaire n’est prévue. « Rizières » décrit seulement
|
||||
l’aspect du paysage en terrasses, sans ajouter une culture de riz.
|
||||
|
||||
**Validation moteur au 9 septembre 2026 :** les trois graines de référence
|
||||
passent les contrôles après 1 800 ticks, avec preuves cumulées des terrasses
|
||||
connectées, geysers actifs, baies lumineuses, spéléothèmes et améthyste
|
||||
bourgeonnante. Le build complet réussit. Les résultats et leurs limites sont
|
||||
consignés dans [Validation](testing.md). Aucune présence universelle n’est
|
||||
garantie. La [distribution](packwiz.md) est publiée et la même instance Prism
|
||||
est synchronisée, sauvegardes et réglages conservés.
|
||||
|
||||
## Failles, géologie et ressources — génération v8
|
||||
|
||||
Les nouveaux mondes utilisent `sanctuary:sanctuary_rift`, la densité
|
||||
`sanctuary:final_density_rift` et le biome source `sanctuary:rift_island`.
|
||||
Les anciens paramètres `sanctuary`, `sanctuary_384`, `sanctuary_hydrology`,
|
||||
`sanctuary_natural`, `sanctuary_layered` et `sanctuary_woodland` restent séparés.
|
||||
Changer le preset ne migre pas un monde existant ; aucune régénération de
|
||||
chunks ni modification de sauvegarde n’est prévue par cet incrément.
|
||||
|
||||
### Failles et palette
|
||||
|
||||
`RiftShape` définit une à trois traces principales, courbes et ramifiées,
|
||||
de longueur nominale 84 à 160 blocs. Leur largeur varie avec deux bruits en
|
||||
trois dimensions ; le cœur ouvert est de l’ordre de 4 à 12 blocs, avec des
|
||||
parois érodées autour. Ces nombres décrivent les traces avant leur intersection
|
||||
avec la roche, pas une garantie de canyon continu de cette taille. La passe
|
||||
retire seulement de la matière et laisse intactes les zones de rayon inférieur
|
||||
à 20 blocs et extérieur à 224 blocs. Elle ne crée aucun socle central.
|
||||
|
||||
Les huit biomes `rift_oak_forest`, `rift_birch_forest`, `rift_clearing`,
|
||||
`rift_dry_woodland`, `rift_rocky_heath`, `rift_dark_grove`, `rift_bamboo_grove`
|
||||
et `rift_sulfur_depths` ont de nouveaux identifiants dans le namespace
|
||||
`sanctuary`. Les choix climatiques de l’alpha.7 sont conservés en haut. Le
|
||||
soufre devient admissible dans des régions plus larges des étages inférieurs,
|
||||
notamment vers Y=176, selon les bruits de variation et d’humidité. Le test de
|
||||
palette prouve cette possibilité ; seule la lecture des vrais blocs peut
|
||||
prouver une poche accessible sur une graine.
|
||||
|
||||
La végétation vanilla de surface garde ses arbres et ses teintes. La feature
|
||||
`rift_groves` passe ensuite, avant `rift_shore_sugar_cane`, pour choisir les
|
||||
volumes inférieurs encore libres après les arbres. Elle peut placer de gros
|
||||
champignons vanilla, avec des petits champignons dispersés et des sols mêlant
|
||||
mousse, podzol et mycélium. Les supports naturels et la place disponible
|
||||
restent nécessaires ; un arbre remarquable peut échouer si son site est occupé.
|
||||
L’ordre des plantations intérieures donne la priorité aux arbres dans trois
|
||||
cas sur quatre et aux gros champignons dans l’autre cas, selon un choix lié
|
||||
au chunk. Cela laisse des volumes disponibles aux chênes noirs. Dans les
|
||||
biomes `sanctuary:rift_dark_grove` et `sanctuary:rift_bamboo_grove`, l’ardoise
|
||||
des abîmes peut aussi fournir le support naturel des sols et bosquets
|
||||
inférieurs. Son admissibilité est vérifiée au-dessus de chaque pied : seules
|
||||
ces deux variantes humides autorisent ce support.
|
||||
|
||||
### Matières selon la profondeur
|
||||
|
||||
La règle `sanctuary:rift_island` ne change que la matière de blocs déjà solides.
|
||||
Ses nappes utilisent un bruit 3D, avec un bruit distinct pour faire varier les
|
||||
transitions de profondeur.
|
||||
|
||||
| Domaine | Matières |
|
||||
| --- | --- |
|
||||
| Masses supérieures | Pierre, granite, andésite, diorite ; sols enherbés hors affleurements |
|
||||
| Intérieur des bosquets humides | Tuf, cobblestone, cobblestone moussue, boue compactée |
|
||||
| Masses profondes | Ardoise des abîmes, roche noire, basalte |
|
||||
| Poches soufrées | Soufre et cinabre ordinaires sur les masses existantes |
|
||||
|
||||
Les strates humides se mêlent aux matières hautes entre Y=192 et Y=224 dans
|
||||
leurs biomes ; la transition vers les roches profondes s’étend de Y=136 à Y=168.
|
||||
La profondeur sert de critère géologique : ces blocs ne signifient pas qu’une
|
||||
source de lave se trouve forcément à proximité. Aucun bassin, spike, source
|
||||
vanilla ou bloc de soufre puissant du biome Sulfur Caves n’est ajouté.
|
||||
|
||||
Les anciens petits dépôts vanilla de granite, andésite, diorite et tuf sont
|
||||
remplacés par ces nappes dans les seuls biomes `rift_*`. Les anciennes données
|
||||
de biomes et leurs features ne sont pas modifiées.
|
||||
|
||||
### Minerais
|
||||
|
||||
Les features `rift_ore_*` ciblent des tags Sanctuary locaux : les pierres hautes
|
||||
et humides produisent les minerais ordinaires ; ardoise des abîmes, roche noire
|
||||
et basalte produisent leurs variantes d’ardoise. Les tags vanilla ne sont pas
|
||||
élargis globalement. Le soufre et le cinabre restent des matières distinctes.
|
||||
|
||||
| Minerai | Taille du filon configurée | Essais principaux par chunk | Altitudes principales |
|
||||
| --- | ---: | --- | --- |
|
||||
| Charbon | 9 | 8 | Y=96..304 |
|
||||
| Fer | 7 | 4 | Y=88..280 |
|
||||
| Cuivre | 7 | 3 | Y=96..280 |
|
||||
| Or | 5 | 2 | Y=88..224 |
|
||||
| Redstone | 5 | 2 | Y=88..216 |
|
||||
| Lapis | 4 | 1 | Y=104..240 |
|
||||
| Diamant | 4 | Un essai une fois sur deux | Y=80..208 |
|
||||
| Émeraude | 3 | 1 | Y=104..224 |
|
||||
|
||||
Les hauteurs suivent une distribution en trapèze. Charbon, fer et cuivre
|
||||
reçoivent aussi respectivement 4, 2 et 2 essais sur roche supérieure exposée,
|
||||
et autant de recherches de corniche depuis l’air entre Y=64 et Y=288. Un essai
|
||||
peut ne produire aucun bloc. Les tailles configurées ne sont ni des quantités
|
||||
garanties par filon ni des quotas par île. L’exposition à l’air est autorisée
|
||||
pour ces trois minerais et l’émeraude ; une partie des candidats exposés est
|
||||
encore écartée pour les autres minerais. Les stocks devront être relevés après
|
||||
génération, avec leur emprise explicite.
|
||||
|
||||
### Eaux et lave
|
||||
|
||||
`RiftHydrology` cherche des bassins plus grands et une longue liaison calme
|
||||
sur la vraie densité fissurée. Le critère de cet incrément est un chemin d’eau
|
||||
continu d’au moins 140 blocs entre deux bassins au même niveau, alimenté par
|
||||
une source rocheuse et sa cascade entrante. Il ne s’agit pas d’une rivière
|
||||
descendant continuellement en altitude. La recherche parcourt huit directions
|
||||
et pondère les trajets selon le relief et un bruit ; le parcours retenu est
|
||||
lissé sur une échelle de 16 à 32 blocs, puis arrondi avant sa conversion en
|
||||
blocs. Le lit varie entre environ 5 et 7 blocs de largeur, hors élargissement
|
||||
des bassins. Le chemin et ses marges sont à nouveau vérifiés sur la roche
|
||||
après ce lissage : une courbe ne peut pas servir à franchir un trou.
|
||||
|
||||
La roche doit soutenir le fond et les berges. L’incision du lit reste limitée
|
||||
à 16 blocs. Les berges de rivière peuvent retirer jusqu’à 12 blocs pour
|
||||
rejoindre l’eau par paliers progressifs ; les autres rives conservent leur
|
||||
limite de 2 blocs. Les dépôts de 3 à 5 couches ont toujours besoin de supports
|
||||
naturels. Aucun remblai ne franchit une faille ou un trou. Si aucun parcours
|
||||
compatible n’est retenu, le générateur garde les bassins et sources qui
|
||||
conviennent au relief.
|
||||
|
||||
Le calcul initial du plan reste coûteux : les essais finaux sur ce poste
|
||||
mesurent 80,5 secondes pour la graine `0`, 50,7 pour `42` et 59,7 pour
|
||||
`8675309`, hors génération des chunks. Le résultat est mis en cache par état
|
||||
aléatoire du monde ; il n’est pas recalculé pour chaque chunk. L’optimisation
|
||||
de ce premier calcul reste un travail ultérieur.
|
||||
|
||||
La recherche de lave profonde vise une à deux petites sources entre Y=40 et
|
||||
Y=160, en complément de la niche couverte accessible. Cette poche de 18 blocs
|
||||
sources est recherchée d’abord sur une corniche intérieure entre Y=64 et
|
||||
Y=160. Si aucun site profond ne réunit les conditions de support, de voûte
|
||||
et d’accès, la recherche reprend sur les corniches plus hautes, jusqu’à Y=300.
|
||||
Il n’y a pas de poche créée de force lorsque les deux recherches échouent.
|
||||
Les sources supplémentaires laissent Minecraft former leurs coulées par ses
|
||||
ticks, sans colonne préremplie. L’eau, les supports et les marges de végétation doivent être
|
||||
contrôlés après décoration et simulation. La finition des bouches doit rester
|
||||
limitée à la première génération du chunk, sans effacer un aménagement joueur
|
||||
lors d’un rechargement.
|
||||
|
||||
**Validation du 9 septembre 2026 :** le build complet réussit et les contrôles
|
||||
moteur passent sur les trois graines de référence. Les failles, les bassins terminaux,
|
||||
les liaisons de rivière, les champignons et les fluides après 1 800 ticks sont
|
||||
documentés avec les relevés de ressources dans [Validation](testing.md).
|
||||
Ces échantillons ne garantissent pas la présence de chaque ressource sur
|
||||
toutes les graines. Les rapports intermédiaires ne servent pas de preuve
|
||||
pour la version finale ; aucun monde personnel n’est utilisé par ces tests.
|
||||
|
||||
## Sculpture du pourtour — conservée depuis la génération v2
|
||||
|
||||
La version **0.1.0-alpha.2** retire deux contraintes de la génération v1
|
||||
(`0.1.0-alpha.1`) : le noyau central qui remplissait les creux du bruit naturel,
|
||||
et la découpe radiale qui pouvait exposer une tranche verticale à toutes les
|
||||
altitudes. Les identifiants `sanctuary:*` du preset et de la fonction restent
|
||||
stables. Il s'agit d'une évolution du terrain, pas d'une migration de sauvegarde.
|
||||
|
||||
Trois passes composent désormais la densité finale, après l'interpolation du
|
||||
champ historique :
|
||||
|
||||
1. Un bruit horizontal à grande échelle déforme le contour. Dans la bande
|
||||
extérieure, large de 128 blocs avant déformation, une érosion progressive
|
||||
(`2 × t⁴`) augmente le seuil nécessaire pour conserver de la roche. Une
|
||||
érosion supplémentaire, quadratique selon la hauteur et nulle à l'intérieur,
|
||||
fait reculer les faces hautes et basses même lorsque le bruit naturel est
|
||||
saturé dans une grande masse pleine. Elle arrondit le pourtour sans ajouter
|
||||
de volume au centre.
|
||||
2. Un bruit **3D**, échantillonné à la hauteur réelle, déplace ce contour et
|
||||
creuse des renfoncements plus larges. Le bord peut ainsi changer de position
|
||||
avec l'altitude au lieu de prolonger une ligne verticale.
|
||||
3. Un second bruit **3D**, plus fin, ajoute de petites irrégularités. Ces deux
|
||||
passes s'atténuent progressivement vers l'intérieur ; elles ne remplissent
|
||||
jamais un trou du terrain initial.
|
||||
|
||||
Les bruits `island_edge`, `island_sculpt` et `island_detail` sont tous initialisés
|
||||
avec la seed du monde. Ils utilisent respectivement les octaves de base
|
||||
`-7`, `-5` et `-3`. Les nouvelles entrées `sculpt` et `detail` du codec sont
|
||||
optionnelles pour garder les anciennes définitions JSON lisibles ; leur absence
|
||||
ne rétablit pas le noyau de la génération v1.
|
||||
|
||||
L'érosion est toujours soustraite à la densité bornée du terrain. Une densité
|
||||
nulle ou négative ne peut donc jamais devenir solide. Même avec un terrain
|
||||
d'entrée maximalement positif, elle est négative dès le rayon 280, avant la
|
||||
limite de sécurité 288. Deux transitions verticales douces protègent aussi les
|
||||
bornes de hauteur, sans découper de dalle à `Y=0` ou `Y=256`.
|
||||
|
||||
Ces formules sont toujours utilisées par les sauvegardes de la génération v2.
|
||||
La génération v3 conserve la sculpture latérale dans le repère de l'île relevée.
|
||||
|
||||
## Île relevée et dessous irrégulier — génération v3
|
||||
|
||||
La version **0.1.0-alpha.3** crée les nouveaux mondes avec les paramètres et le
|
||||
type de dimension `sanctuary:sanctuary_384` : `min_y=0`, `height=384` et
|
||||
`logical_height=384`. Le nom du preset sélectionné reste `sanctuary:sanctuary`.
|
||||
Le gain de hauteur n'agrandit pas le rayon horizontal et n'active aucun continent.
|
||||
|
||||
Le bruit naturel et les bruits de sculpture sont échantillonnés à `Y−64`.
|
||||
Le profil supérieur et les bords approuvés en alpha.2 sont ainsi relevés sans
|
||||
changer de seed ou étirer l'île. Sur les graines de régression, les sommets
|
||||
proches de `Y=184` se retrouvent vers `Y=248`, avec de l'espace libre au-dessus.
|
||||
|
||||
Sous l'île, déplacer l'ancien gradient inférieur aurait seulement déplacé son
|
||||
plan de coupe. `floating_archipelago_384` conserve donc le bruit naturel sans
|
||||
ce gradient inférieur. Une nouvelle érosion travaille sur une transition de
|
||||
**72 blocs**, dont l'altitude varie avec le bruit 3D `island_underside` et les
|
||||
deux bruits de sculpture existants. Elle laisse les lobes naturels se poursuivre
|
||||
vers le bas, puis les amincit à différentes hauteurs ; elle n'ajoute ni socle ni
|
||||
surface garantie. La transition n'affecte plus le terrain à partir de `Y=144`.
|
||||
Une marge vide protège le bas de la dimension : aucun plancher rocheux ne doit
|
||||
apparaître dans `Y=0..15`. À partir de l’alpha.5, seules les eaux descendantes de
|
||||
sources explicitement déclarées peuvent traverser cette marge.
|
||||
|
||||
Les champs optionnels du codec `vertical_offset`, `height` et `underside` valent
|
||||
respectivement `0`, `256` et un bruit nul par défaut. Seule la nouvelle fonction
|
||||
`sanctuary:final_density_384` active l'offset `64`, la hauteur `384` et le bruit du
|
||||
dessous. Les anciennes fonctions `final_density`, `floating_archipelago` et les
|
||||
paramètres `sanctuary:sanctuary` restent inchangés : les sauvegardes alpha.2 qui
|
||||
les référencent gardent leur hauteur de génération et leur ancien terrain.
|
||||
|
||||
**Créer un nouveau monde pour tester l'île relevée.** L'actualisation du pack ne
|
||||
convertit pas les sauvegardes existantes ; aucun chunk n'est supprimé, déplacé
|
||||
ou régénéré. Il n'existe pas de migration automatique des anciens mondes vers la
|
||||
dimension de 384 blocs. Changer manuellement leurs références de générateur ou
|
||||
de dimension sort de ce contrat de compatibilité.
|
||||
|
||||
## Hydrologie de surface — génération v4
|
||||
|
||||
La version **0.1.0-alpha.4** conserve la densité, la hauteur et le ciel de
|
||||
l’alpha.3. Les nouveaux mondes sélectionnent les paramètres
|
||||
`sanctuary:sanctuary_hydrology` et le biome `sanctuary:starter_forest_hydrology`.
|
||||
Le type de dimension reste `sanctuary:sanctuary_384`. Les anciens paramètres et
|
||||
le biome `sanctuary:starter_forest` restent disponibles et inchangés : ils
|
||||
n’activent pas cette hydrologie dans les sauvegardes antérieures.
|
||||
|
||||
Un plan calculé depuis le relief et la graine recherche des emplacements de
|
||||
surface pour des étangs, des petits lacs et un premier cours d’eau calme.
|
||||
Le lit peut être légèrement creusé dans la roche existante. Avant d’accepter
|
||||
un emplacement, le plan vérifie le fond et les parois en trois dimensions ainsi
|
||||
que l’ouverture vers le ciel. Un trou profond dans la carte de hauteur ne suffit
|
||||
pas à constituer un lac. Aucune digue ni masse de roche n’est ajoutée pour
|
||||
boucher une fuite, et aucun bassin souterrain n’est rempli.
|
||||
|
||||
Les fonds alternent sable, gravier et argile ; les berges basses peuvent recevoir
|
||||
du sable. Dans ce nouveau biome, les disques de sédiments vanilla et les veines
|
||||
de gravier sont remplacés par ces dépôts de rive et de fond : une feature
|
||||
ultérieure ne doit pas transformer leur support en gravier instable. Les
|
||||
sources de magma sous-marines sont également absentes. Après les arbres et la
|
||||
végétation, de la canne à sucre peut pousser sur
|
||||
les rives qui respectent ses règles de placement vanilla. Le nombre de sites,
|
||||
leur eau et leurs ressources dépendent du terrain : le générateur ne complète
|
||||
pas une graine pour atteindre un quota économique.
|
||||
|
||||
Le plan est indépendant des chunks chargés. Chaque chunk applique uniquement
|
||||
ses propres colonnes après le terrain et avant la décoration, ce qui permet de
|
||||
raccorder un même plan d’eau aux frontières des chunks. Les anciens chunks ne
|
||||
sont jamais retouchés, et aucun travail hydrologique ne se déclenche au simple
|
||||
chargement d’une sauvegarde.
|
||||
|
||||
Cette première version vise de petites eaux retenues et des cours d’eau calmes
|
||||
sur les plateaux. Le cours d’eau est un chenal à niveau constant reliant des
|
||||
plans d’eau ; il ne simule pas encore une pente vers l’aval ou un débit. Les
|
||||
tracés peuvent passer par les diagonales, mais leur eau reste connectée de
|
||||
bloc en bloc. L’excavation est limitée à six blocs, l’eau à quatre blocs de
|
||||
profondeur, avec un fond plein sur trois blocs d’épaisseur, dont deux blocs naturels sous
|
||||
le sédiment. Ce contrat alpha.4 reste celui de ses sauvegardes. Les sources en cascade sont
|
||||
ajoutées séparément dans la génération v5 ; les grandes rivières en pente, océans,
|
||||
aquifères, climats multiples et continents restent des incréments distincts.
|
||||
|
||||
## Plages, sources et mosaïque tempérée — génération v5
|
||||
|
||||
L’implémentation **0.1.0-alpha.5** sélectionne la nouvelle clé de paramètres
|
||||
`sanctuary:sanctuary_natural` (`SanctuarySpawn.NATURAL_SETTINGS`). Elle conserve
|
||||
la densité relevée, les 384 blocs de hauteur et le ciel de l’alpha.3. La classe
|
||||
`NaturalHydrology` et son application sont séparées de `SurfaceHydrology` et
|
||||
`HydrologyRuntime` : le code, les paramètres `sanctuary:sanctuary_hydrology` et
|
||||
le biome `sanctuary:starter_forest_hydrology` de l’alpha.4 restent inchangés.
|
||||
Les clés alpha.2 et alpha.3 restent également disponibles avec leur comportement
|
||||
antérieur. **Ces changements concernent uniquement les nouveaux mondes** : aucune
|
||||
conversion de sauvegarde, régénération de chunk ou expansion n’est effectuée.
|
||||
|
||||
### Dépôts et eau de surface
|
||||
|
||||
Le plan recherche des étangs, de petits lacs et un cours d’eau calme dans le
|
||||
relief naturel. L’excavation maximale passe à **quatre blocs**, pour une eau de
|
||||
**trois blocs de profondeur** au plus. Les bassins gardent un fond et des parois
|
||||
naturels pleins ; aucune digue n’est ajoutée et aucune chambre souterraine n’est
|
||||
remplie. Le cours d’eau reste à niveau constant : il ne modélise pas un débit
|
||||
ni une pente continue vers l’aval.
|
||||
|
||||
Les plages sont des nappes irrégulières qui suivent les faibles variations du
|
||||
relief autour des plans d’eau. Elles peuvent s’étendre sur plusieurs blocs,
|
||||
se rapprocher d’une autre poche d’eau et retrouver progressivement l’herbe ou
|
||||
la pierre. Les matériaux varient entre sable, gravier, pierre et terre enherbée ;
|
||||
l’argile peut apparaître dans les fonds. Le traitement d’une terrasse sèche
|
||||
retire au plus **deux blocs** du relief existant.
|
||||
|
||||
Chaque dépôt remplace **trois à cinq couches** de terrain déjà plein et garde
|
||||
**deux couches naturelles de support** en dessous. Ce support permet notamment
|
||||
au sable et au gravier de rester en place après les mises à jour de gravité.
|
||||
Les veines de gravier, disques de sédiments et magma sous-marin vanilla ne sont
|
||||
pas réintroduits dans ces nouveaux biomes. Les autres décorations rocheuses et
|
||||
les minerais peuvent encore remplacer la pierre ; ils ne remplacent pas les
|
||||
couches de sable, gravier ou argile. Les rives compatibles peuvent porter de la
|
||||
canne à sucre après la végétation. Les troncs couchés vanilla peuvent occuper
|
||||
quelques blocs de la surface d’un étang ; les contrôles distinguent cette
|
||||
décoration de l’eau restante et vérifient leur fond ou l’eau sous les troncs,
|
||||
les sources voisines et la rétention du bassin.
|
||||
|
||||
### Sources rocheuses et cascades
|
||||
|
||||
Le plan peut retenir jusqu’à **trois sources** dans des niches rocheuses
|
||||
naturelles, séparées des dépôts modifiés. Une source remplace un seul bloc de
|
||||
roche, sous un toit, sur un fond et entre trois côtés déjà pleins. Son unique
|
||||
sortie horizontale est naturellement ouverte sur une chute d’au moins huit
|
||||
blocs. Le générateur ne construit ni pylône, ni corniche, ni tunnel pour obtenir
|
||||
ce résultat.
|
||||
|
||||
Seul le bloc source est généré. Les ticks de fluide de Minecraft forment la
|
||||
cascade et ses éventuels passages sur des paliers. Ces écoulements peuvent
|
||||
descendre **jusque dans le vide**, conformément au comportement demandé. Cette
|
||||
exception concerne les sources déclarées. Le trajet calculé dans la roche nue
|
||||
est une prévision : les arbres générés ensuite peuvent dévier une chute. Les
|
||||
diagnostics vérifient donc les connexions réelles de chaque bloc d’eau vers une
|
||||
source, dans l’emprise des chunks inspectés et de leur halo. Ils échouent si de
|
||||
l’eau touche une frontière non inspectée ou si un bassin perd sa coque. Les
|
||||
déviations par rapport à la prévision restent mesurées dans le rapport. Le
|
||||
comportement des fluides reste celui de Minecraft, y compris lorsque les joueurs
|
||||
modifient une rive.
|
||||
|
||||
### Petite réserve de lave
|
||||
|
||||
`StarterLavaDeposit` recherche au plus une niche de **18 blocs sources** dans
|
||||
la roche existante, accessible par un passage sec de deux blocs de haut depuis
|
||||
une corniche naturelle. Le fond garde trois blocs pleins, les parois deux et
|
||||
la couverture huit ; ces supports sont vérifiés avant les écritures. La niche
|
||||
reste éloignée des eaux et de leurs parcours. Elle fournit de la lave prélevable
|
||||
pour les premières productions et un portail du Nether. Si aucun site ne
|
||||
convient, le plan reste vide : aucun bassin artificiel n’est ajouté dans le vide.
|
||||
|
||||
Les tests vérifient l’accès praticable, la rétention de la lave et l’absence
|
||||
d’incendie après simulation.
|
||||
|
||||
### Biomes et minerais
|
||||
|
||||
Le biome source `sanctuary:temperate_island` utilise deux champs de bruit
|
||||
initialisés par la seed pour répartir cinq variantes horizontales. Une rive et
|
||||
la roche sous elle restent dans la même variante :
|
||||
|
||||
| Identifiant | Variante |
|
||||
| --- | --- |
|
||||
| `sanctuary:temperate_forest` | Forêt dominante |
|
||||
| `sanctuary:temperate_plains` | Clairières et plaines tempérées |
|
||||
| `sanctuary:temperate_birch_forest` | Forêt de bouleaux |
|
||||
| `sanctuary:temperate_flower_forest` | Forêt fleurie |
|
||||
| `sanctuary:temperate_dappled_forest` | Dappled Forest, peupliers et végétation associée |
|
||||
|
||||
Dappled Forest est un biome vanilla introduit dans
|
||||
[Minecraft 26.3 Snapshot 1](https://www.minecraft.net/en-us/article/minecraft-26-3-snapshot-1).
|
||||
Cette variante reprend ses données de **26.3-pre-2**, avec une humidité légèrement
|
||||
relevée pour Sanctuary. Elle n’ajoute ni dépendance externe ni structure de camp.
|
||||
La palette tempérée ne constitue pas encore le catalogue TerraMix ni le champ
|
||||
climatique des futurs continents.
|
||||
|
||||
Les nouveaux biomes remplacent les filons vanilla de métaux par de petites
|
||||
features `sanctuary:island_ore_*`, à des altitudes adaptées à l’île relevée :
|
||||
|
||||
| Ressource | Taille du filon | Tentatives par chunk | Plage Y |
|
||||
| --- | --- | --- | --- |
|
||||
| Charbon | 6 | 2 | 96–288 |
|
||||
| Fer | 4 | 2 | 88–248 |
|
||||
| Cuivre | 4 | 1 | 96–248 |
|
||||
| Or | 4 | 1 | 104–224 |
|
||||
| Redstone | 4 | 1 | 96–224 |
|
||||
| Lapis | 3 | Une tentative, chance 1/2 | 112–232 |
|
||||
| Diamant | 3 | Une tentative, chance 1/4 | 104–208 |
|
||||
|
||||
Ces paramètres décrivent des tentatives, pas des quantités promises : le filon
|
||||
doit rencontrer la roche existante, et sa forme dépend du hasard de génération.
|
||||
La sélection des biomes et des eaux ne cherche aucun stock cible. Un relevé
|
||||
partiel reste un relevé partiel ; la présence des sept ressources sur une seed
|
||||
de référence ne garantit pas leur présence sur toutes les seeds.
|
||||
|
||||
La validation de l’alpha.5 est suivie dans [Validation](testing.md). Ses diagnostics
|
||||
sont conçus pour inspecter les blocs décorés, les profils des dépôts, les
|
||||
jointures de chunks, les biomes et les ressources, puis simuler réellement les
|
||||
fluides dans les chunks des sites. Les cartes et profils PNG montrent des
|
||||
lectures de blocs, pas un rendu Minecraft. La descente des
|
||||
cascades est contrôlée après activation effective des chunks qui les simulent.
|
||||
|
||||
## Prairies et corniches — génération v6
|
||||
|
||||
L’alpha.6 utilise `sanctuary:sanctuary_layered` et le biome source
|
||||
`sanctuary:layered_island`. Les formes et le ciel restent ceux de la génération
|
||||
relevée. Les anciennes classes, biomes et règles de matériaux restent disponibles
|
||||
pour les sauvegardes alpha.2 à alpha.5 ; seul le preset de création choisit v6.
|
||||
|
||||
### Une palette qui varie aussi en hauteur
|
||||
|
||||
Les cinq variantes partagent leurs teintes d’herbe et de feuillage. Les champs
|
||||
de climat liés à la seed déforment de larges bandes d’altitude, autour de Y154
|
||||
et Y214 : prairies et prairies fleuries sur le dessus, bosquets et corniches
|
||||
moussues plus bas. Des zones rocheuses traversent ces bandes et remplacent la
|
||||
couverture de terre par de la pierre sur le relief existant. Les prairies gardent
|
||||
quelques arbres épars et leur végétation vanilla. Dappled Forest n’appartient
|
||||
plus à cette palette ; son utilisation sur de futurs continents reste à développer.
|
||||
|
||||
| Identifiant | Ambiance |
|
||||
| --- | --- |
|
||||
| `sanctuary:layered_meadow` | Prairie tempérée |
|
||||
| `sanctuary:layered_flower_meadow` | Prairie fleurie |
|
||||
| `sanctuary:layered_lower_grove` | Bosquet inférieur |
|
||||
| `sanctuary:layered_mossy_terraces` | Corniches moussues |
|
||||
| `sanctuary:layered_rocky_terraces` | Affleurements rocheux |
|
||||
|
||||
`LowerTerraceVegetationFeature` parcourt aussi les sols naturels sous les
|
||||
surplombs, au moins huit blocs sous une surface supérieure. Elle demande trois
|
||||
blocs pleins de support et deux blocs libres au-dessus. Elle peut remplacer la
|
||||
surface existante par de la mousse et planter fougères, fleurs ou petits arbustes
|
||||
lorsque leurs règles de survie le permettent. Les cellules hydrologiques, les
|
||||
sources, les parcours d’eau prévus et les supports de lave sont protégés.
|
||||
Il n’y a ni corniche construite dans l’air ni ajout d’éclairage artificiel.
|
||||
|
||||
### Bassins et sources de paroi
|
||||
|
||||
`LayeredHydrology` conserve les petits bassins et les dépôts épais, avec une
|
||||
plus grande présence de pierre. Le court `STREAM` à niveau constant de l’alpha.5
|
||||
n’est pas repris. Cette version retient uniquement les étangs et les petits
|
||||
lacs ; elle ne cherche pas de grande rivière. Le parcours traversant avec
|
||||
source, cascade et bassin reste un ticket distinct, à démontrer sur le relief
|
||||
Minecraft réel. Aucun chenal de secours n’est creusé.
|
||||
|
||||
La profondeur reste limitée à trois blocs et l’incision à quatre. Le plan
|
||||
vérifie le fond et les rives avant d’appliquer ses écritures, dans les nouveaux
|
||||
chunks seulement. Le sable, le gravier, l’argile et la roche suivent les rives.
|
||||
Des niches de source sont également recherchées dans les strates inférieures,
|
||||
et pas seulement près de la surface la plus haute. Leurs chutes sont produites
|
||||
par les ticks de Minecraft à partir d’un seul bloc source. Le post-traitement
|
||||
des nouveaux chunks libère uniquement leur ouverture, déjà vide dans la densité
|
||||
naturelle, pour qu’un lichen ou une plante ne bouche pas la source. Les positions
|
||||
sont marquées dans la file vanilla pendant la génération, puis consommées une
|
||||
seule fois. Un rechargement ne dégage pas une bouche modifiée par un joueur.
|
||||
Aucune chute n’est préremplie et aucun nouveau passage n’est creusé dans la roche.
|
||||
|
||||
Les parcours, les écoulements et la végétation réellement observés, ainsi que
|
||||
les limites du modèle, sont consignés dans [Validation](testing.md).
|
||||
|
||||
## Apparition commune
|
||||
|
||||
Une injection limitée à la création initiale cherche du sol naturel depuis
|
||||
l'origine vers l'extérieur, dans toute l'emprise finie de l'île. Elle préfère
|
||||
une surface stable de 3 × 3 blocs, avec deux blocs libres en hauteur. En
|
||||
l'absence d'une telle surface, elle retient une colonne de sol praticable ; si
|
||||
aucun emplacement naturel sûr n'existe, la création signale la seed concernée
|
||||
plutôt que d'ajouter une plateforme. Elle ne s'applique qu'à l'Overworld
|
||||
utilisant les paramètres `sanctuary:sanctuary`, `sanctuary:sanctuary_384` ou
|
||||
`sanctuary:sanctuary_hydrology`, `sanctuary:sanctuary_natural` ou
|
||||
`sanctuary:sanctuary_layered`.
|
||||
Elle enregistre le spawn partagé dans la sauvegarde et ne le réinitialise pas
|
||||
au redémarrage : les modifications administratives de `/setworldspawn` restent
|
||||
conservées. Les lits et la dispersion habituelle du spawn suivent encore les
|
||||
règles de Minecraft.
|
||||
|
||||
## Adaptation 26.2 → 26.3
|
||||
|
||||
Les références historiques sont
|
||||
`sanctuary/worldgen/density_function/base_3d_noise.json` et
|
||||
`floating_archipelago.json` dans l'ancien module Sanctuary. L'ancien masque
|
||||
annulaire n'est pas conservé : après son anneau de vide, il faisait réapparaître
|
||||
un archipel infini. Les anciennes surcharges de `minecraft:normal`, dimensions
|
||||
supplémentaires, intégrations TerraMix et injections globales de features ne
|
||||
font pas partie de ce port.
|
||||
|
||||
Minecraft 26.3 a changé l'API de génération : les fonctions de densité compilent
|
||||
maintenant un `DensitySampler`, leurs opérateurs JSON utilisent
|
||||
`left` / `right` / `input`, les gradients utilisent `from_coordinate` et les
|
||||
surfaces passent par le registre `worldgen/material_rule`. Les données de cette
|
||||
branche suivent ces formats réels ; copier directement les JSON 26.2 ne suffit
|
||||
pas. Les bruits utilisent également `base_octave`, `octave_count` et
|
||||
`amplitude_modifiers`. Le seul opérateur Java ajouté, `sanctuary:main_island`,
|
||||
compose le terrain et les bruits de sculpture, applique le repère vertical
|
||||
choisi et impose la limite extérieure après interpolation.
|
||||
|
||||
## Vérification
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:worldgenSmoke
|
||||
./gradlew :sanctuary:runGameTest
|
||||
```
|
||||
|
||||
Le smoke test vérifie qu'aucun sol n'est ajouté, que l'intérieur conserve le champ
|
||||
naturel et que l'érosion évolue progressivement sur un profil de terrain contrôlé.
|
||||
Il vérifie aussi qu'un lobe plein recule d'au moins 30 blocs en haut et en bas
|
||||
par rapport à son milieu, pour éviter une paroi verticale simplement déplacée.
|
||||
Il couvre aussi l'influence des deux bruits de sculpture, le vide avant les
|
||||
bornes de sécurité, et le vide dans les quatre directions même avec un bruit
|
||||
entièrement positif. Les coordonnées testées incluent les limites de Minecraft
|
||||
et des entiers Java pour détecter débordements et répétitions involontaires.
|
||||
|
||||
Les GameTests chargent le preset réel sur un serveur éphémère, vérifient le
|
||||
spawn après décoration, puis inspectent des chunks complets à distance dans
|
||||
les quatre directions. Ils comparent aussi la densité compilée avec deux
|
||||
instances de la même seed et une seed différente. Cela détecte les références
|
||||
JSON invalides et les blocs que des features pourraient ajouter après le calcul
|
||||
de densité. Les injections du serveur de test sont isolées dans
|
||||
`src/gametest` et ne sont pas distribuées dans le mod.
|
||||
|
||||
Les graines de régression des générations v2 et v3 sont **0**, **42** et **8675309**.
|
||||
Le serveur de test exporte leurs cartes de hauteur et coupes de densité dans
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/`. Ces images permettent de
|
||||
comparer la vraie densité alpha.2, toujours chargée, avec la nouvelle île relevée
|
||||
dans toute la hauteur `0..383`. Les assertions vérifient la translation du relief
|
||||
supérieur, la variation des hauteurs du dessous, l'absence de plancher, les
|
||||
384 blocs de génération et la conservation des anciens paramètres 256.
|
||||
Ces images ne montrent
|
||||
pas les arbres ni les matériaux et ne remplacent pas un essai visuel en jeu.
|
||||
|
||||
## Contrat du prochain ticket : expansion
|
||||
|
||||
Avant de créer des continents, définir une donnée **persistante par monde**
|
||||
contenant pour chaque région son identifiant, centre, direction, emprise,
|
||||
seed dérivée, version de générateur et état de déblocage. L'île principale
|
||||
devient la première région de ce registre ; les nouvelles régions utilisent
|
||||
des coordonnées absolues, sans cellules périodiques.
|
||||
|
||||
L'activation doit être une action du serveur, reproductible et sauvegardée,
|
||||
conditionnée ensuite par les contributions collectives. Elle doit tenir compte
|
||||
du fait qu'un joueur peut déjà avoir chargé ou construit dans le vide : un
|
||||
chunk existant ne se régénère pas simplement parce que la liste des continents
|
||||
change. Le ticket doit choisir une réservation d'espace ou une matérialisation
|
||||
contrôlée qui protège les constructions et ne remplace jamais aveuglément des
|
||||
chunks sauvegardés.
|
||||
|
||||
La version de génération et les emprises doivent être gelées avant les premières
|
||||
sauvegardes destinées à durer. Ce prototype ne promet pas encore de migration
|
||||
automatique des mondes entre deux versions de terrain.
|
||||
|
||||
|
||||
## Forêts, corniches humides et affleurements — génération v7
|
||||
|
||||
L’alpha.7 crée les nouveaux mondes avec `sanctuary:sanctuary_woodland` et
|
||||
`sanctuary:woodland_island`. La densité `sanctuary:final_density_384`, les
|
||||
formes rocheuses, la hauteur et le ciel restent identiques. Les plans des
|
||||
bassins, sources et petites niches de lave reprennent les algorithmes alpha.6.
|
||||
Les anciens paramètres, biomes et features sont conservés ; cette livraison
|
||||
ne modifie pas les mondes existants.
|
||||
|
||||
La palette comporte huit biomes :
|
||||
|
||||
| Identifiant | Rôle |
|
||||
| --- | --- |
|
||||
| `sanctuary:woodland_oak_forest` | Forêt de chênes dominante, bouleaux secondaires |
|
||||
| `sanctuary:woodland_birch_forest` | Forêt de bouleaux complémentaire |
|
||||
| `sanctuary:woodland_clearing` | Clairière fleurie |
|
||||
| `sanctuary:woodland_dry_woodland` | Bois sec plus clairsemé |
|
||||
| `sanctuary:woodland_rocky_heath` | Masses rocheuses exposées |
|
||||
| `sanctuary:woodland_dark_grove` | Corniches humides à chênes noirs et champignons |
|
||||
| `sanctuary:woodland_bamboo_grove` | Rare intérieur à jungle et bambou |
|
||||
| `sanctuary:woodland_sulfur_depths` | Rare poche profonde de soufre sur roche existante |
|
||||
|
||||
Une altitude élevée ne transforme plus automatiquement une forêt en prairie
|
||||
ou en roche. Les forêts utilisent les véritables arbres vanilla avec leurs
|
||||
conditions de croissance ; les affleurements sont des patches horizontaux
|
||||
et des variations de matériau sur le terrain existant.
|
||||
|
||||
`woodland_groves` place de vrais chênes noirs sur des sols naturels de 2×2,
|
||||
avec trois blocs de support et une place suffisante pour le tronc et la
|
||||
canopée sous le plafond. Des champignons accompagnent les sols de podzol et
|
||||
de mycélium, avec quelques touches de mousse. La jungle et le bambou restent
|
||||
limités aux rares poches qui leur conviennent.
|
||||
|
||||
Un seul emplacement remarquable est recherché par graine, avec une essence
|
||||
choisie parmi cerisier (favorisé), épicéa, jungle, acacia, palétuvier et chêne
|
||||
pâle. Il ne produit pas de terrain : si le site ou les conditions de l’arbre
|
||||
vanilla ne conviennent pas, il ne pousse pas. Ce n’est pas une garantie de
|
||||
chaque essence sur chaque île. Aucun cœur de Creaking n’est ajouté par l’arbre
|
||||
pâle utilisé ici.
|
||||
|
||||
Les affleurements mêlent des nappes cohérentes de pierre, andésite, diorite et
|
||||
granite. Les berges conservent leur excavation limitée et leurs dépôts de
|
||||
3 à 5 blocs, avec des patches de sable, gravier, argile et roche. Les plantations de `woodland_groves` évitent les sources, leurs exutoires
|
||||
et les niches de lave. Les décorations vanilla conservent leurs placements ;
|
||||
le traitement unique après génération peut rouvrir la seule bouche de source
|
||||
déclarée si une plante l’obstrue. Le soufre
|
||||
reste une matière rare des profondeurs ; les grands bassins et sources
|
||||
vanilla du biome Sulfur Caves ne sont pas activés.
|
||||
|
||||
Les anciens petits filons souterrains de charbon, fer et cuivre étaient peu
|
||||
susceptibles d’atteindre les surfaces hautes ; leur réglage écartait aussi une
|
||||
partie des blocs voisins de l’air. Les nouvelles features `woodland_ore_*`
|
||||
utilisent des tailles vanilla limitées (`size=6` pour le charbon, `size=5`
|
||||
pour fer/cuivre), sans garantir ce nombre de blocs produits par filon,
|
||||
autorisent cette exposition et ajoutent des essais aléatoires sur les roches
|
||||
de surface et les corniches rencontrées depuis l’air. Elles remplacent
|
||||
seulement les roches naturelles reconnues par Minecraft, dont andésite,
|
||||
diorite et granite. Les autres minerais et les anciens générateurs gardent
|
||||
leurs réglages ; aucune quantité cible n’est réinjectée après mesure.
|
||||
|
||||
La version et les graines de validation sont consignées dans [Validation](testing.md).
|
||||
@@ -0,0 +1,13 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configuration-cache=false
|
||||
|
||||
# Verified 2026-09-08. 26.3 is still a pre-release; do not silently move worlds.
|
||||
minecraft_version=26.3-pre-2
|
||||
loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.3
|
||||
|
||||
mod_version=0.1.0-alpha.10
|
||||
pack_version=0.1.0-alpha.10
|
||||
maven_group=fr.koka.sanctuary
|
||||
Vendored
BIN
Binary file not shown.
+8
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
if [ -z "$JAVA_HOME" ] && [ -x "$APP_HOME/.jdk/current/bin/java" ] ; then
|
||||
JAVA_HOME=$APP_HOME/.jdk/current
|
||||
export JAVA_HOME
|
||||
fi
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
-632
@@ -1,632 +0,0 @@
|
||||
[
|
||||
{
|
||||
"version": "beta.001",
|
||||
"tag_name": "notes/beta.001",
|
||||
"name": "Sanctuary beta.001 — Les quatre anciennes expéditions",
|
||||
"body_file": "notes/beta.001.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "d2faf130a76135e466a160376263915a711c20703dbdba4c1261a4d01b204adf"
|
||||
},
|
||||
{
|
||||
"version": "beta.002",
|
||||
"tag_name": "notes/beta.002",
|
||||
"name": "Sanctuary beta.002 — Biomes boréaux, corail et temples",
|
||||
"body_file": "notes/beta.002.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "517197580174ddd46d1c6b5be20829e91ed2daa61c635b8b369ea9afad3ab4d2"
|
||||
},
|
||||
{
|
||||
"version": "beta.003",
|
||||
"tag_name": "notes/beta.003",
|
||||
"name": "Sanctuary beta.003 — Créer son habitant et commencer la progression",
|
||||
"body_file": "notes/beta.003.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "8154048c7b2afff8a3c42e7993cc899de10716b4106fa00bc3f6f7d2827b0963"
|
||||
},
|
||||
{
|
||||
"version": "beta.004",
|
||||
"tag_name": "notes/beta.004",
|
||||
"name": "Sanctuary beta.004 — Cadre Blocodex et sprint",
|
||||
"body_file": "notes/beta.004.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "7e587dc5519573752dd955fb4c34d9f5fae824f867b4dcbec514e5bab9d6768f"
|
||||
},
|
||||
{
|
||||
"version": "beta.005",
|
||||
"tag_name": "notes/beta.005",
|
||||
"name": "Sanctuary beta.005 — Carte native et noms de couleurs",
|
||||
"body_file": "notes/beta.005.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "eafb297b4b2e23ab51244c817114d1c1fe55edfbea5a7eb41dadaec1b1f0a4d1"
|
||||
},
|
||||
{
|
||||
"version": "beta.006",
|
||||
"tag_name": "notes/beta.006",
|
||||
"name": "Sanctuary beta.006 — Brouillard, grille et Demeure",
|
||||
"body_file": "notes/beta.006.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "af67ab386fae0e2ed2e0833e206cb6a4681bbbeee5cb3f77e04ec31eb2f76023"
|
||||
},
|
||||
{
|
||||
"version": "beta.007",
|
||||
"tag_name": "notes/beta.007",
|
||||
"name": "Sanctuary beta.007 — Carte immersive",
|
||||
"body_file": "notes/beta.007.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "6ef0c1df5b317159a0dd05b9e5d4535d60cf77c7e700cc82cbc4bc63f1a673bc"
|
||||
},
|
||||
{
|
||||
"version": "beta.008",
|
||||
"tag_name": "notes/beta.008",
|
||||
"name": "Sanctuary beta.008 — Minage et vein mining",
|
||||
"body_file": "notes/beta.008.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "5873b41ee191e709ad1b12fbbbea3b237c974e29740006923df3f295ece5ddd7"
|
||||
},
|
||||
{
|
||||
"version": "beta.009",
|
||||
"tag_name": "notes/beta.009",
|
||||
"name": "Sanctuary beta.009 — Construction et vein building",
|
||||
"body_file": "notes/beta.009.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "1e6b325a3c5f337b711a7c6506ea8adc665c4f0ac687f9f0ef221ff8e99fe17d"
|
||||
},
|
||||
{
|
||||
"version": "beta.010",
|
||||
"tag_name": "notes/beta.010",
|
||||
"name": "Sanctuary beta.010 — Inventaire jusqu'à six rangées",
|
||||
"body_file": "notes/beta.010.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "6bb2e7a0b6428ce0481cf565123aca6b3781e00a199dad7001302c3006a126eb"
|
||||
},
|
||||
{
|
||||
"version": "beta.011",
|
||||
"tag_name": "notes/beta.011",
|
||||
"name": "Sanctuary beta.011 — Factions, prestige et New Game+",
|
||||
"body_file": "notes/beta.011.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "40b82f673945db70e2654181eba8cc022b2d1dec6375bf6b0d659ebfbfa2762b"
|
||||
},
|
||||
{
|
||||
"version": "beta.012",
|
||||
"tag_name": "notes/beta.012",
|
||||
"name": "Sanctuary beta.012 — Inventaire et progression lisible",
|
||||
"body_file": "notes/beta.012.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "17eed6551ae82eef2634b1f9a7a59c19a74a542c66847aca56183e60179596cf"
|
||||
},
|
||||
{
|
||||
"version": "beta.013",
|
||||
"tag_name": "notes/beta.013",
|
||||
"name": "Sanctuary beta.013 — Progression, prestige et repères visuels",
|
||||
"body_file": "notes/beta.013.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "92f9e99124d10c33f22e2d376d988ef9f2e2290d62c6cb32794b45583c3e4ab4"
|
||||
},
|
||||
{
|
||||
"version": "beta.014",
|
||||
"tag_name": "notes/beta.014",
|
||||
"name": "Sanctuary beta.014 — Rangement, hotbar mobile et préfixe de faction",
|
||||
"body_file": "notes/beta.014.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "80071335c063edec5f76059d839c7904fd24bb21641f31d029cf25b88e0efdd6"
|
||||
},
|
||||
{
|
||||
"version": "beta.015",
|
||||
"tag_name": "notes/beta.015",
|
||||
"name": "Sanctuary beta.015 — Icônes de progression communes",
|
||||
"body_file": "notes/beta.015.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "f0ea350fc5710608b445494a8f229cf7453616e27c3fe08a880a14ca087153d5"
|
||||
},
|
||||
{
|
||||
"version": "beta.016",
|
||||
"tag_name": "notes/beta.016",
|
||||
"name": "Sanctuary beta.016 — Les premiers gestes rapportent de l’XP",
|
||||
"body_file": "notes/beta.016.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "2f049fcfa96f69b4ad77832ee21d9fa897e412a7766628479dcbb9229bf3dc15"
|
||||
},
|
||||
{
|
||||
"version": "beta.017",
|
||||
"tag_name": "notes/beta.017",
|
||||
"name": "Sanctuary beta.017 — Nage, S’allonger, Se reposer et fabrication manuelle",
|
||||
"body_file": "notes/beta.017.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "75f45ac8203a8be496bf56f20641e5d900dcb6d656004641ab166a975b248a98"
|
||||
},
|
||||
{
|
||||
"version": "beta.018",
|
||||
"tag_name": "notes/beta.018",
|
||||
"name": "Sanctuary beta.018 — Familiers, cape et cosmétique de tête",
|
||||
"body_file": "notes/beta.018.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "af5d7f2a9a08be017e9ea565e0ac7b7f89a5668eaca752bbef3fd83d4dcd1b83"
|
||||
},
|
||||
{
|
||||
"version": "beta.019",
|
||||
"tag_name": "notes/beta.019",
|
||||
"name": "Sanctuary beta.019 — Pouvoirs et déplacements des familiers",
|
||||
"body_file": "notes/beta.019.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "89014f518126d23a927f385e39e0e45bac1862b753eedd5dbf6a982f2fc02ae8"
|
||||
},
|
||||
{
|
||||
"version": "beta.020",
|
||||
"tag_name": "notes/beta.020",
|
||||
"name": "Sanctuary beta.020 — Œufs singuliers, portage et plongeon",
|
||||
"body_file": "notes/beta.020.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "a40be9d43b2e706d96b051a801445ce623889c36963f4a9a1114c3275dab1406"
|
||||
},
|
||||
{
|
||||
"version": "beta.022",
|
||||
"tag_name": "notes/beta.022",
|
||||
"name": "Sanctuary beta.022 — Création de monde plus rapide",
|
||||
"body_file": "notes/beta.022.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "20c29abb7f12024956084f5ad5c384306a0beb57b6f49e5108dd01cad16a3e71"
|
||||
},
|
||||
{
|
||||
"version": "beta.023",
|
||||
"tag_name": "notes/beta.023",
|
||||
"name": "Sanctuary beta.023 — Cosmétiques directement sur leur case",
|
||||
"body_file": "notes/beta.023.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "56290132ee6abb0992102e7b3bc0320298390e56a4055f7f36a49986209b356c"
|
||||
},
|
||||
{
|
||||
"version": "beta.024",
|
||||
"tag_name": "notes/beta.024",
|
||||
"name": "Sanctuary beta.024 — Recettes progressives dans le Blocodex",
|
||||
"body_file": "notes/beta.024.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "92e5de8ae776f91a5ba5f3d0a884fea7e1f81811a83b2a712bf234c08293b4bb"
|
||||
},
|
||||
{
|
||||
"version": "beta.025",
|
||||
"tag_name": "notes/beta.025",
|
||||
"name": "Sanctuary beta.025 — Barres d’XP des compétences",
|
||||
"body_file": "notes/beta.025.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "cbd9efc6d630a0ef2a1511c489545e9e87c8bd79b836b7616ade9ac077f0f8eb"
|
||||
},
|
||||
{
|
||||
"version": "beta.026",
|
||||
"tag_name": "notes/beta.026",
|
||||
"name": "Sanctuary beta.026 — Cache d’affichage Demeure sur la carte",
|
||||
"body_file": "notes/beta.026.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "aee2d4e998dd2ac9dc172d79018e552c658bd66db217f55b94e5bdd6bcabd50b"
|
||||
},
|
||||
{
|
||||
"version": "beta.027",
|
||||
"tag_name": "notes/beta.027",
|
||||
"name": "Sanctuary beta.027 — Piles de joueurs",
|
||||
"body_file": "notes/beta.027.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "9ec554eebe10b8b35955fbebd1404a273b26f8fe3693aed1b2407305cf7c7699"
|
||||
},
|
||||
{
|
||||
"version": "beta.028",
|
||||
"tag_name": "notes/beta.028",
|
||||
"name": "Sanctuary beta.028 — Plongeon jusqu’à collision",
|
||||
"body_file": "notes/beta.028.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "af533c1dc914aeab9e967d07dc8a419a7d039e1ab78365221ac90aae69a2142d"
|
||||
},
|
||||
{
|
||||
"version": "beta.029",
|
||||
"tag_name": "notes/beta.029",
|
||||
"name": "Sanctuary beta.029 — Cri et recul des familiers",
|
||||
"body_file": "notes/beta.029.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "bf653938f97f056493b17666cb2e5504db244cb2f114ebbd4fe581b48b20c53b"
|
||||
},
|
||||
{
|
||||
"version": "beta.030",
|
||||
"tag_name": "notes/beta.030",
|
||||
"name": "Sanctuary beta.030 — Lumières portées et objets jetés",
|
||||
"body_file": "notes/beta.030.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "603e95e406ebf286c1b029d97e18b389f1d0135e1c92374dcdaa927b49ead93c"
|
||||
},
|
||||
{
|
||||
"version": "beta.031",
|
||||
"tag_name": "notes/beta.031",
|
||||
"name": "Sanctuary beta.031 — Apparition galactique",
|
||||
"body_file": "notes/beta.031.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "700426014052a0c44f4a7e360912dae6c1e8c80379cfe298ba5f8c5575cd6efe"
|
||||
},
|
||||
{
|
||||
"version": "beta.032",
|
||||
"tag_name": "notes/beta.032",
|
||||
"name": "Sanctuary beta.032 — Refonte des 88 familiers",
|
||||
"body_file": "notes/beta.032.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "b24ef127919fafdb71d5b0b79b8ed6419b786cfd8b74aed6244b3b4527e6af3f"
|
||||
},
|
||||
{
|
||||
"version": "beta.033",
|
||||
"tag_name": "notes/beta.033",
|
||||
"name": "Sanctuary beta.033 — Menus directs et profil de test rapide",
|
||||
"body_file": "notes/beta.033.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "865724dab2959885f19f39d479b2f49218c4001a818c304c1ef200f30412ccf8"
|
||||
},
|
||||
{
|
||||
"version": "beta.034",
|
||||
"tag_name": "notes/beta.034",
|
||||
"name": "Sanctuary beta.034 — Fil de découvertes et hotbar agrandie",
|
||||
"body_file": "notes/beta.034.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "07afd3e8dd1b82aa515bb0db789975d34b9d44e6ae00f5213b8ea29912e41367"
|
||||
},
|
||||
{
|
||||
"version": "beta.035",
|
||||
"tag_name": "notes/beta.035",
|
||||
"name": "Sanctuary beta.035 — Collections Minecraft",
|
||||
"body_file": "notes/beta.035.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "5b705cd4160907d649f5888130432d390dbd4bc28d3fe5aabf6234feaac30cc6"
|
||||
},
|
||||
{
|
||||
"version": "beta.036",
|
||||
"tag_name": "notes/beta.036",
|
||||
"name": "Sanctuary beta.036 — Notifications et suivi choisi",
|
||||
"body_file": "notes/beta.036.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "3e18002138675b1e7d3f80ae633a49cc9204a32c683405dc1ef2a887d1da5810"
|
||||
},
|
||||
{
|
||||
"version": "beta.037",
|
||||
"tag_name": "notes/beta.037",
|
||||
"name": "Sanctuary beta.037 — Temps réel et suivi commun",
|
||||
"body_file": "notes/beta.037.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "a4dc686f2bea49c80a001c99b3c5e42d77bda5227223ba5e4d2a09d928915180"
|
||||
},
|
||||
{
|
||||
"version": "beta.038",
|
||||
"tag_name": "notes/beta.038",
|
||||
"name": "Sanctuary beta.038 — Nourriture, têtes-tombes et factions",
|
||||
"body_file": "notes/beta.038.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "41d70130039d2fc5bf24fc761757a82223ed7c64bdf209371a2ce1455b2a107b"
|
||||
},
|
||||
{
|
||||
"version": "beta.039",
|
||||
"tag_name": "notes/beta.039",
|
||||
"name": "Sanctuary beta.039 — Hello World, savoir alimentaire et inventaires de mort",
|
||||
"body_file": "notes/beta.039.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "11eeb112aef244c42253a088a407c81513bad569f1a006ea85ff8c37a87c71da"
|
||||
},
|
||||
{
|
||||
"version": "beta.040",
|
||||
"tag_name": "notes/beta.040",
|
||||
"name": "Sanctuary beta.040 — Mode opérateur, fiche Habitant et ciel partagé",
|
||||
"body_file": "notes/beta.040.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "f4ba5e5510fef9200b5a0d1cf0935ef6bb431b334caf55d59566873cdfa068cd"
|
||||
},
|
||||
{
|
||||
"version": "beta.041",
|
||||
"tag_name": "notes/beta.041",
|
||||
"name": "Sanctuary beta.041 — Portage des bébés",
|
||||
"body_file": "notes/beta.041.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "e9d46eb29e305c31e3826c4d65f76ca1666704c75bcef5ccbe43720e5409b8fe"
|
||||
},
|
||||
{
|
||||
"version": "beta.042",
|
||||
"tag_name": "notes/beta.042",
|
||||
"name": "Sanctuary beta.042 — Joueurs assis sur la tête",
|
||||
"body_file": "notes/beta.042.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "bb1937a1ce6b9f559921ec0a70042eea8b5333f4377cf208559d951e6cb2ff04"
|
||||
},
|
||||
{
|
||||
"version": "beta.043",
|
||||
"tag_name": "notes/beta.043",
|
||||
"name": "Sanctuary beta.043 — Blocs portés et icône officielle",
|
||||
"body_file": "notes/beta.043.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "335aac4865234c699ab0210ec2eba76453e5865b23ec34fba319c3e1cd29f4f9"
|
||||
},
|
||||
{
|
||||
"version": "beta.044",
|
||||
"tag_name": "notes/beta.044",
|
||||
"name": "Sanctuary beta.044 — Pouvoirs natifs des familiers",
|
||||
"body_file": "notes/beta.044.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "4fa2312d8b24495a19f6237ddea2db6df5ff830887936900bc6673d34859fb43"
|
||||
},
|
||||
{
|
||||
"version": "beta.045",
|
||||
"tag_name": "notes/beta.045",
|
||||
"name": "Sanctuary beta.045 — Menu principal",
|
||||
"body_file": "notes/beta.045.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "963ac6b973b2dec3a6d7874a33085c35d76ed5b6b9e3203e87b46d859ad60f3a"
|
||||
},
|
||||
{
|
||||
"version": "beta.046",
|
||||
"tag_name": "notes/beta.046",
|
||||
"name": "Sanctuary beta.046 — Constellations, vol et zoom",
|
||||
"body_file": "notes/beta.046.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "dab6dda881a19155e56543c9a23cb2c0081f9e4bb5cd16c8ff36a3cca70ece0a"
|
||||
},
|
||||
{
|
||||
"version": "beta.047",
|
||||
"tag_name": "notes/beta.047",
|
||||
"name": "Sanctuary beta.047 — Étoiles à la longue-vue et boutons",
|
||||
"body_file": "notes/beta.047.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "bc62848c2d43d78898ebed20beb7011e2dc006434edab6202c1a2d9b73605ecb"
|
||||
},
|
||||
{
|
||||
"version": "beta.048",
|
||||
"tag_name": "notes/beta.048",
|
||||
"name": "Sanctuary beta.048 — Bateaux collectifs et moteurs animaux",
|
||||
"body_file": "notes/beta.048.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "67fb7645ea42cc1ba5bc57167ea241dca10f77cead976bf055acb29d596dd773"
|
||||
},
|
||||
{
|
||||
"version": "beta.049",
|
||||
"tag_name": "notes/beta.049",
|
||||
"name": "Sanctuary beta.049 — Bateaux rectangulaires et chapeaux actifs",
|
||||
"body_file": "notes/beta.049.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "4c4a60abae6821ba0fa790abd90ff0ccf87f838b19507cf620e840c2da6b3915"
|
||||
},
|
||||
{
|
||||
"version": "beta.050",
|
||||
"tag_name": "notes/beta.050",
|
||||
"name": "Sanctuary beta.050 — Bateaux compacts et redstone portée",
|
||||
"body_file": "notes/beta.050.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "6e5a697160c8696017ffcf946934d030dea5dc86982f70612ec2ee2bfb1e497d"
|
||||
},
|
||||
{
|
||||
"version": "beta.051",
|
||||
"tag_name": "notes/beta.051",
|
||||
"name": "Sanctuary beta.051 — Distributeur porté orienté par le regard",
|
||||
"body_file": "notes/beta.051.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "087d760d64ebe1ff6b948080e8e47583101c28c31c6e61ed1c6cb1713cd1bfc5"
|
||||
},
|
||||
{
|
||||
"version": "beta.052",
|
||||
"tag_name": "notes/beta.052",
|
||||
"name": "Sanctuary beta.052 — Nom du familier de départ",
|
||||
"body_file": "notes/beta.052.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "9039530f3a89ee84a36883a7ddcf631029400b698ac480355c814bf8fa83be0b"
|
||||
},
|
||||
{
|
||||
"version": "beta.053",
|
||||
"tag_name": "notes/beta.053",
|
||||
"name": "Sanctuary beta.053 — Brume progressive et saturation",
|
||||
"body_file": "notes/beta.053.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "f8cb565c3f91c9c02a364557acb89fd38a85c90d8e7f60e867959cc66cbbe2d6"
|
||||
},
|
||||
{
|
||||
"version": "beta.054",
|
||||
"tag_name": "notes/beta.054",
|
||||
"name": "Sanctuary beta.054 — Duels, mises et niveaux des familiers",
|
||||
"body_file": "notes/beta.054.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "d402b014be15f7bcce2e0cd3e570f06aedd0fb0bb3ce03896b86654d53f70346"
|
||||
},
|
||||
{
|
||||
"version": "beta.055",
|
||||
"tag_name": "notes/beta.055",
|
||||
"name": "Sanctuary beta.055 — Force et portage de joueurs",
|
||||
"body_file": "notes/beta.055.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "858439b89ffce6ccd712bbe490fc795ef8dd57ea2de7a02d7ce81a6e0e7f150c"
|
||||
},
|
||||
{
|
||||
"version": "beta.056",
|
||||
"tag_name": "notes/beta.056",
|
||||
"name": "Sanctuary beta.056 — Étoiles dispersées et saturation",
|
||||
"body_file": "notes/beta.056.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "59db3c7369bad4ac4553e99df7af9f1a7740c990be11e1c5348b0e02eb54109e"
|
||||
},
|
||||
{
|
||||
"version": "beta.057",
|
||||
"tag_name": "notes/beta.057",
|
||||
"name": "Sanctuary beta.057 — Météo quotidienne et nouvelles ambiances",
|
||||
"body_file": "notes/beta.057.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "d9f72880eb257b40711f85163f9ea5e16f74c7fb161482e9f65d66b37fafcbd7"
|
||||
},
|
||||
{
|
||||
"version": "beta.058",
|
||||
"tag_name": "notes/beta.058",
|
||||
"name": "Sanctuary beta.058 — Altitude de la carte",
|
||||
"body_file": "notes/beta.058.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "690d0e236dcc4bef1ba3ce888224e3650dda38076496994157908c674f84b325"
|
||||
},
|
||||
{
|
||||
"version": "beta.059",
|
||||
"tag_name": "notes/beta.059",
|
||||
"name": "Sanctuary beta.059 — Bannières et cartes au trésor",
|
||||
"body_file": "notes/beta.059.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "367ee855927b0f58c5b21daa67611d4c07f951d05edb3b088a60994193bdc873"
|
||||
},
|
||||
{
|
||||
"version": "beta.060",
|
||||
"tag_name": "beta.060",
|
||||
"name": "Sanctuary beta.060 — Objets et blocs sur les mobs",
|
||||
"body_file": "notes/beta.060.md",
|
||||
"source_tag_exact": true,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "4ea89295586862e5b549e9ed386f4467b9816290380e2d26c84765eecf186ded"
|
||||
},
|
||||
{
|
||||
"version": "beta.061",
|
||||
"tag_name": "beta.061",
|
||||
"name": "Sanctuary beta.061 — Musique d’arrivée et montures familières",
|
||||
"body_file": "notes/beta.061.md",
|
||||
"source_tag_exact": true,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "410bc6d3b4a01133d4ebb653e7923216bd615584a148c1711d009f681d1d5616"
|
||||
},
|
||||
{
|
||||
"version": "beta.062",
|
||||
"tag_name": "notes/beta.062",
|
||||
"name": "Sanctuary beta.062 — Soleil et chapeaux des familiers",
|
||||
"body_file": "notes/beta.062.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "a082002465fb10d1c9e133270e9ec96ade18ec087703b1e7e3cbfd57f06d0a12"
|
||||
},
|
||||
{
|
||||
"version": "beta.063",
|
||||
"tag_name": "notes/beta.063",
|
||||
"name": "Sanctuary beta.063 — Chargement et règle de jeu Sanctuary",
|
||||
"body_file": "notes/beta.063.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "84e009e16741375fc36a01d54d33476c0f0f9559a0dc3eb146549d9326b579c7"
|
||||
},
|
||||
{
|
||||
"version": "beta.064",
|
||||
"tag_name": "notes/beta.064",
|
||||
"name": "Sanctuary beta.064 — Cache et étapes réelles de préparation",
|
||||
"body_file": "notes/beta.064.md",
|
||||
"source_tag_exact": false,
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"body_sha256": "40073089e63e58e1c5f1a994ab17e3dee5d4869cbb00e33c1a63d7d204c6d015"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,224 @@
|
||||
plugins {
|
||||
id 'net.fabricmc.fabric-loom'
|
||||
}
|
||||
|
||||
group = rootProject.maven_group
|
||||
version = rootProject.mod_version
|
||||
base { archivesName = 'sanctuary' }
|
||||
|
||||
def clientRenderTests = providers.gradleProperty('sanctuaryClientTests')
|
||||
.map { it.toBoolean() }.getOrElse(false)
|
||||
|
||||
fabricApi {
|
||||
configureTests {
|
||||
createSourceSet = true
|
||||
modId = 'sanctuary-gametest'
|
||||
enableGameTests = true
|
||||
// Explicit opt-in: the rendering check needs a graphics-capable client.
|
||||
enableClientGameTests = clientRenderTests
|
||||
eula = false
|
||||
}
|
||||
}
|
||||
|
||||
if (clientRenderTests) {
|
||||
sourceSets.named('gametest') {
|
||||
java.srcDir('src/clientGametest/java')
|
||||
}
|
||||
}
|
||||
|
||||
// Every integration run must exercise fresh generation, never stale saved chunks.
|
||||
tasks.register('cleanGameTestWorld', Delete) {
|
||||
delete(layout.buildDirectory.dir('run/gameTest/world'))
|
||||
}
|
||||
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'))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
|
||||
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
|
||||
implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
options.release = 25
|
||||
}
|
||||
|
||||
processResources {
|
||||
// The launcher uses 26.3-pre-2; Fabric's runtime normalizes it to 26.3-pre.2.
|
||||
def values = [version: project.version, minecraft_version: rootProject.minecraft_version,
|
||||
minecraft_dependency_version: rootProject.minecraft_version.replace('-pre-', '-pre.'),
|
||||
loader_version: rootProject.loader_version, fabric_api_version: rootProject.fabric_api_version]
|
||||
inputs.properties(values)
|
||||
filesMatching('fabric.mod.json') { expand(values) }
|
||||
}
|
||||
|
||||
tasks.named('jar') {
|
||||
from(rootProject.file('LICENSE')) { rename { 'LICENSE_sanctuary' } }
|
||||
from(rootProject.file('THIRD_PARTY_NOTICES.md'))
|
||||
}
|
||||
|
||||
tasks.register('worldgenSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check natural central holes, continuous erosion, density bounds and exterior void.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.WorldgenSmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('worldgenSmoke') }
|
||||
|
||||
tasks.register('hydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check deterministic surface drainage, natural support and bounded excavation.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.SurfaceHydrologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('hydrologySmoke') }
|
||||
|
||||
// src/test contains a JavaExec assertion harness; engine tests use Fabric's gametest source set.
|
||||
tasks.named('test') { failOnNoDiscoveredTests = false }
|
||||
|
||||
tasks.register('naturalHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check broad shores, supported sediments and natural rock springs.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.NaturalHydrologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('naturalHydrologySmoke') }
|
||||
|
||||
tasks.register('starterLavaSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check a small contained lava niche in naturally supported rock.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.StarterLavaDepositSmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('starterLavaSmoke') }
|
||||
|
||||
// Alpha.6 has separate contracts so saved alpha.5 worlds keep their generation.
|
||||
tasks.register('layeredHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check retained basins, broad rock shores and lower wall springs.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.LayeredHydrologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('layeredHydrologySmoke') }
|
||||
|
||||
tasks.register('layeredEcologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check altitude biome variation and naturally supported lower terraces.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.LayeredEcologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('layeredEcologySmoke') }
|
||||
|
||||
// Alpha.7 keeps previous biome palettes and generators unchanged.
|
||||
tasks.register('woodlandPaletteSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check surface forest coverage and bounded rare interior biomes.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.WoodlandPaletteSmoke'
|
||||
}
|
||||
tasks.register('woodlandGroveSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check natural tree supports and canopy space without creating terrain.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.WoodlandGroveSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('woodlandPaletteSmoke', 'woodlandGroveSmoke') }
|
||||
|
||||
tasks.register('riftShapeSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftShapeSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftShapeSmoke') }
|
||||
|
||||
tasks.register('riftPaletteSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftPaletteSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftPaletteSmoke') }
|
||||
|
||||
tasks.register('riftHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftHydrologySmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftHydrologySmoke') }
|
||||
|
||||
tasks.register('riftLavaSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftLavaSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftLavaSmoke') }
|
||||
|
||||
tasks.register('cavernPaletteSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.CavernPaletteSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('cavernPaletteSmoke') }
|
||||
|
||||
tasks.register('cavernHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.CavernHydrologySmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('cavernHydrologySmoke') }
|
||||
|
||||
tasks.register('cavernLavaSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.CavernLavaSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('cavernLavaSmoke') }
|
||||
|
||||
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') }
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Arrays;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
|
||||
/** Opt-in check of production render mixins in the actual client, without opening a save. */
|
||||
public final class SanctuaryClientRenderTests implements FabricClientGameTest {
|
||||
@Override
|
||||
public void runTest(ClientGameTestContext context) {
|
||||
context.runOnClient(client -> {
|
||||
assertApplied(client.getClass().getClassLoader(),
|
||||
"net.minecraft.client.renderer.SkyRenderer", "sanctuary$openLowerSky");
|
||||
assertApplied(client.getClass().getClassLoader(),
|
||||
"net.minecraft.client.renderer.fog.FogRenderer", "sanctuary$openVoidFog");
|
||||
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
|
||||
});
|
||||
}
|
||||
|
||||
private static void assertApplied(ClassLoader loader, String target, String hook) {
|
||||
try {
|
||||
// Force transformation even when this renderer is lazily loaded on world entry.
|
||||
Class<?> transformed = Class.forName(target, true, loader);
|
||||
boolean applied = Arrays.stream(transformed.getDeclaredMethods())
|
||||
.anyMatch(method -> method.getName().contains(hook));
|
||||
if (!applied) {
|
||||
throw new AssertionError("Missing Sanctuary render injection: " + target + " / " + hook);
|
||||
}
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new AssertionError("Minecraft render target is missing: " + target, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.MainIslandDensity;
|
||||
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.RandomState;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.storage.LevelData;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Test-only sampled density plots, not screenshots or a substitute for looking at generated chunks. */
|
||||
final class DensityDiagnostics {
|
||||
private static final int EXTENT = 304;
|
||||
private static final int STEP = 4;
|
||||
private static final int GRID = 2 * EXTENT / STEP + 1;
|
||||
private static final int MAP_SIZE = GRID * 4;
|
||||
private static final int VOID_COLOR = 0x132132;
|
||||
|
||||
private DensityDiagnostics() {}
|
||||
|
||||
static void verifyAndExport(GameTestHelper helper, LevelData.RespawnData spawn) throws IOException {
|
||||
var level = helper.getLevel();
|
||||
var registries = level.registryAccess();
|
||||
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value();
|
||||
DensityFunction finalDensity = registries.lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density_384"))).value();
|
||||
helper.assertTrue(finalDensity instanceof MainIslandDensity,
|
||||
"The diagnostic must inspect the actual production island density wrapper");
|
||||
MainIslandDensity island = (MainIslandDensity) finalDensity;
|
||||
DensityFunction legacy = registries.lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density"))).value();
|
||||
var noises = registries.lookupOrThrow(Registries.NOISE);
|
||||
// The default run exports all three seeds once. Additional complete-world runs only
|
||||
// repeat the sparse assertions, avoiding redundant image generation.
|
||||
boolean export = level.getSeed() == 0L && Boolean.parseBoolean(System.getProperty("sanctuary.test.densityMaps", "true"));
|
||||
boolean foundUnforcedCentralVoid = false;
|
||||
for (long seed : new long[]{0L, 42L, 8675309L}) {
|
||||
RandomState state = RandomState.create(noises, seed, settings);
|
||||
boolean foundSolid = false;
|
||||
var bottomHeights = new java.util.TreeSet<Integer>();
|
||||
for (int x = -288; x <= 288; x += 16) {
|
||||
for (int z = -288; z <= 288; z += 16) {
|
||||
int firstSolid = -1;
|
||||
for (int y = 4; y < island.height(); y += 8) {
|
||||
float shaped = state.sampleBlockValueUncached(finalDensity, x, y, z);
|
||||
float natural = state.sampleBlockValueUncached(island.terrain(), x, y - island.verticalOffset(), z);
|
||||
helper.assertTrue(Float.isFinite(shaped), "Sculpted density must be finite");
|
||||
helper.assertTrue(shaped <= 0.0F || natural > 0.0F,
|
||||
"Sculpt must not invent solid terrain; seed " + seed + " at " + x + "," + y + "," + z);
|
||||
foundSolid |= shaped > 0.0F;
|
||||
if (shaped > 0.0F && firstSolid < 0) firstSolid = y;
|
||||
if (Math.abs(x) <= 48 && Math.abs(z) <= 48 && y >= 44 + island.verticalOffset() && y <= 108 + island.verticalOffset()) {
|
||||
foundUnforcedCentralVoid |= natural < 0.0F && shaped < 0.0F;
|
||||
}
|
||||
}
|
||||
if (firstSolid >= 0 && Math.abs(x) <= 96 && Math.abs(z) <= 96) bottomHeights.add(firstSolid);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(foundSolid, "The sampled natural island must contain land for seed " + seed);
|
||||
helper.assertTrue(bottomHeights.size() >= 4 && bottomHeights.first() >= 16,
|
||||
"The central underside must vary in height and leave an empty margin below; seed " + seed);
|
||||
SanctuaryMod.LOGGER.info("Raised island seed {}: sampled central underside Y{}..{} ({} distinct heights)",
|
||||
seed, bottomHeights.first(), bottomHeights.last(), bottomHeights.size());
|
||||
verifyUpperTranslation(helper, state, island, legacy, seed);
|
||||
if (export) {
|
||||
export(state, island, legacy, seed, seed == level.getSeed() ? spawn : null);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(foundUnforcedCentralVoid,
|
||||
"The regression seeds must exercise natural holes inside the former forced core");
|
||||
}
|
||||
|
||||
private static void verifyUpperTranslation(GameTestHelper helper, RandomState state,
|
||||
MainIslandDensity island, DensityFunction legacy, long seed) {
|
||||
for (int x = -256; x <= 256; x += 32) {
|
||||
for (int z = -256; z <= 256; z += 32) {
|
||||
for (int oldY = 80; oldY < 256; oldY += 8) {
|
||||
float before = state.sampleBlockValueUncached(legacy, x, oldY, z);
|
||||
float after = state.sampleBlockValueUncached(island, x, oldY + island.verticalOffset(), z);
|
||||
helper.assertTrue(Math.abs(before - after) < 0.000001F,
|
||||
"The upper silhouette must keep its seed's alpha.2 profile after translation; seed "
|
||||
+ seed + " at " + x + "," + oldY + "," + z + ": " + before + " -> " + after);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void export(RandomState state, MainIslandDensity island, DensityFunction legacy, long seed,
|
||||
LevelData.RespawnData spawn) throws IOException {
|
||||
Path directory = Path.of("diagnostics");
|
||||
Files.createDirectories(directory);
|
||||
BufferedImage picture = new BufferedImage(MAP_SIZE * 2 + 60, 715 + 2 * (island.height() + 28), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = picture.createGraphics();
|
||||
graphics.setColor(new Color(0xF0F0EC));
|
||||
graphics.fillRect(0, 0, picture.getWidth(), picture.getHeight());
|
||||
graphics.setColor(new Color(0x14202A));
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 18));
|
||||
graphics.drawString("Seed " + seed + " | alpha.2 (actual preserved density)", 20, 27);
|
||||
graphics.drawString("Raised production density (384 blocks)", MAP_SIZE + 40, 27);
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
|
||||
graphics.drawString("Top view: x/z -304..304, sample every 4 blocks; height sampled every 4 blocks", 20, 48);
|
||||
graphics.drawString("Dark = void; low green to high cream. Terrain only; no trees/materials.", MAP_SIZE + 40, 48);
|
||||
StringBuilder heights = new StringBuilder("x,z,alpha2_top_y,raised_top_y\n");
|
||||
int beforeColumns = 0;
|
||||
int afterColumns = 0;
|
||||
for (int gx = 0; gx < GRID; gx++) {
|
||||
int x = -EXTENT + gx * STEP;
|
||||
for (int gz = 0; gz < GRID; gz++) {
|
||||
int z = -EXTENT + gz * STEP;
|
||||
int beforeTop = -1;
|
||||
int afterTop = -1;
|
||||
// Stop as soon as both topmost samples have been found.
|
||||
for (int y = island.height() - 4; y >= 4 && (beforeTop < 0 || afterTop < 0); y -= 4) {
|
||||
if (beforeTop < 0) {
|
||||
if (state.sampleBlockValueUncached(legacy, x, y, z) > 0.0F) beforeTop = y;
|
||||
}
|
||||
if (afterTop < 0 && state.sampleBlockValueUncached(island, x, y, z) > 0.0F) afterTop = y;
|
||||
}
|
||||
beforeColumns += beforeTop >= 0 ? 1 : 0;
|
||||
afterColumns += afterTop >= 0 ? 1 : 0;
|
||||
graphics.setColor(new Color(heightColor(beforeTop)));
|
||||
graphics.fillRect(20 + gx * 4, 60 + gz * 4, 4, 4);
|
||||
graphics.setColor(new Color(heightColor(afterTop)));
|
||||
graphics.fillRect(MAP_SIZE + 40 + gx * 4, 60 + gz * 4, 4, 4);
|
||||
heights.append(x).append(',').append(z).append(',').append(beforeTop).append(',').append(afterTop).append('\n');
|
||||
}
|
||||
}
|
||||
if (spawn != null) {
|
||||
graphics.setColor(Color.MAGENTA);
|
||||
int px = MAP_SIZE + 40 + spawn.pos().getX() + EXTENT;
|
||||
int pz = 60 + spawn.pos().getZ() + EXTENT;
|
||||
graphics.drawOval(px - 4, pz - 4, 8, 8);
|
||||
graphics.drawString("Initial spawn " + spawn.pos().toShortString(), MAP_SIZE + 40, 689);
|
||||
}
|
||||
drawSections(graphics, state, island, legacy, false, 715);
|
||||
drawSections(graphics, state, island, legacy, true, 715 + island.height() + 28);
|
||||
graphics.dispose();
|
||||
Path output = directory.resolve("island-density-seed-" + seed + ".png");
|
||||
ImageIO.write(picture, "png", output.toFile());
|
||||
Files.writeString(directory.resolve("island-heightmap-seed-" + seed + ".csv"), heights);
|
||||
SanctuaryMod.LOGGER.info("Island density diagnostic seed {}: {} -> {} sampled land columns; {}",
|
||||
seed, beforeColumns, afterColumns, output.toAbsolutePath());
|
||||
}
|
||||
|
||||
private static void drawSections(Graphics2D graphics, RandomState state, MainIslandDensity island, DensityFunction legacy,
|
||||
boolean alongZ, int top) {
|
||||
graphics.setColor(new Color(0x14202A));
|
||||
graphics.drawString(alongZ ? "Section x=0: z -304..304; y 383 (top)..0 (bottom)"
|
||||
: "Section z=0: x -304..304; y 383 (top)..0 (bottom)", 20, top - 8);
|
||||
for (int horizontal = -EXTENT; horizontal < EXTENT; horizontal += 2) {
|
||||
int x = alongZ ? 0 : horizontal;
|
||||
int z = alongZ ? horizontal : 0;
|
||||
for (int y = 0; y < island.height(); y++) {
|
||||
float before = state.sampleBlockValueUncached(legacy, x, y, z);
|
||||
float after = state.sampleBlockValueUncached(island, x, y, z);
|
||||
graphics.setColor(new Color(before > 0.0F ? heightColor(y) : VOID_COLOR));
|
||||
graphics.fillRect(20 + horizontal + EXTENT, top + island.height() - 1 - y, 2, 1);
|
||||
graphics.setColor(new Color(after > 0.0F ? heightColor(y) : VOID_COLOR));
|
||||
graphics.fillRect(MAP_SIZE + 40 + horizontal + EXTENT, top + island.height() - 1 - y, 2, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int heightColor(int y) {
|
||||
if (y < 0) return VOID_COLOR;
|
||||
float height = Math.clamp(y / 280.0F, 0.0F, 1.0F);
|
||||
int red = (int) (43 + 192 * height);
|
||||
int green = (int) (91 + 139 * height);
|
||||
int blue = (int) (70 + 117 * height);
|
||||
return red << 16 | green << 8 | blue;
|
||||
}
|
||||
}
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.SurfaceHydrology;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Checks the actual decorated terrain and fluid simulation in a disposable world, far from test fixtures. */
|
||||
public final class HydrologyDiagnostics {
|
||||
private static final List<Direction> HORIZONTAL = List.of(Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST);
|
||||
|
||||
private HydrologyDiagnostics() {}
|
||||
|
||||
public static void start(GameTestHelper helper) throws IOException {
|
||||
Context context = new Context(helper);
|
||||
context.prepare();
|
||||
context.verifyNaturalShellAndFinishedWater();
|
||||
context.verifyAllObservedWaterIsPlanned();
|
||||
context.export("generated");
|
||||
helper.assertTrue(context.caneBlocks > 0,
|
||||
"Regression seed must provide at least one naturally valid shore sugar cane plant; inspect the exported site map");
|
||||
// Other synchronous generation diagnostics finish before this tick. The temporary fluid
|
||||
// witness therefore cannot be counted by the initial resource survey.
|
||||
helper.runAtTickTime(20, context::startFluidWitness);
|
||||
helper.runAtTickTime(50, context::verifyFluidWitnessAndRemoveIt);
|
||||
helper.runAtTickTime(200, () -> {
|
||||
try {
|
||||
context.verifyNaturalShellAndFinishedWater();
|
||||
context.verifyAllObservedWaterIsPlanned();
|
||||
context.export("after-200-ticks");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: {} water blocks retained after real fluid ticks, {} chunk seams checked",
|
||||
context.level.getSeed(), context.expectedWater.size(), context.seams);
|
||||
helper.succeed();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Could not write hydrology diagnostics", exception);
|
||||
} finally {
|
||||
context.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void verifyLegacyIsolation(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var active = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertTrue(HydrologyRuntime.enabled(active), "The current preset must enable surface hydrology");
|
||||
helper.assertTrue(active.generatorSettings().is(SanctuarySpawn.HYDROLOGY_SETTINGS),
|
||||
"The active generator must use the new hydrology settings, not alter a legacy settings key");
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
for (var key : List.of(SanctuarySpawn.SETTINGS, SanctuarySpawn.RAISED_SETTINGS)) {
|
||||
var legacy = new NoiseBasedChunkGenerator(active.getBiomeSource(), registry.getOrThrow(key));
|
||||
helper.assertFalse(HydrologyRuntime.enabled(legacy), "Saved legacy generator must remain dry: " + key.identifier());
|
||||
helper.assertFalse(new ShoreSugarCaneFeature().place(level, legacy, RandomSource.create(0), BlockPos.ZERO),
|
||||
"Even an explicitly invoked shoreline feature must ignore legacy generators");
|
||||
}
|
||||
var biomes = level.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
var oldBiome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest"))).value();
|
||||
var wetBiome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest_hydrology"))).value();
|
||||
var shore = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE).getOrThrow(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, SanctuaryMod.id("shore_sugar_cane"))).value();
|
||||
helper.assertFalse(oldBiome.getGenerationSettings().hasFeature(shore),
|
||||
"The old biome referenced by saved worlds must not acquire the new shoreline decoration");
|
||||
helper.assertTrue(wetBiome.getGenerationSettings().hasFeature(shore),
|
||||
"The new hydrology biome must include its shoreline decoration");
|
||||
helper.assertTrue(active.getBiomeSource().possibleBiomes().stream().allMatch(holder -> holder.is(
|
||||
ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest_hydrology")))),
|
||||
"The current preset must select the separate hydrology biome");
|
||||
}
|
||||
|
||||
private static final class Context {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SurfaceHydrology.Plan plan;
|
||||
final RandomState random;
|
||||
final DensityFunction natural;
|
||||
final Map<Long, SurfaceHydrology.Cell> cells = new HashMap<>();
|
||||
final Set<BlockPos> expectedWater = new HashSet<>();
|
||||
final Map<Long, LevelChunk> chunks = new TreeMap<>();
|
||||
final Set<Long> newlyForced = new HashSet<>();
|
||||
BlockPos witness;
|
||||
boolean witnessPlaced;
|
||||
boolean allWaterChunksTicking;
|
||||
int seams;
|
||||
int caneBlocks;
|
||||
|
||||
Context(GameTestHelper helper) {
|
||||
this.helper = helper;
|
||||
level = helper.getLevel();
|
||||
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertTrue(HydrologyRuntime.enabled(generator), "Hydrology test requires the production wet preset");
|
||||
plan = HydrologyRuntime.plan(level);
|
||||
random = level.getChunkSource().randomState();
|
||||
natural = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
}
|
||||
|
||||
void prepare() {
|
||||
helper.assertTrue(!plan.features().isEmpty(), "Regression seed must exercise real surface water");
|
||||
for (var feature : plan.features()) {
|
||||
SanctuaryMod.LOGGER.info("Hydrology planned site: seed {}, {}, Y{}, start {}, {} water columns, {} path points",
|
||||
level.getSeed(), feature.kind(), feature.waterY(), feature.path().getFirst(), feature.waterCells(), feature.path().size());
|
||||
}
|
||||
if (level.getSeed() == 0L) {
|
||||
helper.assertTrue(plan.features().stream().anyMatch(feature -> feature.kind() == SurfaceHydrology.Kind.STREAM),
|
||||
"The reference seed 0 must demonstrate a real stream, not only isolated ponds; see the logged planned sites");
|
||||
}
|
||||
Set<Long> requested = new HashSet<>();
|
||||
for (var cell : plan.cells()) {
|
||||
helper.assertTrue(cells.put(ChunkPos.pack(cell.x(), cell.z()), cell) == null,
|
||||
"A plan must own each horizontal column exactly once");
|
||||
if (cell.hasWater()) {
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
expectedWater.add(new BlockPos(cell.x(), y, cell.z()));
|
||||
}
|
||||
}
|
||||
int cx = cell.x() >> 4;
|
||||
int cz = cell.z() >> 4;
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) requested.add(ChunkPos.pack(cx + dx, cz + dz));
|
||||
}
|
||||
}
|
||||
helper.assertTrue(!expectedWater.isEmpty(), "At least one generated water volume must be tested");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: preparing {} site and neighbour chunks for actual water inspection",
|
||||
level.getSeed(), requested.size());
|
||||
for (long packed : requested.stream().sorted().toList()) {
|
||||
ChunkPos pos = ChunkPos.unpack(packed);
|
||||
if (level.setChunkForced(pos.x(), pos.z(), true)) newlyForced.add(packed);
|
||||
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Hydrology inspection requires a decorated FULL chunk");
|
||||
chunks.put(packed, (LevelChunk) chunk);
|
||||
}
|
||||
// Use one contained witness in the SAME simulation chunk as actual water. It proves
|
||||
// that the island's fluids tick, rather than merely waiting beside the remote test grid.
|
||||
BlockPos first = expectedWater.stream().min(Comparator.comparingInt((BlockPos pos) -> pos.getX())
|
||||
.thenComparingInt(BlockPos::getZ).thenComparingInt(BlockPos::getY)).orElseThrow();
|
||||
witness = new BlockPos((first.getX() >> 4) * 16 + 8, level.getMaxY() - 20,
|
||||
(first.getZ() >> 4) * 16 + 8);
|
||||
for (BlockPos position : expectedWater) {
|
||||
for (Direction direction : List.of(Direction.EAST, Direction.SOUTH)) {
|
||||
BlockPos next = position.relative(direction);
|
||||
if (expectedWater.contains(next)
|
||||
&& (position.getX() >> 4 != next.getX() >> 4 || position.getZ() >> 4 != next.getZ() >> 4)) seams++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(seams > 0, "Regression seed must exercise a continuous water volume across a chunk seam");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: {} features, {} water blocks, {} FULL chunks including halo, {} seam adjacencies",
|
||||
level.getSeed(), plan.features().size(), expectedWater.size(), chunks.size(), seams);
|
||||
}
|
||||
|
||||
void verifyNaturalShellAndFinishedWater() {
|
||||
for (var cell : plan.cells()) {
|
||||
if (!cell.hasWater()) continue;
|
||||
helper.assertTrue(cell.waterY() > cell.bedY() && cell.waterY() - cell.bedY() <= SurfaceHydrology.MAX_DEPTH,
|
||||
"A surface basin must keep the declared shallow water depth");
|
||||
helper.assertTrue(cell.carveTop() - cell.bedY() <= SurfaceHydrology.MAX_CARVE,
|
||||
"Hydrology must not excavate a deep artificial chamber beneath the surface");
|
||||
for (int depth = 0; depth < 3; depth++) {
|
||||
int y = cell.bedY() - depth;
|
||||
helper.assertTrue(sample(cell.x(), y, cell.z()) > 0,
|
||||
"Water must retain three blocks of natural floor, without adding a liner at " + cell.x() + "," + y + "," + cell.z());
|
||||
BlockPos floor = new BlockPos(cell.x(), y, cell.z());
|
||||
helper.assertTrue(level.getBlockState(floor).isCollisionShapeFullBlock(level, floor),
|
||||
"Generated water floor must remain solid after decoration and fluid ticks: " + floor);
|
||||
}
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
BlockPos water = new BlockPos(cell.x(), y, cell.z());
|
||||
helper.assertTrue(level.getFluidState(water).is(FluidTags.WATER) && level.getFluidState(water).isSource(),
|
||||
"The planned water volume must exist as retained source water: " + water);
|
||||
for (Direction direction : HORIZONTAL) {
|
||||
BlockPos side = water.relative(direction);
|
||||
if (expectedWater.contains(side)) continue;
|
||||
helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) > 0,
|
||||
"Every external underwater side must be supported by natural terrain: " + side);
|
||||
helper.assertTrue(level.getBlockState(side).isCollisionShapeFullBlock(level, side),
|
||||
"A decorated shore must not leave a hole in the three-dimensional water shell: " + side);
|
||||
}
|
||||
}
|
||||
// The original density must have no roof above the explicitly carved surface.
|
||||
// Leaves and branches from neighbouring trees may shade the water afterwards.
|
||||
for (int y = cell.carveTop() + 1; y <= level.getMaxY(); y++) {
|
||||
helper.assertTrue(sample(cell.x(), y, cell.z()) <= 0,
|
||||
"A surface basin must not be a flooded underground chamber: " + cell.x() + "," + y + "," + cell.z());
|
||||
}
|
||||
}
|
||||
for (var feature : plan.features()) {
|
||||
if (feature.kind() != SurfaceHydrology.Kind.STREAM) continue;
|
||||
helper.assertTrue(feature.path().size() > 1, "A stream needs a route connecting distinct surface locations");
|
||||
double travelled = 0;
|
||||
for (int i = 1; i < feature.path().size(); i++) {
|
||||
var from = feature.path().get(i - 1);
|
||||
var to = feature.path().get(i);
|
||||
int length = Math.max(Math.abs(to.x() - from.x()), Math.abs(to.z() - from.z()));
|
||||
helper.assertTrue(length > 0, "A stream route must advance between consecutive points");
|
||||
helper.assertTrue(from.x() == to.x() || from.z() == to.z()
|
||||
|| Math.abs(to.x() - from.x()) == Math.abs(to.z() - from.z()),
|
||||
"Stream control points must be joined by cardinal or diagonal steps");
|
||||
travelled += Math.hypot(to.x() - from.x(), to.z() - from.z());
|
||||
BlockPos previous = null;
|
||||
for (int step = 0; step <= length; step++) {
|
||||
BlockPos p = new BlockPos(from.x() + (to.x() - from.x()) * step / length,
|
||||
feature.waterY(), from.z() + (to.z() - from.z()) * step / length);
|
||||
helper.assertTrue(expectedWater.contains(p) && level.getFluidState(p).is(FluidTags.WATER),
|
||||
"The real stream must form an uninterrupted water route across chunk boundaries: " + p);
|
||||
var column = plan.cellAt(p.getX(), p.getZ());
|
||||
helper.assertTrue(column != null && column.waterY() == feature.waterY(),
|
||||
"This stream contract has one constant water level along its whole route");
|
||||
if (previous != null && p.getX() != previous.getX() && p.getZ() != previous.getZ()) {
|
||||
BlockPos bridgeX = new BlockPos(p.getX(), p.getY(), previous.getZ());
|
||||
BlockPos bridgeZ = new BlockPos(previous.getX(), p.getY(), p.getZ());
|
||||
helper.assertTrue(expectedWater.contains(bridgeX) && level.getFluidState(bridgeX).is(FluidTags.WATER)
|
||||
|| expectedWater.contains(bridgeZ) && level.getFluidState(bridgeZ).is(FluidTags.WATER),
|
||||
"Diagonal route steps need actual cardinal water connectivity, not only touching corners");
|
||||
}
|
||||
previous = p;
|
||||
}
|
||||
}
|
||||
var first = feature.path().getFirst();
|
||||
var last = feature.path().getLast();
|
||||
helper.assertTrue(travelled >= 24 && Math.hypot(last.x() - first.x(), last.z() - first.z()) >= 16,
|
||||
"A demonstrated stream must have at least 24 blocks of route between locations at least 16 blocks apart");
|
||||
}
|
||||
}
|
||||
|
||||
void verifyAllObservedWaterIsPlanned() {
|
||||
int[] observed = {0};
|
||||
int[] cane = {0};
|
||||
for (LevelChunk chunk : chunks.values()) {
|
||||
chunk.findBlocks(state -> state.getFluidState().is(FluidTags.WATER) || state.is(Blocks.SUGAR_CANE), (pos, state) -> {
|
||||
if (state.getFluidState().is(FluidTags.WATER)) {
|
||||
helper.assertTrue(expectedWater.contains(pos),
|
||||
"Water escaped its declared surface volume, possibly through a chunk seam or toward the void: " + pos);
|
||||
observed[0]++;
|
||||
}
|
||||
if (state.is(Blocks.SUGAR_CANE)) {
|
||||
helper.assertTrue(state.canSurvive(level, pos), "Generated shore sugar cane must survive at " + pos);
|
||||
cane[0]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
helper.assertTrue(observed[0] == expectedWater.size(), "Observed water must account for every planned water block");
|
||||
caneBlocks = cane[0];
|
||||
}
|
||||
|
||||
void startFluidWitness() {
|
||||
helper.assertTrue(level.shouldTickBlocksAt(witness), "The witness and its water-site chunk must actually tick");
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
for (int dy = -4; dy <= 0; dy++) {
|
||||
BlockPos p = witness.offset(dx, dy, dz);
|
||||
helper.assertTrue(level.getBlockState(p).isAir(), "The temporary simulation witness must use empty test space");
|
||||
}
|
||||
}
|
||||
}
|
||||
witnessPlaced = true;
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
for (int dy = -4; dy <= 0; dy++) {
|
||||
if (dx != 0 || dz != 0 || dy == -4) level.setBlock(witness.offset(dx, dy, dz), Blocks.STONE.defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
level.setBlock(witness, Blocks.WATER.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getFluidState(witness.below()).isEmpty(), "Witness starts with an empty cell below its source");
|
||||
level.scheduleTick(witness, Fluids.WATER, 1);
|
||||
for (BlockPos water : expectedWater) {
|
||||
helper.assertTrue(level.shouldTickBlocksAt(water), "Every actual water-site chunk must be simulated: " + water);
|
||||
level.scheduleTick(water, Fluids.WATER, 1);
|
||||
}
|
||||
allWaterChunksTicking = true;
|
||||
}
|
||||
|
||||
void verifyFluidWitnessAndRemoveIt() {
|
||||
try {
|
||||
helper.assertTrue(level.getFluidState(witness.below()).is(FluidTags.WATER),
|
||||
"Real fluid simulation must move the witness water down inside the forced island chunk");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: fluid tick witness passed at {}", level.getSeed(), witness);
|
||||
} finally {
|
||||
removeWitness();
|
||||
}
|
||||
}
|
||||
|
||||
void removeWitness() {
|
||||
if (!witnessPlaced) return;
|
||||
// Empty the fluid column before opening its walls, keeping the test fixture contained.
|
||||
for (int dy = 0; dy >= -3; dy--) level.setBlock(witness.offset(0, dy, 0), Blocks.AIR.defaultBlockState(), 3);
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
for (int dy = -4; dy <= 0; dy++) level.setBlock(witness.offset(dx, dy, dz), Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
witnessPlaced = false;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
removeWitness();
|
||||
for (long packed : newlyForced) {
|
||||
ChunkPos chunk = ChunkPos.unpack(packed);
|
||||
level.setChunkForced(chunk.x(), chunk.z(), false);
|
||||
}
|
||||
}
|
||||
|
||||
float sample(int x, int y, int z) {
|
||||
return random.sampleBlockValueUncached(natural, x, y, z);
|
||||
}
|
||||
|
||||
void export(String phase) throws IOException {
|
||||
Path directory = Path.of("diagnostics");
|
||||
Files.createDirectories(directory);
|
||||
String stem = "surface-hydrology-seed-" + level.getSeed() + "-" + phase;
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("phase", phase);
|
||||
report.put("actual_water_blocks", expectedWater.size());
|
||||
report.put("actual_cane_blocks", caneBlocks);
|
||||
report.put("checked_chunk_seam_adjacencies", seams);
|
||||
report.put("all_water_site_chunks_confirmed_ticking", allWaterChunksTicking);
|
||||
report.put("inspected_full_chunks", chunks.values().stream().map(chunk ->
|
||||
Map.of("x", chunk.getPos().x(), "z", chunk.getPos().z())).toList());
|
||||
report.put("features", plan.features());
|
||||
report.put("shore_diagnostics_by_feature", shoreDiagnostics());
|
||||
report.put("planned_columns", plan.cells());
|
||||
report.put("scope", "actual blocks in completed site chunks and a one-chunk halo; uninspected map cells remain blank");
|
||||
Files.writeString(directory.resolve(stem + ".json"), new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
exportMap(directory.resolve(stem + ".png"), phase);
|
||||
}
|
||||
|
||||
Map<Long, Map<String, Object>> shoreDiagnostics() {
|
||||
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
|
||||
for (var feature : plan.features()) {
|
||||
int dryBanks = 0;
|
||||
int banksAtWaterLevel = 0;
|
||||
int baseCanSurvive = 0;
|
||||
int unobstructedBases = 0;
|
||||
int viableUnobstructedBases = 0;
|
||||
int actualCane = 0;
|
||||
for (var cell : plan.cells()) {
|
||||
if (cell.featureId() != feature.id() || cell.hasWater()) continue;
|
||||
dryBanks++;
|
||||
if (cell.bedY() == feature.waterY()) banksAtWaterLevel++;
|
||||
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
|
||||
boolean viable = Blocks.SUGAR_CANE.defaultBlockState().canSurvive(level, base);
|
||||
boolean clear = level.getBlockState(base).isAir() || level.getBlockState(base).is(Blocks.SUGAR_CANE);
|
||||
if (viable) baseCanSurvive++;
|
||||
if (clear) unobstructedBases++;
|
||||
if (viable && clear) viableUnobstructedBases++;
|
||||
for (int height = 0; height < 4; height++) {
|
||||
if (level.getBlockState(base.above(height)).is(Blocks.SUGAR_CANE)) actualCane++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
result.put(feature.id(), Map.of("kind", feature.kind().name(), "water_y", feature.waterY(),
|
||||
"dry_bank_columns", dryBanks, "bank_surface_at_water_level", banksAtWaterLevel,
|
||||
"bases_where_cane_can_survive", baseCanSurvive, "unobstructed_bases", unobstructedBases,
|
||||
"viable_unobstructed_bases", viableUnobstructedBases,
|
||||
"actual_cane_blocks_on_planned_banks", actualCane));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void exportMap(Path output, String phase) throws IOException {
|
||||
int minX = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMinBlockX()).min().orElseThrow();
|
||||
int maxX = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMaxBlockX()).max().orElseThrow();
|
||||
int minZ = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMinBlockZ()).min().orElseThrow();
|
||||
int maxZ = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMaxBlockZ()).max().orElseThrow();
|
||||
int scale = Math.max(1, Math.min(3, 1000 / Math.max(maxX - minX + 1, maxZ - minZ + 1)));
|
||||
int width = (maxX - minX + 1) * scale;
|
||||
int height = (maxZ - minZ + 1) * scale;
|
||||
BufferedImage image = new BufferedImage(Math.max(width + 40, 920), height + 110, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
graphics.setColor(new Color(0xF0F0EC));
|
||||
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
|
||||
graphics.setColor(new Color(0x17222C));
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 17));
|
||||
graphics.drawString("Actual FULL chunks | seed " + level.getSeed() + " | " + phase, 20, 24);
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
|
||||
graphics.drawString("Water blue; sand gold; clay teal; gravel brown; cane bright green; terrain height grey. Canopy skipped.", 20, 45);
|
||||
graphics.drawString("Not a Minecraft render. X=" + minX + ".." + maxX + ", Z=" + minZ + ".." + maxZ
|
||||
+ ". Dark blue = void; dark brown = not inspected; faint lines = chunk seams.", 20, 63);
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
LevelChunk chunk = chunks.get(ChunkPos.pack(x >> 4, z >> 4));
|
||||
int color = 0x3A3030;
|
||||
if (chunk != null) {
|
||||
color = 0x142738;
|
||||
for (int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE, x, z); y >= level.getMinY(); y--) {
|
||||
position.set(x, y, z);
|
||||
BlockState state = chunk.getBlockState(position);
|
||||
if (state.getFluidState().is(FluidTags.WATER)) { color = 0x207BD4; break; }
|
||||
if (state.is(Blocks.SUGAR_CANE)) { color = 0x7AFF38; break; }
|
||||
if (state.is(Blocks.SAND)) { color = 0xD5B668; break; }
|
||||
if (state.is(Blocks.CLAY)) { color = 0x6D9FAD; break; }
|
||||
if (state.is(Blocks.GRAVEL)) { color = 0x9B8878; break; }
|
||||
if (state.isAir() || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|
||||
|| state.getCollisionShape(level, position).isEmpty()) continue;
|
||||
int value = 40 + Math.clamp(y, 0, level.getHeight()) * 180 / level.getHeight();
|
||||
color = value << 16 | value << 8 | value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
graphics.setColor(new Color(color));
|
||||
graphics.fillRect(20 + (x - minX) * scale, 80 + (z - minZ) * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
graphics.setColor(new Color(255, 255, 255, 35));
|
||||
for (int x = minX; x <= maxX; x += 16) graphics.drawLine(20 + (x - minX) * scale, 80, 20 + (x - minX) * scale, 80 + height);
|
||||
for (int z = minZ; z <= maxZ; z += 16) graphics.drawLine(20, 80 + (z - minZ) * scale, 20 + width, 80 + (z - minZ) * scale);
|
||||
graphics.dispose();
|
||||
ImageIO.write(image, "png", output.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1308
File diff suppressed because it is too large
Load Diff
+1030
File diff suppressed because it is too large
Load Diff
+598
@@ -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<Integer> 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<PopulationHydrologyRuntime.RegionPlan> regions = new ArrayList<>();
|
||||
final Map<Long, LevelChunk> chunks = new TreeMap<>();
|
||||
final Set<Long> forced = new HashSet<>();
|
||||
final Set<BlockPos> expectedWater = new HashSet<>();
|
||||
final Map<BlockPos, PopulationHydrology.Cell> selectedCells = new LinkedHashMap<>();
|
||||
final Map<BlockPos, BlockState> naturalLogs = new HashMap<>();
|
||||
final Set<BlockPos> declaredWater = new HashSet<>();
|
||||
final Set<BlockPos> expectedLava = new HashSet<>();
|
||||
final Set<BlockPos> lavaSupports = new HashSet<>();
|
||||
final List<PopulationLavaDeposit.Cell> pocketCells = new ArrayList<>();
|
||||
final List<PopulationLavaFlows.Fall> lavaFalls = new ArrayList<>();
|
||||
final List<Map<String,Object>> flowReports = new ArrayList<>();
|
||||
int reentryWitnesses;
|
||||
final List<PopulationHydrology.Spring> springs = new ArrayList<>();
|
||||
final List<PopulationHydrology.Spill> spills = new ArrayList<>();
|
||||
final List<Map<String,Object>> 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<IslandCapacity.Region> 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<Long> 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<Long>();
|
||||
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<BlockPos> 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<Long> 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<regions.size();i++) {
|
||||
var region=regions.get(i).region(); var counts=new TreeMap<String,Long>();
|
||||
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<BlockPos> 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<BlockPos> 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<BlockPos>();
|
||||
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<BlockPos> destination) {
|
||||
return connectedDescent(source, outlet, destination, false);
|
||||
}
|
||||
|
||||
int connectedDescent(BlockPos source,BlockPos outlet,Set<BlockPos> 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<BlockPos>(); var queue=new ArrayDeque<BlockPos>();
|
||||
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<String,Object>();
|
||||
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<Long> 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<Long> 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));
|
||||
}
|
||||
}
|
||||
+74
@@ -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<Map<String, Object>> 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");
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
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;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.Property;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.storage.LevelData;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Exact post-generation observations in the disposable GameTest world; never edits terrain. */
|
||||
public final class ResourceSurveyDiagnostics {
|
||||
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
|
||||
private static final int DECORATION_MARGIN = 16;
|
||||
private static final int PREPARATION_HALO_CHUNKS = 1;
|
||||
|
||||
private ResourceSurveyDiagnostics() {}
|
||||
|
||||
public static void verifyAndExport(GameTestHelper helper, LevelData.RespawnData initialSpawn) throws IOException {
|
||||
String mode = System.getProperty("sanctuary.test.resourceSurvey", "sample");
|
||||
if (mode.equals("off")) return;
|
||||
if (!mode.equals("sample") && !mode.equals("island")) {
|
||||
throw new IllegalArgumentException("sanctuary.test.resourceSurvey must be sample, island or off");
|
||||
}
|
||||
ServerLevel level = helper.getLevel();
|
||||
helper.assertTrue(SanctuarySpawn.usesSanctuaryGenerator(level),
|
||||
"A resource survey must inspect the production Sanctuary generator");
|
||||
helper.assertTrue(initialSpawn != null, "Use the actual initial spawn, not the remote GameTest fixture");
|
||||
Instant started = Instant.now();
|
||||
long startedNanos = System.nanoTime();
|
||||
long initialGameTime = level.getGameTime();
|
||||
int centreX = initialSpawn.pos().getX() >> 4;
|
||||
int centreZ = initialSpawn.pos().getZ() >> 4;
|
||||
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;
|
||||
int maximumX = mode.equals("island") ? maximumIslandChunk : centreX + 1;
|
||||
int minimumZ = mode.equals("island") ? minimumIslandChunk : centreZ - 1;
|
||||
int maximumZ = mode.equals("island") ? maximumIslandChunk : centreZ + 1;
|
||||
int expectedChunks = (maximumX - minimumX + 1) * (maximumZ - minimumZ + 1);
|
||||
BlockPos fixture = helper.absolutePos(BlockPos.ZERO);
|
||||
helper.assertTrue(fixture.getX() < minimumX * 16 - 1024 || fixture.getX() > (maximumX + 1) * 16 + 1024
|
||||
|| fixture.getZ() < minimumZ * 16 - 1024 || fixture.getZ() > (maximumZ + 1) * 16 + 1024,
|
||||
"The remote GameTest fixture must remain well outside the resource survey envelope");
|
||||
|
||||
// Complete the entire requested region, plus its immediate decoration neighbours, before
|
||||
// taking counts. Counting while still generating the next chunk would risk stale borders.
|
||||
SanctuaryMod.LOGGER.info("Resource survey {}: preparing {} inspected chunks plus a {}-chunk halo (seed {})",
|
||||
mode, expectedChunks, PREPARATION_HALO_CHUNKS, level.getSeed());
|
||||
int preparedChunks = 0;
|
||||
for (int x = minimumX - PREPARATION_HALO_CHUNKS; x <= maximumX + PREPARATION_HALO_CHUNKS; x++) {
|
||||
for (int z = minimumZ - PREPARATION_HALO_CHUNKS; z <= maximumZ + PREPARATION_HALO_CHUNKS; z++) {
|
||||
requireFullChunk(helper, level, x, z);
|
||||
preparedChunks++;
|
||||
}
|
||||
if (mode.equals("island") && (x - minimumX + PREPARATION_HALO_CHUNKS + 1) % 4 == 0) {
|
||||
SanctuaryMod.LOGGER.info("Resource survey preparation: {} FULL chunks ready", preparedChunks);
|
||||
}
|
||||
}
|
||||
long preparedNanos = System.nanoTime();
|
||||
Map<BlockState, Long> observedStates = new IdentityHashMap<>();
|
||||
List<Map<String, Object>> chunks = new ArrayList<>();
|
||||
long totalCells = 0;
|
||||
for (int x = minimumX; x <= maximumX; x++) {
|
||||
for (int z = minimumZ; z <= maximumZ; z++) {
|
||||
LevelChunk chunk = requireFullChunk(helper, level, x, z);
|
||||
Map<BlockState, Long> chunkStates = new IdentityHashMap<>();
|
||||
for (LevelChunkSection section : chunk.getSections()) {
|
||||
// This counts every cell in each palette, including all forms of air. Registry
|
||||
// lookups and property serialization happen once per distinct state later.
|
||||
section.getStates().count((state, count) -> chunkStates.merge(state, (long) count, Long::sum));
|
||||
}
|
||||
long chunkCells = sum(chunkStates);
|
||||
helper.assertTrue(chunkCells == 256L * level.getHeight(),
|
||||
"Every cell in the full dimension height must be counted at chunk " + x + "," + z);
|
||||
long nonAir = 0;
|
||||
for (var entry : chunkStates.entrySet()) {
|
||||
observedStates.merge(entry.getKey(), entry.getValue(), Long::sum);
|
||||
if (!entry.getKey().isAir()) nonAir += entry.getValue();
|
||||
}
|
||||
chunks.add(Map.of("x", x, "z", z, "status", "minecraft:full", "counted_cells", chunkCells,
|
||||
"non_air_blocks", nonAir, "distinct_block_states", chunkStates.size()));
|
||||
totalCells += chunkCells;
|
||||
}
|
||||
}
|
||||
Map<String, Long> blocks = new TreeMap<>();
|
||||
Map<String, Long> states = new TreeMap<>();
|
||||
long air = 0;
|
||||
for (var entry : observedStates.entrySet()) {
|
||||
String block = blockName(entry.getKey());
|
||||
blocks.merge(block, entry.getValue(), Long::sum);
|
||||
states.put(stateName(entry.getKey(), block), entry.getValue());
|
||||
if (entry.getKey().isAir()) air += entry.getValue();
|
||||
}
|
||||
long expectedCells = expectedChunks * 256L * level.getHeight();
|
||||
helper.assertTrue(chunks.size() == expectedChunks && totalCells == expectedCells,
|
||||
"Survey completeness must match its explicit requested chunk rectangle and height");
|
||||
helper.assertTrue(sum(blocks) == totalCells && sum(states) == totalCells,
|
||||
"Registry block and full-state totals must account for every inspected cell");
|
||||
var settingsHolder = ((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator()).generatorSettings();
|
||||
var settings = settingsHolder.value();
|
||||
String modVersion = FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow()
|
||||
.getMetadata().getVersion().getFriendlyString();
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("schema_version", 1);
|
||||
report.put("purpose", "development_post_generation_resource_observation");
|
||||
report.put("mode", mode);
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("minecraft_version", SharedConstants.getCurrentVersion().name());
|
||||
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()));
|
||||
report.put("dimension", "minecraft:overworld");
|
||||
report.put("initial_spawn", Map.of("x", initialSpawn.pos().getX(), "y", initialSpawn.pos().getY(),
|
||||
"z", initialSpawn.pos().getZ()));
|
||||
report.put("remote_test_fixture", Map.of("x", fixture.getX(), "y", fixture.getY(), "z", fixture.getZ(),
|
||||
"verified_outside_bounds_with_margin_blocks", 1024));
|
||||
report.put("bounds", Map.of("min_x_inclusive", minimumX * 16, "max_x_exclusive", (maximumX + 1) * 16,
|
||||
"min_z_inclusive", minimumZ * 16, "max_z_exclusive", (maximumZ + 1) * 16,
|
||||
"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", 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"),
|
||||
"covers_entire_world", false, "extrapolated", false, "height_coverage", "entire_dimension",
|
||||
"basis", "all palette cells in every inspected FULL chunk, not density samples"));
|
||||
report.put("preparation", Map.of("halo_chunks", PREPARATION_HALO_CHUNKS, "full_chunks_prepared", preparedChunks,
|
||||
"halo_included_in_counts", false, "world", "fresh_disposable_gametest_world",
|
||||
"personal_saves_accessed", false, "player_inventory_included", false, "remote_test_fixture_included", false));
|
||||
report.put("counts", Map.of("all_cells", totalCells, "air_cells", air, "non_air_blocks", totalCells - air,
|
||||
"by_block_registry_id", blocks, "by_complete_block_state", states));
|
||||
report.put("aggregates", aggregates(observedStates, blocks));
|
||||
report.put("aggregate_semantics", "Counts are blocks, not drops or economic value; groups overlap and must not be added together.");
|
||||
report.put("inspected_chunks", chunks);
|
||||
report.put("exclusions", List.of("entities", "item_drops", "container_contents", "player_inventories",
|
||||
"other_dimensions", "future_expansions", "blocks_outside_requested_bounds", "future_growth_and_loot"));
|
||||
report.put("observation", Map.of("started_at_utc", started.toString(), "completed_at_utc", Instant.now().toString(),
|
||||
"game_time_at_start", initialGameTime, "game_time_at_end", level.getGameTime(),
|
||||
"generation_preparation_millis", (preparedNanos - startedNanos) / 1_000_000L,
|
||||
"counting_and_serialization_preparation_millis", (System.nanoTime() - preparedNanos) / 1_000_000L,
|
||||
"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-" + (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());
|
||||
}
|
||||
|
||||
private static LevelChunk requireFullChunk(GameTestHelper helper, ServerLevel level, int x, int z) {
|
||||
ChunkAccess chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Survey requires a FULL chunk at " + x + "," + z);
|
||||
return (LevelChunk) chunk;
|
||||
}
|
||||
|
||||
private static Map<String, Object> aggregates(Map<BlockState, Long> states, Map<String, Long> blocks) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("stone", explicitBlocks(blocks, "minecraft:stone"));
|
||||
result.put("base_stone_overworld", taggedStates(states, state -> state.is(BlockTags.BASE_STONE_OVERWORLD),
|
||||
"#minecraft:base_stone_overworld"));
|
||||
result.put("logs", taggedStates(states, state -> state.is(BlockTags.LOGS), "#minecraft:logs"));
|
||||
result.put("leaves", taggedStates(states, state -> state.is(BlockTags.LEAVES), "#minecraft:leaves"));
|
||||
for (String ore : List.of("coal", "iron", "copper", "gold", "redstone", "lapis", "diamond", "emerald")) {
|
||||
result.put(ore + "_ore", explicitBlocks(blocks, "minecraft:" + ore + "_ore", "minecraft:deepslate_" + ore + "_ore"));
|
||||
}
|
||||
result.put("water_blocks", explicitBlocks(blocks, "minecraft:water"));
|
||||
result.put("water_containing_blocks", taggedStates(states, state -> state.getFluidState().is(FluidTags.WATER),
|
||||
"block states containing #minecraft:water, including waterlogged blocks"));
|
||||
result.put("lava_blocks", explicitBlocks(blocks, "minecraft:lava"));
|
||||
result.put("sand", explicitBlocks(blocks, "minecraft:sand", "minecraft:red_sand"));
|
||||
result.put("gravel", explicitBlocks(blocks, "minecraft:gravel"));
|
||||
result.put("clay", explicitBlocks(blocks, "minecraft:clay"));
|
||||
result.put("sugar_cane", explicitBlocks(blocks, "minecraft:sugar_cane"));
|
||||
result.put("soil", explicitBlocks(blocks, "minecraft:grass_block", "minecraft:dirt", "minecraft:coarse_dirt",
|
||||
"minecraft:rooted_dirt", "minecraft:podzol", "minecraft:mud"));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Object> explicitBlocks(Map<String, Long> blocks, String... names) {
|
||||
Map<String, Long> members = new TreeMap<>();
|
||||
for (String name : names) members.put(name, blocks.getOrDefault(name, 0L));
|
||||
return Map.of("count", sum(members), "definition", "sum of the listed block registry IDs", "members", members);
|
||||
}
|
||||
|
||||
private static Map<String, Object> taggedStates(Map<BlockState, Long> states, Predicate<BlockState> predicate,
|
||||
String definition) {
|
||||
Map<String, Long> members = new TreeMap<>();
|
||||
states.forEach((state, count) -> {
|
||||
if (predicate.test(state)) members.merge(blockName(state), count, Long::sum);
|
||||
});
|
||||
return Map.of("count", sum(members), "definition", definition, "observed_members", members);
|
||||
}
|
||||
|
||||
private static String blockName(BlockState state) {
|
||||
return BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString();
|
||||
}
|
||||
|
||||
private static String stateName(BlockState state, String block) {
|
||||
Map<String, String> properties = new TreeMap<>();
|
||||
for (Property<?> property : state.getProperties()) properties.put(property.getName(), propertyValue(state, property));
|
||||
if (properties.isEmpty()) return block;
|
||||
StringJoiner suffix = new StringJoiner(",", "[", "]");
|
||||
properties.forEach((name, value) -> suffix.add(name + "=" + value));
|
||||
return block + suffix;
|
||||
}
|
||||
|
||||
private static <T extends Comparable<T>> String propertyValue(BlockState state, Property<T> property) {
|
||||
return property.getName(state.getValue(property));
|
||||
}
|
||||
|
||||
private static long sum(Map<?, Long> counts) {
|
||||
return counts.values().stream().mapToLong(Long::longValue).sum();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+252
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.IslandShape;
|
||||
import fr.koka.sanctuary.worldgen.MainIslandDensity;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.storage.LevelData;
|
||||
|
||||
/** Assertions use absolute world positions, away from the framework's random fixture grid. */
|
||||
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 (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);
|
||||
else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.start(helper);
|
||||
else HydrologyDiagnostics.start(helper);
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) {
|
||||
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
if (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);
|
||||
else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.verifyLegacyIsolation(helper);
|
||||
else HydrologyDiagnostics.verifyLegacyIsolation(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void generatedIslandAndInitialSpawn(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
helper.assertTrue(SanctuarySpawn.usesSanctuaryGenerator(level),
|
||||
"GameTest must load the production Sanctuary preset, not a flat world");
|
||||
|
||||
helper.assertTrue(initialSpawn != null, "Capture the world's initial spawn before the test grid moves it");
|
||||
BlockPos spawn = initialSpawn.pos();
|
||||
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 = generator.generatorSettings().value();
|
||||
float spawnFloorDensity = level.getChunkSource().randomState().sampleBlockValueUncached(
|
||||
settings.noiseRouter().finalDensity(), spawn.getX(), spawn.getY() - 1, spawn.getZ());
|
||||
helper.assertTrue(spawnFloorDensity > 0.0F,
|
||||
"The spawn floor must already exist in natural terrain density, without adding a platform");
|
||||
// These regression seeds have a naturally flat 3x3 arrival area. Production also supports
|
||||
// a single safe column when a more irregular seed offers no sampled 3x3 floor.
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
BlockPos feet = spawn.offset(dx, 0, dz);
|
||||
BlockPos floor = feet.below();
|
||||
helper.assertTrue(level.getBlockState(floor).isCollisionShapeFullBlock(level, floor),
|
||||
"Every cell of the 3x3 arrival area needs a full solid floor: " + floor);
|
||||
for (int height = 0; height < 2; height++) {
|
||||
BlockPos space = feet.above(height);
|
||||
helper.assertTrue(level.getBlockState(space).getCollisionShape(level, space).isEmpty()
|
||||
&& level.getFluidState(space).isEmpty(),
|
||||
"The arrival area needs two dry unobstructed blocks: " + space);
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void sculptOnlyRemovesTerrain(GameTestHelper helper) throws java.io.IOException {
|
||||
if (IslandCapacity.forGenerator(helper.getLevel().getChunkSource().getGenerator()) != null)
|
||||
PopulationDiagnostics.verifyDensity(helper);
|
||||
else DensityDiagnostics.verifyAndExport(helper, initialSpawn);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void generationHeightAndLegacySettingsStaySeparate(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
var registries = level.registryAccess();
|
||||
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384,
|
||||
"The new Sanctuary dimension must have exactly 384 buildable blocks, Y0..383");
|
||||
float cloudHeight = level.environmentAttributes()
|
||||
.getDimensionValue(net.minecraft.world.attribute.EnvironmentAttributes.CLOUD_HEIGHT);
|
||||
helper.assertTrue(Math.abs(cloudHeight - 352.33F) < 0.001F,
|
||||
"Sanctuary clouds must be raised to Y352.33");
|
||||
helper.assertTrue(settings.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value().noiseSettings().height() == 384,
|
||||
"Noise generation must use all 384 blocks, not just a taller dimension");
|
||||
helper.assertTrue(settings.getOrThrow(SanctuarySpawn.SETTINGS).value().noiseSettings().height() == 256,
|
||||
"The noise settings referenced by saved alpha.2 worlds must retain their old height");
|
||||
var legacy = registries.lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
|
||||
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density"))).value();
|
||||
helper.assertTrue(legacy instanceof MainIslandDensity old && old.verticalOffset() == 0 && old.height() == 256,
|
||||
"The legacy density codec must remain in its unshifted alpha.2 mode");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@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);
|
||||
helper.assertTrue(chunk != null, "The lower-margin test needs a fully generated chunk");
|
||||
if (chunk.getSection(0).hasOnlyAir()) continue;
|
||||
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(NaturalHydrologyRuntime.enabled(generator) || LayeredHydrologyRuntime.enabled(generator)
|
||||
|| WoodlandHydrologyRuntime.enabled(generator) || RiftHydrologyRuntime.enabled(generator) || CavernHydrologyRuntime.enabled(generator),
|
||||
"The legacy island must leave its lower margin entirely empty");
|
||||
for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) {
|
||||
for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) {
|
||||
for (int y = 0; y < 16; y++) {
|
||||
BlockPos p = new BlockPos(x, y, z);
|
||||
var state = chunk.getBlockState(p);
|
||||
if (CavernHydrologyRuntime.enabled(generator)) {
|
||||
boolean water = state.getFluidState().is(FluidTags.WATER)
|
||||
&& CavernDiagnostics.isWithinSpringInspectionFootprint(CavernHydrologyRuntime.cavernPlan(helper.getLevel()), x, z);
|
||||
final int bx = x, bz = z;
|
||||
boolean lava = state.getFluidState().is(FluidTags.LAVA)
|
||||
&& CavernHydrologyRuntime.lavaPlan(helper.getLevel()).flowBounds().stream().anyMatch(b ->
|
||||
bx >= (Math.floorDiv(b.minX(),16) - 1) * 16 && bx <= (Math.floorDiv(b.maxX(),16) + 2) * 16 - 1
|
||||
&& bz >= (Math.floorDiv(b.minZ(),16) - 1) * 16 && bz <= (Math.floorDiv(b.maxZ(),16) + 2) * 16 - 1);
|
||||
helper.assertTrue(state.isAir() || water || lava, "Lower margin must remain void except declared fluid falls: " + p);
|
||||
continue;
|
||||
}
|
||||
if (RiftHydrologyRuntime.enabled(generator)) {
|
||||
boolean water = state.getFluidState().is(FluidTags.WATER)
|
||||
&& RiftDiagnostics.isWithinSpringInspectionFootprint(RiftHydrologyRuntime.riftPlan(helper.getLevel()), x, z);
|
||||
final int bx = x, bz = z;
|
||||
boolean lava = state.getFluidState().is(FluidTags.LAVA)
|
||||
&& RiftHydrologyRuntime.lavaPlan(helper.getLevel()).flowBounds().stream().anyMatch(b ->
|
||||
bx >= (Math.floorDiv(b.minX(),16) - 1) * 16 && bx <= (Math.floorDiv(b.maxX(),16) + 2) * 16 - 1
|
||||
&& bz >= (Math.floorDiv(b.minZ(),16) - 1) * 16 && bz <= (Math.floorDiv(b.maxZ(),16) + 2) * 16 - 1);
|
||||
helper.assertTrue(state.isAir() || water || lava, "Lower margin must remain void except declared fluid falls: " + p);
|
||||
continue;
|
||||
}
|
||||
helper.assertTrue(state.isAir() || state.getFluidState().is(FluidTags.WATER)
|
||||
&& (WoodlandHydrologyRuntime.enabled(generator)
|
||||
? WoodlandDiagnostics.isWithinSpringInspectionFootprint(WoodlandHydrologyRuntime.woodlandPlan(helper.getLevel()), x, z)
|
||||
: LayeredHydrologyRuntime.enabled(generator)
|
||||
? LayeredHydrologyDiagnostics.isWithinSpringInspectionFootprint(LayeredHydrologyRuntime.layeredPlan(helper.getLevel()), x, z)
|
||||
: NaturalHydrologyDiagnostics.isWithinSpringInspectionFootprint(NaturalHydrologyRuntime.naturalPlan(helper.getLevel()), x, z)),
|
||||
"The lower margin has no artificial floor; water must lie in the finite spring inspection footprint, with provenance checked by the full fluid diagnostic: " + p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void generatedResourceSurvey(GameTestHelper helper) throws java.io.IOException {
|
||||
ResourceSurveyDiagnostics.verifyAndExport(helper, initialSpawn);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@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.
|
||||
int[][] chunks = {
|
||||
{20, 0}, {-21, 0}, {0, 20}, {0, -21},
|
||||
{32, 0}, {-33, 0}, {0, 32}, {0, -33},
|
||||
{256, 0}, {-257, 0}, {0, 256}, {0, -257}
|
||||
};
|
||||
for (int[] coordinate : chunks) {
|
||||
ChunkAccess chunk = level.getChunkSource().getChunk(coordinate[0], coordinate[1], ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "The test must inspect a fully generated chunk");
|
||||
for (LevelChunkSection section : chunk.getSections()) {
|
||||
helper.assertTrue(section.hasOnlyAir(),
|
||||
"Finished exterior chunk contains blocks after world generation: " + chunk.getPos());
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void compiledWorldDensityUsesTheSeed(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var registries = level.registryAccess();
|
||||
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);
|
||||
RandomState otherSeed = RandomState.create(noises, 8675309L, settings);
|
||||
DensityFunction density = settings.noiseRouter().finalDensity();
|
||||
boolean observedSeedVariation = false;
|
||||
for (int x : new int[]{112, 160, 208}) {
|
||||
for (int z : new int[]{-96, -48, 0, 48, 96}) {
|
||||
for (int y : new int[]{96, 128, 160, 192, 224}) {
|
||||
float value = first.sampleBlockValueUncached(density, x, y, z);
|
||||
float repeated = replay.sampleBlockValueUncached(density, x, y, z);
|
||||
float changed = otherSeed.sampleBlockValueUncached(density, x, y, z);
|
||||
helper.assertTrue(Float.isFinite(value) && Float.isFinite(changed),
|
||||
"Compiled world density must remain finite");
|
||||
helper.assertTrue(Float.floatToIntBits(value) == Float.floatToIntBits(repeated),
|
||||
"The actual datapack density must replay identically for the same seed");
|
||||
observedSeedVariation |= Float.floatToIntBits(value) != Float.floatToIntBits(changed);
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.assertTrue(observedSeedVariation, "Different world seeds must change the production density");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
+1773
File diff suppressed because it is too large
Load Diff
+52
@@ -0,0 +1,52 @@
|
||||
package fr.koka.sanctuary.gametest.mixin;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.gametest.SanctuaryWorldGameTests;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestServer;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.levelgen.presets.WorldPreset;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Test-only: run the real Sanctuary datapack instead of the framework's flat fixture. */
|
||||
@Mixin(GameTestServer.class)
|
||||
public abstract class SanctuaryGameTestServerMixin {
|
||||
@ModifyArg(method = "<clinit>", at = @At(value = "INVOKE", target =
|
||||
"Lnet/minecraft/world/level/levelgen/WorldOptions;<init>(JZZ)V"), index = 0)
|
||||
private static long sanctuary$testSeed(long original) {
|
||||
return Long.getLong("sanctuary.test.seed", 0L);
|
||||
}
|
||||
|
||||
@ModifyArg(method = "<clinit>", at = @At(value = "INVOKE", target =
|
||||
"Lnet/minecraft/world/level/levelgen/WorldOptions;<init>(JZZ)V"), index = 1)
|
||||
private static boolean sanctuary$enableStructureGeneration(boolean original) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@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<WorldPreset> sanctuary$selectActualPreset() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "sanctuary-gametest",
|
||||
"version": "1.0.0",
|
||||
"name": "Sanctuary generation tests",
|
||||
"environment": "*",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"entrypoints": {
|
||||
"fabric-gametest": ["fr.koka.sanctuary.gametest.SanctuaryWorldGameTests"],
|
||||
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
|
||||
},
|
||||
"mixins": ["sanctuary-gametest.mixins.json"],
|
||||
"depends": {
|
||||
"sanctuary": "*",
|
||||
"fabric-gametest-api-v1": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"required": true,
|
||||
"package": "fr.koka.sanctuary.gametest.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"mixins": ["SanctuaryGameTestServerMixin"],
|
||||
"injectors": {"defaultRequire": 1}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
import fr.koka.sanctuary.worldgen.RiftIslandDensity;
|
||||
import fr.koka.sanctuary.worldgen.RiftIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.RiftGrovesFeature;
|
||||
import fr.koka.sanctuary.worldgen.RiftShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.MainIslandDensity;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandGrovesFeature;
|
||||
import fr.koka.sanctuary.worldgen.NaturalShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.TemperateIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.LayeredIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.LayeredShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.LowerTerraceVegetationFeature;
|
||||
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public final class SanctuaryMod implements ModInitializer {
|
||||
public static final String ID = "sanctuary";
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(ID);
|
||||
|
||||
public static Identifier id(String path) {
|
||||
return Identifier.fromNamespaceAndPath(ID, path);
|
||||
}
|
||||
|
||||
@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);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("layered_shore_sugar_cane"), LayeredShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("lower_terrace_vegetation"), LowerTerraceVegetationFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("layered_island"), LayeredIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("woodland_island"), WoodlandIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("woodland_shore_sugar_cane"), WoodlandShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("woodland_groves"), WoodlandGrovesFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("rift_island"), RiftIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("rift_island"), RiftIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("rift_groves"), RiftGrovesFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("rift_shore_sugar_cane"), RiftShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("cavern_island"), CavernIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_groves"), CavernGrovesFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_decorations"), CavernDecorationsFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_shore_sugar_cane"), CavernShoreSugarCaneFeature.CODEC);
|
||||
LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Client visuals belonging only to the new floating-world dimension type. */
|
||||
public final class SanctuarySky {
|
||||
private static final Identifier FLOATING_WORLD = SanctuaryMod.id("sanctuary_384");
|
||||
|
||||
private SanctuarySky() {
|
||||
}
|
||||
|
||||
public static boolean isFloatingWorld(ClientLevel level) {
|
||||
return level.dimensionTypeRegistration().is(FLOATING_WORLD);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class CavernSpringOutletMixin {
|
||||
@Unique
|
||||
private 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.progress.LevelLoadListener;
|
||||
import net.minecraft.world.level.storage.ServerLevelData;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(MinecraftServer.class)
|
||||
public abstract class InitialSpawnMixin {
|
||||
@Inject(method = "setInitialSpawn", at = @At("RETURN"))
|
||||
private static void sanctuary$initialSpawn(ServerLevel level, ServerLevelData data, boolean bonusChest,
|
||||
boolean debugWorld, LevelLoadListener listener, CallbackInfo ci) {
|
||||
SanctuarySpawn.initialize(level);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.LayeredSpringOutlets;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class LayeredSpringOutletMixin {
|
||||
@Unique
|
||||
private LayeredSpringOutlets.Pending sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
|
||||
private void sanctuary$captureSpringOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
sanctuary$pendingSpringOutlets = LayeredSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
}
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
|
||||
private void sanctuary$finishSpringOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
var pending = sanctuary$pendingSpringOutlets;
|
||||
sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY;
|
||||
LayeredSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.RiftSpringOutlets;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class RiftSpringOutletMixin {
|
||||
@Unique
|
||||
private RiftSpringOutlets.Pending sanctuary$pendingRiftOutlets = RiftSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
|
||||
private void sanctuary$captureRiftOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
sanctuary$pendingRiftOutlets = RiftSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
}
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
|
||||
private void sanctuary$finishRiftOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
var pending = sanctuary$pendingRiftOutlets;
|
||||
sanctuary$pendingRiftOutlets = RiftSpringOutlets.Pending.EMPTY;
|
||||
RiftSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** The terrain future completes before decoration starts; never reads or writes another chunk. */
|
||||
@Mixin(NoiseBasedChunkGenerator.class)
|
||||
public abstract class SurfaceHydrologyMixin {
|
||||
@Inject(method = "buildTerrain", at = @At("RETURN"), cancellable = true)
|
||||
private void sanctuary$surfaceWater(ChunkAccess chunk, Blender blender, RandomState randomState,
|
||||
StructureManager structures, BiomeManager biomeManager, WorldGenRegion region,
|
||||
Set<Holder<Biome>> biomes, CallbackInfoReturnable<CompletableFuture<ChunkAccess>> callback) {
|
||||
var generator = (NoiseBasedChunkGenerator) (Object) this;
|
||||
if (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;
|
||||
}));
|
||||
} else if (RiftHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
RiftHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (WoodlandHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
WoodlandHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (LayeredHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
LayeredHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (NaturalHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
NaturalHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (HydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
HydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.WoodlandSpringOutlets;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class WoodlandSpringOutletMixin {
|
||||
@Unique
|
||||
private WoodlandSpringOutlets.Pending sanctuary$pendingWoodlandOutlets = WoodlandSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
|
||||
private void sanctuary$captureWoodlandOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
sanctuary$pendingWoodlandOutlets = WoodlandSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
}
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
|
||||
private void sanctuary$finishWoodlandOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
var pending = sanctuary$pendingWoodlandOutlets;
|
||||
sanctuary$pendingWoodlandOutlets = WoodlandSpringOutlets.Pending.EMPTY;
|
||||
WoodlandSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.SanctuarySky;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.fog.FogRenderer;
|
||||
import net.minecraft.util.Mth;
|
||||
import org.joml.Vector4f;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
|
||||
@Mixin(FogRenderer.class)
|
||||
public abstract class SanctuaryFogMixin {
|
||||
// Only the altitude-derived darkness is removed. Vanilla still applies
|
||||
// blindness/darkness effects, weather, fluids and the normal distance fog.
|
||||
@Redirect(method = "computeFogColor", at = @At(value = "INVOKE",
|
||||
target = "Lnet/minecraft/util/Mth;clamp(FFF)F", ordinal = 0))
|
||||
private float sanctuary$openVoidFog(float value, float minimum, float maximum,
|
||||
Camera camera, float partialTicks, ClientLevel level,
|
||||
int renderDistance, float darkenWorldAmount, Vector4f destination) {
|
||||
return SanctuarySky.isFloatingWorld(level) ? 0.0F : Mth.clamp(value, minimum, maximum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.SanctuarySky;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.SkyRenderer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(SkyRenderer.class)
|
||||
public abstract class SanctuarySkyMixin {
|
||||
// Vanilla draws a black lower sky below Y=63 even when there is no terrain.
|
||||
@Inject(method = "shouldRenderDarkDisc", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$openLowerSky(float partialTicks, ClientLevel level,
|
||||
CallbackInfoReturnable<Boolean> callback) {
|
||||
if (SanctuarySky.isFloatingWorld(level)) {
|
||||
callback.setReturnValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
/** Alpha.9 keeps the upper woodland climate and specializes sheltered lower layers. */
|
||||
public final class CavernBiomePalette {
|
||||
public enum Kind {
|
||||
OAK_FOREST, BIRCH_FOREST, CLEARING, DRY_WOODLAND, ROCKY_HEATH,
|
||||
DARK_GROVE, BAMBOO_GROVE, SULFUR_DEPTHS, LUSH_CAVES, DRIPSTONE_CAVES
|
||||
}
|
||||
|
||||
private CavernBiomePalette() {}
|
||||
|
||||
public static Kind select(int blockY, float variation, float moisture) {
|
||||
double elevation = blockY - Math.clamp(variation, -1.0F, 1.0F) * 16
|
||||
- Math.clamp(moisture, -1.0F, 1.0F) * 10;
|
||||
// Small climate pockets replace alpha.8's broad sulfur province. Neither
|
||||
// mineral pockets nor cave vegetation can take over the upper forest.
|
||||
if (blockY < 176 && elevation < 168 && variation > 0.22F && variation < 0.42F
|
||||
&& moisture > -0.42F && moisture < -0.12F) return Kind.SULFUR_DEPTHS;
|
||||
if (variation > 0.48F) return Kind.ROCKY_HEATH;
|
||||
if (blockY < 184 && elevation < 180) {
|
||||
if (moisture > 0.05F && variation > -0.32F) return Kind.LUSH_CAVES;
|
||||
if (moisture < -0.18F && variation < 0.22F) return Kind.DRIPSTONE_CAVES;
|
||||
}
|
||||
if (elevation < 214) {
|
||||
if (variation < -0.45F && moisture > 0.40F) return Kind.BAMBOO_GROVE;
|
||||
double shelteredMoisture = moisture + Math.min(0.55, (214 - elevation) / 160);
|
||||
if (shelteredMoisture > -0.08) return Kind.DARK_GROVE;
|
||||
}
|
||||
if (moisture < -0.55F && variation > -0.25F) return Kind.DRY_WOODLAND;
|
||||
if (Math.abs(variation) < 0.09F && moisture < 0.35F) return Kind.CLEARING;
|
||||
if (variation < -0.33F) return Kind.BIRCH_FOREST;
|
||||
return Kind.OAK_FOREST;
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.AmethystClusterBlock;
|
||||
import net.minecraft.world.level.block.CaveVines;
|
||||
import net.minecraft.world.level.block.GrowingPlantHeadBlock;
|
||||
import net.minecraft.world.level.block.PotentSulfurBlock;
|
||||
import net.minecraft.world.level.block.SpeleothemBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.PotentSulfurState;
|
||||
import net.minecraft.world.level.block.state.properties.SpeleothemThickness;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.feature.GeodeFeature;
|
||||
|
||||
/** Decorations in existing lower cavities only. All writes belong to the owner's chunk. */
|
||||
public final class CavernDecorationsFeature implements Feature {
|
||||
public static final MapCodec<CavernDecorationsFeature> CODEC = MapCodec.unit(CavernDecorationsFeature::new);
|
||||
private static final ResourceKey<Feature> GEODE = ResourceKey.create(Registries.FEATURE, SanctuaryMod.id("cavern_amethyst_geode"));
|
||||
private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST};
|
||||
public enum Kind { LUSH, DRIPSTONE, SULFUR, GEODE }
|
||||
public record Candidate(Kind kind, BlockPos floor, BlockPos ceiling) {
|
||||
public Candidate { floor = floor.immutable(); ceiling = ceiling.immutable(); }
|
||||
}
|
||||
|
||||
@Override public MapCodec<CavernDecorationsFeature> codec() { return CODEC; }
|
||||
|
||||
/** Pure, bounded probes for development diagnostics; no chunk loads or successful-placement promise. */
|
||||
public static List<Candidate> candidates(NoiseBasedChunkGenerator generator, RandomState state, int chunkX, int chunkZ) {
|
||||
if (!CavernHydrologyRuntime.enabled(generator) || Math.abs(chunkX) > 17 || Math.abs(chunkZ) > 17) return List.of();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
var biomes = generator.getBiomeSource().createUncachedResolver(state);
|
||||
List<Candidate> found = new ArrayList<>();
|
||||
for (int[] offset : new int[][]{{4, 4}, {4, 12}, {12, 4}, {12, 12}, {8, 8}}) {
|
||||
int x = chunkX * 16 + offset[0], z = chunkZ * 16 + offset[1], floors = 0;
|
||||
for (int y = 182; y >= 40; y--) {
|
||||
if (sampler.sample(x, y, z) <= 0 || sampler.sample(x, y + 1, z) > 0
|
||||
|| sampler.sample(x, y + 2, z) > 0 || sampler.sample(x, y - 1, z) <= 0
|
||||
|| sampler.sample(x, y - 2, z) <= 0) continue;
|
||||
int roof = y + 3;
|
||||
while (roof <= Math.min(230, y + 48) && sampler.sample(x, roof, z) <= 0) roof++;
|
||||
if (roof > Math.min(230, y + 48) || roof - y < 6
|
||||
|| sampler.sample(x, roof + 1, z) <= 0) continue;
|
||||
BlockPos floor = new BlockPos(x, y, z), ceiling = new BlockPos(x, roof, z);
|
||||
var biome = biomes.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2);
|
||||
if (biome.is(CavernIslandBiomeSource.LUSH_CAVES)) found.add(new Candidate(Kind.LUSH, floor, ceiling));
|
||||
else if (biome.is(CavernIslandBiomeSource.DRIPSTONE_CAVES)) found.add(new Candidate(Kind.DRIPSTONE, floor, ceiling));
|
||||
else if (biome.is(CavernIslandBiomeSource.SULFUR_DEPTHS) && y < 176)
|
||||
found.add(new Candidate(Kind.SULFUR, floor, ceiling));
|
||||
if (offset[0] == 8 && offset[1] == 8 && Math.floorMod(chunkSeed(state.seed(), chunkX, chunkZ, 0x9501L), 8) == 0)
|
||||
found.add(new Candidate(Kind.GEODE, floor, ceiling));
|
||||
if (++floors >= 3) break;
|
||||
}
|
||||
}
|
||||
// Geodes keep their entry free before this feature's moss, grass or pointed
|
||||
// dripstone can occupy it. Stable sorting preserves every other candidate order.
|
||||
found.sort(java.util.Comparator.comparingInt(candidate -> candidate.kind() == Kind.GEODE ? 0 : 1));
|
||||
return List.copyOf(found);
|
||||
}
|
||||
|
||||
@Override public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
int cx = origin.getX() >> 4, cz = origin.getZ() >> 4;
|
||||
boolean changed = false, geyser = false, geode = false;
|
||||
for (Candidate candidate : candidates(noise, state, cx, cz)) {
|
||||
BlockPos floor = candidate.floor();
|
||||
if (!stable(level, sampler, floor) || !level.getBlockState(floor.above()).isAir()) continue;
|
||||
long seed = WoodlandGroveGeometry.seed(state.seed(), floor.getX(), floor.getY(), floor.getZ(), 0x9502L);
|
||||
switch (candidate.kind()) {
|
||||
case LUSH -> changed |= lush(level, noise, state, sampler, candidate, seed);
|
||||
case DRIPSTONE -> changed |= dripstone(level, noise, state, sampler, candidate, seed);
|
||||
case SULFUR -> {
|
||||
if (!geyser && Math.floorMod(chunkSeed(state.seed(), cx, cz, 0x9503L), 2) == 0)
|
||||
changed |= geyser = geyser(level, noise, state, sampler, candidate);
|
||||
}
|
||||
case GEODE -> { if (!geode) changed |= geode = geode(level, noise, state, sampler, candidate, seed); }
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean lush(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) {
|
||||
BlockPos floor = site.floor(), roof = site.ceiling();
|
||||
int gap = roof.getY() - floor.getY();
|
||||
if (CavernHydrologyRuntime.protects(noise, state, floor, 3, gap + 2, 3)) return false;
|
||||
boolean changed = false;
|
||||
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) {
|
||||
if (dx * dx + dz * dz > 5) continue;
|
||||
BlockPos p = floor.offset(dx, 0, dz);
|
||||
if (!stable(level, sampler, p) || !stable(level, sampler, p.below()) || !stable(level, sampler, p.below(2))
|
||||
|| !level.getBlockState(p.above()).isAir()) continue;
|
||||
setBlock(level, p, Blocks.MOSS_BLOCK.defaultBlockState());
|
||||
long local = WoodlandGroveGeometry.seed(seed, p.getX(), p.getY(), p.getZ(), 0x9504L);
|
||||
BlockState plant = (Math.floorMod(local, 5) == 0 ? Blocks.AZALEA : Math.floorMod(local, 3) == 0
|
||||
? Blocks.MOSS_CARPET : Blocks.SHORT_GRASS).defaultBlockState();
|
||||
if (plant.canSurvive(level, p.above())) setBlock(level, p.above(), plant);
|
||||
changed = true;
|
||||
}
|
||||
// Real cave-vine heads and bodies remain harvestable and luminous via BERRIES.
|
||||
int length = Math.min(gap - 3, 3 + Math.floorMod(seed, 6));
|
||||
if (stable(level, sampler, roof) && stable(level, sampler, roof.above())) {
|
||||
boolean free = true;
|
||||
for (int d = 1; d <= length; d++) free &= level.getBlockState(roof.below(d)).isAir();
|
||||
if (free) for (int d = 1; d <= length; d++) {
|
||||
BlockPos p = roof.below(d);
|
||||
BlockState vine = (d == length ? Blocks.CAVE_VINES : Blocks.CAVE_VINES_PLANT).defaultBlockState()
|
||||
.setValue(CaveVines.BERRIES, d == length || Math.floorMod(seed + d, 3) == 0);
|
||||
if (d == length) vine = vine.setValue(GrowingPlantHeadBlock.AGE, 25);
|
||||
if (!vine.canSurvive(level, p)) break;
|
||||
setBlock(level, p, vine); changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean dripstone(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) {
|
||||
BlockPos floor = site.floor(), roof = site.ceiling();
|
||||
int gap = roof.getY() - floor.getY();
|
||||
if (CavernHydrologyRuntime.protects(noise, state, floor, 1, gap + 2, 3)) return false;
|
||||
int length = Math.min((gap - 3) / 2, 1 + Math.floorMod(seed, 4));
|
||||
boolean changed = spike(level, sampler, floor, Direction.UP, length);
|
||||
changed |= spike(level, sampler, roof, Direction.DOWN, length);
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean spike(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, BlockPos support, Direction direction, int length) {
|
||||
if (length < 1 || !stable(level, sampler, support) || !stable(level, sampler, support.relative(direction.getOpposite()))) return false;
|
||||
for (int d = 1; d <= length; d++) {
|
||||
BlockPos p = support.relative(direction, d);
|
||||
if (!level.getBlockState(p).isAir() || sampler.sample(p.getX(), p.getY(), p.getZ()) > 0) return false;
|
||||
}
|
||||
setBlock(level, support, Blocks.DRIPSTONE_BLOCK.defaultBlockState());
|
||||
for (int d = 1; d <= length; d++) {
|
||||
SpeleothemThickness thickness = d == length ? SpeleothemThickness.TIP
|
||||
: d == length - 1 ? SpeleothemThickness.FRUSTUM : d == 1 ? SpeleothemThickness.BASE : SpeleothemThickness.MIDDLE;
|
||||
BlockState pointed = Blocks.POINTED_DRIPSTONE.defaultBlockState().setValue(SpeleothemBlock.TIP_DIRECTION, direction)
|
||||
.setValue(SpeleothemBlock.THICKNESS, thickness);
|
||||
setBlock(level, support.relative(direction, d), pointed);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean geyser(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site) {
|
||||
BlockPos water = site.floor(), potent = water.below(), heat = water.below(2);
|
||||
if (site.ceiling().getY() - water.getY() < 8
|
||||
|| CavernHydrologyRuntime.protects(noise, state, water, 2, 8, 5)) return false;
|
||||
for (int d = 0; d <= 4; d++) if (!stable(level, sampler, water.below(d))) return false;
|
||||
for (Direction side : SIDES) for (int d = 0; d <= 2; d++)
|
||||
if (!stable(level, sampler, water.below(d).relative(side))) return false;
|
||||
for (int d = 1; d <= 7; d++) if (!level.getBlockState(water.above(d)).isAir()) return false;
|
||||
// One source, four original rock walls, original deep support. Vanilla's
|
||||
// periodic geyser ticker requires magma below potent sulfur and source water above.
|
||||
setBlock(level, heat, Blocks.MAGMA_BLOCK.defaultBlockState());
|
||||
setBlock(level, water, Blocks.WATER.defaultBlockState());
|
||||
setBlock(level, potent, Blocks.POTENT_SULFUR.defaultBlockState().setValue(PotentSulfurBlock.STATE, PotentSulfurState.DORMANT));
|
||||
level.getChunk(water).markPosForPostProcessing(water);
|
||||
level.getChunk(potent).markPosForPostProcessing(potent);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean geode(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) {
|
||||
BlockPos origin = site.floor().offset(-1, -9, -1);
|
||||
// Include the vanilla write range, its crystal neighbours and the entrance.
|
||||
// This asymmetric envelope is centred explicitly so its positive edge is protected too.
|
||||
if (origin.getY() - 6 < 24 || !level.getBlockState(site.floor().above(2)).isAir()
|
||||
|| CavernHydrologyRuntime.protects(noise, state, origin.offset(1, -6, 1), 7, 17, 0)) return false;
|
||||
Map<BlockPos, BlockState> 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<BlockPos, BlockState> 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<BlockPos> geodeEntrance(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler,
|
||||
BlockPos start, BlockPos origin, Map<BlockPos, BlockState> before) {
|
||||
var previous = new java.util.HashMap<BlockPos, BlockPos>();
|
||||
var distances = new java.util.HashMap<BlockPos, Integer>();
|
||||
var queue = new java.util.ArrayDeque<BlockPos>();
|
||||
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<BlockPos>();
|
||||
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<BlockPos, BlockState> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.data.worldgen.features.TreeFeatures;
|
||||
import net.minecraft.data.worldgen.features.VegetationFeatures;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.VegetationBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature;
|
||||
|
||||
/** Alpha.9: sheltered vanilla mushroom trees and dark oak on natural lower floors,
|
||||
* plus one globally selected remarkable tree.
|
||||
* Placement runs only in new generation. No world journal or mutable placement counter is retained. */
|
||||
public final class CavernGrovesFeature implements Feature {
|
||||
public static final MapCodec<CavernGrovesFeature> CODEC = MapCodec.unit(CavernGrovesFeature::new);
|
||||
private static final Map<RandomState, Optional<RemarkableTree>> 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<Feature> 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<CavernGrovesFeature> codec() { return CODEC; }
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(ServerLevel level) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty();
|
||||
return remarkablePlan(noise, level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) {
|
||||
if (!CavernHydrologyRuntime.enabled(noise)) return Optional.empty();
|
||||
return REMARKABLE.computeIfAbsent(state, ignored -> {
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
Protection protection = (base, radius, height, rootDepth) ->
|
||||
CavernHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth);
|
||||
Species species = SPECIES[Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), 0, 0, 0, 0x70EEL), SPECIES.length)];
|
||||
var candidates = new ArrayList<Candidate>();
|
||||
for (int x = -152; x <= 152; x += 16) for (int z = -152; z <= 152; z += 16) {
|
||||
if (Math.hypot(x, z) <= 168) candidates.add(new Candidate(x, z,
|
||||
WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x71AFL)));
|
||||
}
|
||||
candidates.sort(Comparator.comparingLong(Candidate::rank).thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
for (Candidate candidate : candidates) {
|
||||
int bed = WoodlandGroveGeometry.highestSurface(sampler, candidate.x(), candidate.z());
|
||||
if (!WoodlandGroveGeometry.naturalFootprint(sampler, candidate.x(), bed, candidate.z(), species.width())) continue;
|
||||
BlockPos base = new BlockPos(candidate.x(), bed + 1, candidate.z());
|
||||
if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species))) continue;
|
||||
if (!WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(),
|
||||
species.width(), species.radius(), species.height())) continue;
|
||||
return Optional.of(new RemarkableTree(base, species.key().identifier().toString(), species.radius(), species.height()));
|
||||
}
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false;
|
||||
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
|
||||
if (Math.abs((long) minX) > 272 || Math.abs((long) minZ) > 272) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
Protection protection = (base, radius, height, rootDepth) ->
|
||||
CavernHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth);
|
||||
boolean changed = false;
|
||||
int[] heights = new int[256];
|
||||
Arrays.fill(heights, Integer.MIN_VALUE);
|
||||
var remarkable = remarkablePlan(noise, state);
|
||||
if (remarkable.isPresent()) {
|
||||
var tree = remarkable.get();
|
||||
BlockPos base = tree.base();
|
||||
if ((base.getX() >> 4) == (minX >> 4) && (base.getZ() >> 4) == (minZ >> 4)) {
|
||||
for (Species species : SPECIES) if (species.key().identifier().toString().equals(tree.featureId())) {
|
||||
changed |= tree(level, generator, sampler, state.seed(), base, species, protection, 0x723BL);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The scarce tall rooms usually go to dark oak first; one quarter of chunks
|
||||
// give mushroom trees first choice. This changes priority, never attempt budgets
|
||||
// or natural fit requirements, and uses a separate deterministic chunk seed.
|
||||
boolean mushroomsFirst = Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), minX, 0, minZ, 0x8503L), 4) == 0;
|
||||
if (mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights);
|
||||
// Four spaced origins per chunk; the 2x2 trunk and full canopy still have to fit the cave.
|
||||
for (int x = minX + 4; x < minX + 16; x += 8) for (int z = minZ + 4; z < minZ + 16; z += 8) {
|
||||
int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), placed = 0;
|
||||
for (int bed = highest - 8; bed >= 40; bed--) {
|
||||
BlockPos base = new BlockPos(x, bed + 1, z);
|
||||
if (!ground(level, base.below()) || !level.getBlockState(base).isAir()) continue;
|
||||
var biome = level.getBiome(base);
|
||||
boolean dark = biome.is(CavernIslandBiomeSource.DARK_GROVE);
|
||||
boolean bamboo = biome.is(CavernIslandBiomeSource.BAMBOO_GROVE);
|
||||
if (!dark && !bamboo) continue;
|
||||
long placementSeed = WoodlandGroveGeometry.seed(state.seed(), x, bed, z, 0x730DL);
|
||||
if (dark) {
|
||||
if (tree(level, generator, sampler, state.seed(), base,
|
||||
new Species(TreeFeatures.DARK_OAK, 2, 6, 12), protection, 0x730DL)) { changed = true; placed++; }
|
||||
} else if (Math.floorMod(placementSeed, 8) == 0) {
|
||||
if (tree(level, generator, sampler, state.seed(), base,
|
||||
new Species(TreeFeatures.JUNGLE_TREE_NO_VINE, 1, 5, 14), protection, 0x735BL)) { changed = true; placed++; }
|
||||
} else if (Math.floorMod(placementSeed, 3) == 0
|
||||
&& WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1)
|
||||
&& WoodlandGroveGeometry.treeRoom(sampler, x, bed + 1, z, 1, 3, 16)
|
||||
&& !blocked(protection, base, 4, 17) && actualRoom(level, base, 1, 3, 16)) {
|
||||
setBlock(level, base.below(), Blocks.PODZOL.defaultBlockState());
|
||||
changed |= level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(VegetationFeatures.BAMBOO_NO_PODZOL)
|
||||
.value().place(level, generator, RandomSource.create(placementSeed), base);
|
||||
placed++;
|
||||
}
|
||||
if (placed >= 2) break;
|
||||
}
|
||||
}
|
||||
if (!mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights);
|
||||
// Coherent ground patches: about 40% moss, 40% podzol, 20% mycelium.
|
||||
// Small mushrooms are accents (one opportunity per hundred columns), not the canopy.
|
||||
for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) {
|
||||
int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), floors = 0;
|
||||
long detail = WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x843EL);
|
||||
boolean smallMushroomPlaced = false;
|
||||
for (int bed = highest - 8; bed >= 32; bed--) {
|
||||
BlockPos floor = new BlockPos(x, bed, z), plant = floor.above();
|
||||
if (!ground(level, floor) || !level.getBlockState(plant).isAir()) continue;
|
||||
var biome = level.getBiome(plant);
|
||||
if (!biome.is(CavernIslandBiomeSource.DARK_GROVE) && !biome.is(CavernIslandBiomeSource.BAMBOO_GROVE)) continue;
|
||||
if (!WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1) || blocked(protection, plant, 1, 2)) continue;
|
||||
long patch = WoodlandGroveGeometry.seed(state.seed(), Math.floorDiv(x, 7), 0, Math.floorDiv(z, 7), 0x742DL);
|
||||
int soilKind = Math.floorMod(patch, 5);
|
||||
BlockState soil = (soilKind < 2 ? Blocks.MOSS_BLOCK : soilKind < 4 ? Blocks.PODZOL : Blocks.MYCELIUM).defaultBlockState();
|
||||
setBlock(level, floor, soil);
|
||||
if (!smallMushroomPlaced && Math.floorMod(detail, 100) == 0) {
|
||||
BlockState mushroom = (Math.floorMod(detail >>> 8, 2) == 0 ? Blocks.BROWN_MUSHROOM : Blocks.RED_MUSHROOM).defaultBlockState();
|
||||
if (mushroom.canSurvive(level, plant)) {
|
||||
setBlock(level, plant, mushroom);
|
||||
smallMushroomPlaced = true;
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
if (++floors >= 3) break;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean mushroomTrees(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler,
|
||||
long seed, Protection protection, int minX, int minZ, int[] heights) {
|
||||
boolean changed = false;
|
||||
RandomSource mushroomSites = RandomSource.create(WoodlandGroveGeometry.seed(seed, minX, 0, minZ, 0x8501L));
|
||||
int mushroomAttempts = 1 + mushroomSites.nextInt(3);
|
||||
for (int attempt = 0; attempt < mushroomAttempts; attempt++) {
|
||||
int x = minX + 3 + mushroomSites.nextInt(10), z = minZ + 3 + mushroomSites.nextInt(10);
|
||||
int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights);
|
||||
for (int bed = highest - 8; bed >= 32; bed--) {
|
||||
BlockPos base = new BlockPos(x, bed + 1, z);
|
||||
if (!ground(level, base.below()) || !level.getBlockState(base).isAir()
|
||||
|| !level.getBiome(base).is(CavernIslandBiomeSource.DARK_GROVE)) continue;
|
||||
long mushroomSeed = WoodlandGroveGeometry.seed(seed, x, bed, z, 0x8502L + attempt);
|
||||
var key = Math.floorMod(mushroomSeed, 2) == 0 ? TreeFeatures.HUGE_BROWN_MUSHROOM : TreeFeatures.HUGE_RED_MUSHROOM;
|
||||
Feature feature = level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(key).value();
|
||||
if (!(feature instanceof AbstractHugeMushroomFeature mushroom)) break;
|
||||
int height = mushroom.getTreeHeight(RandomSource.create(mushroomSeed));
|
||||
int radius = mushroom.foliageRadius();
|
||||
if (!mushroomRoom(level, sampler, protection, base, radius, height)) continue;
|
||||
BlockState originalFloor = level.getBlockState(base.below());
|
||||
setBlock(level, base.below(), (Math.floorMod(mushroomSeed, 5) == 0 ? Blocks.MYCELIUM : Blocks.PODZOL).defaultBlockState());
|
||||
// Vanilla consumes its height draw first. Replaying the same random seed
|
||||
// gives precisely the height whose natural envelope was checked above.
|
||||
boolean placed = mushroom.place(level, generator, RandomSource.create(mushroomSeed), base);
|
||||
if (!placed) setBlock(level, base.below(), originalFloor);
|
||||
changed |= placed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean tree(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler, long seed,
|
||||
BlockPos base, Species species, Protection protection, long salt) {
|
||||
if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species))
|
||||
|| !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), species.width())
|
||||
|| !actualRoom(level, base, species.width(), species.radius(), species.height())
|
||||
|| !WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(), species.width(), species.radius(), species.height())) return false;
|
||||
for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) {
|
||||
BlockPos floor = base.offset(dx, -1, dz);
|
||||
if (!ground(level, floor)) return false;
|
||||
}
|
||||
for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) {
|
||||
setBlock(level, base.offset(dx, -1, dz), Blocks.PODZOL.defaultBlockState());
|
||||
}
|
||||
return level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(species.key()).value()
|
||||
.place(level, generator, RandomSource.create(WoodlandGroveGeometry.seed(seed, base.getX(), base.getY(), base.getZ(), salt)), base);
|
||||
}
|
||||
|
||||
private static boolean actualRoom(WorldGenLevel level, BlockPos base, int width, int radius, int height) {
|
||||
for (int y = 0; y <= height; y++) {
|
||||
int from = y < 3 ? 0 : -radius, to = y < 3 ? width - 1 : radius;
|
||||
for (int dx = from; dx <= to; dx++) for (int dz = from; dz <= to; dz++) {
|
||||
BlockState current = level.getBlockState(base.offset(dx, y, dz));
|
||||
if (!current.getFluidState().isEmpty() || !(current.isAir() || current.is(BlockTags.LEAVES)
|
||||
|| current.getBlock() instanceof VegetationBlock)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean ground(WorldGenLevel level, BlockPos floor) {
|
||||
BlockState state = level.getBlockState(floor);
|
||||
if (state.is(Blocks.DEEPSLATE)) {
|
||||
// Deep humid ledges can support organic soil. Check every tree foot rather
|
||||
// than admitting this rock globally, including for the remarkable tree.
|
||||
var biome = level.getBiome(floor.above());
|
||||
return biome.is(CavernIslandBiomeSource.DARK_GROVE) || biome.is(CavernIslandBiomeSource.BAMBOO_GROVE);
|
||||
}
|
||||
return state.is(Blocks.STONE) || state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.PODZOL)
|
||||
|| state.is(Blocks.MYCELIUM) || state.is(Blocks.ROOTED_DIRT) || state.is(Blocks.COARSE_DIRT)
|
||||
|| state.is(Blocks.GRANITE) || state.is(Blocks.DIORITE) || state.is(Blocks.ANDESITE) || state.is(Blocks.TUFF)
|
||||
|| state.is(Blocks.COBBLESTONE) || state.is(Blocks.MOSSY_COBBLESTONE) || state.is(Blocks.PACKED_MUD);
|
||||
}
|
||||
|
||||
private static int localTop(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection,
|
||||
int minX, int minZ, int x, int z, int[] heights) {
|
||||
int index = (x - minX) * 16 + z - minZ;
|
||||
if (heights[index] != Integer.MIN_VALUE) return heights[index];
|
||||
// In an untouched column the heightmap bounds the original surface cheaply.
|
||||
// A protected water/lava column may have been carved: recover its raw top
|
||||
// without depending on the current hydrology plan's representation.
|
||||
int ceiling = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - 1;
|
||||
if (protection.intersects(new BlockPos(x, level.getMinY(), z), 0, level.getHeight() - 1, 0))
|
||||
ceiling = level.getMaxY();
|
||||
return heights[index] = WoodlandGroveGeometry.highestSurfaceBelow(sampler, x, z, ceiling);
|
||||
}
|
||||
|
||||
private static boolean mushroomRoom(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection,
|
||||
BlockPos base, int radius, int height) {
|
||||
if (blocked(protection, base, radius + 1, height + 1, 4)
|
||||
|| !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), 1)) return false;
|
||||
for (int depth = 1; depth <= 3; depth++) {
|
||||
BlockPos floor = base.below(depth);
|
||||
if (!level.getBlockState(floor).isCollisionShapeFullBlock(level, floor)) return false;
|
||||
}
|
||||
// A complete conservative column around the cap handles both brown umbrellas
|
||||
// and the lower rounded red cap. Neither natural rock nor existing logs move.
|
||||
for (int y = 0; y <= height + 1; y++) for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) {
|
||||
BlockPos p = base.offset(dx, y, dz);
|
||||
BlockState current = level.getBlockState(p);
|
||||
if (sampler.sample(p.getX(), p.getY(), p.getZ()) > 0 || !current.getFluidState().isEmpty()
|
||||
|| !(current.isAir() || current.is(BlockTags.LEAVES))) return false;
|
||||
}
|
||||
// Being below another surface is not enough: the same column needs an actual
|
||||
// original rock ceiling above the whole mushroom, not an open hillside.
|
||||
for (int y = base.getY() + height + 2; y <= level.getMaxY(); y++)
|
||||
if (sampler.sample(base.getX(), y, base.getZ()) > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int rootDepth(Species species) { return species.key().equals(TreeFeatures.MANGROVE) ? 16 : 4; }
|
||||
|
||||
private static boolean blocked(Protection protection, BlockPos base, int radius, int height) {
|
||||
return blocked(protection, base, radius, height, 4);
|
||||
}
|
||||
|
||||
private static boolean blocked(Protection protection, BlockPos base, int radius, int height, int rootDepth) {
|
||||
return protection.intersects(base, radius, height, rootDepth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Set;
|
||||
|
||||
/** Alpha.9 river catchments, wider basins and rocky wall springs.
|
||||
* This version is separate from NaturalHydrology so previously created worlds keep their original terrain.
|
||||
* Results retain no chunks, sampler, random state or Minecraft objects. */
|
||||
public final class CavernHydrology {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int MAX_CARVE = 16;
|
||||
public static final int MAX_DEPTH = 8;
|
||||
public static final int MAX_LAKE_CARVE = 12;
|
||||
public static final int MAX_RIVER_BANK_CARVE = 12;
|
||||
private static final int GRID = 8;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
|
||||
|
||||
private CavernHydrology() {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Sampler { float sample(int x, int y, int z); }
|
||||
|
||||
public enum Kind { POND, LAKE, RIVER, TERRACE }
|
||||
public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS }
|
||||
public record Point(int x, int z) {}
|
||||
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
|
||||
public record Spring(long id, Position source, Position outlet, List<Position> flowPath, FlowBounds flowBounds) {
|
||||
public Spring { flowPath = List.copyOf(flowPath); }
|
||||
}
|
||||
public record Terrace(long id, List<Long> 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<Position> 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<Point> path, int waterCells, Bounds bounds) {
|
||||
public Feature { path = List.copyOf(path); }
|
||||
}
|
||||
|
||||
public static final class Plan {
|
||||
private final Map<Long, Cell> cells;
|
||||
private final List<Cell> allCells;
|
||||
private final Map<Long, List<Cell>> layers;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final List<Feature> features;
|
||||
private final List<Spring> springs;
|
||||
private final Map<Long, List<Spring>> springChunks;
|
||||
private final Set<Position> springFlow;
|
||||
private final int sampledColumns;
|
||||
private final long densitySamples;
|
||||
private final List<Terrace> terraces;
|
||||
private final List<Spill> spills;
|
||||
private final Set<Position> spillOpenings;
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow, int columns, long samples) {
|
||||
this(source, features, springs, springFlow, columns, samples, CavernTerraces.Result.EMPTY);
|
||||
}
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow,
|
||||
int columns, long samples, CavernTerraces.Result lower) {
|
||||
List<Cell> combined = new ArrayList<>(source.values());
|
||||
combined.addAll(lower.cells());
|
||||
List<Cell> sorted = combined.stream()
|
||||
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z).thenComparingInt(Cell::bedY)).toList();
|
||||
allCells = List.copyOf(sorted);
|
||||
Map<Long, Cell> ordered = new LinkedHashMap<>();
|
||||
Map<Long, List<Cell>> stacked = new HashMap<>();
|
||||
Map<Long, List<Cell>> 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<Feature> allFeatures = new ArrayList<>(features);
|
||||
allFeatures.addAll(lower.features());
|
||||
this.features = List.copyOf(allFeatures);
|
||||
this.springs = List.copyOf(springs);
|
||||
Set<Position> 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<Long, List<Spring>> groupedSprings = new HashMap<>();
|
||||
for (Spring spring : springs) groupedSprings.computeIfAbsent(
|
||||
key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring);
|
||||
groupedSprings.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
springChunks = Map.copyOf(groupedSprings);
|
||||
sampledColumns = columns;
|
||||
densitySamples = samples;
|
||||
}
|
||||
|
||||
public Cell cellAt(int x, int z) { return cells.get(key(x, z)); }
|
||||
public List<Cell> 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<Cell> cells() { return allCells; }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Feature> 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<Terrace> terraces() { return terraces; }
|
||||
public List<Spill> spills() { return spills; }
|
||||
public boolean isSpillOpening(int x, int y, int z) { return spillOpenings.contains(new Position(x, y, z)); }
|
||||
public List<Spring> springs() { return springs; }
|
||||
public List<Spring> springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); }
|
||||
/** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */
|
||||
public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); }
|
||||
public int sampledColumns() { return sampledColumns; }
|
||||
public long densitySamples() { return densitySamples; }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, Sampler sampler) {
|
||||
return new Planner(seed, sampler).build();
|
||||
}
|
||||
|
||||
private record Column(int top, int solidBottom) {}
|
||||
private record Candidate(int x, int z, double score) {}
|
||||
private record Node(int x, int z, double cost) {}
|
||||
private static final Comparator<Node> NODE_ORDER = Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z);
|
||||
|
||||
private static final class Planner {
|
||||
private final long seed;
|
||||
private final Sampler sampler;
|
||||
private final Map<Long, Column> columns = new HashMap<>();
|
||||
private final Map<Long, Integer> coarse = new HashMap<>();
|
||||
private final Map<Long, Cell> cells = new HashMap<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private final List<Spring> springs = new ArrayList<>();
|
||||
private final Set<Position> springFlow = new HashSet<>();
|
||||
private long samples;
|
||||
|
||||
Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; }
|
||||
|
||||
Plan build() {
|
||||
List<Candidate> candidates = candidates();
|
||||
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<Long> footprint = growBasin(center, level, target);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = kind == Kind.LAKE ? lakeCells(footprint, level, id) : waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, kind, level, List.of(center), wet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
addRiverSpring();
|
||||
addSedimentSheets();
|
||||
addLowerWallSprings();
|
||||
addSprings();
|
||||
Plan surface = new Plan(cells, features, springs, springFlow, columns.size(), samples);
|
||||
var lower = CavernTerraces.create(seed, sampler, surface);
|
||||
return new Plan(cells, features, springs, springFlow, columns.size(), samples, lower);
|
||||
}
|
||||
|
||||
/** A connected catchment is excavated only in a thick natural plateau. The route is
|
||||
* selected on the actual fissured density, not painted across missing rock. Its two
|
||||
* terminal pools share the same retained waterline; a separate rock spring feeds it. */
|
||||
private void addRiver() {
|
||||
List<Integer> heights = coarse.values().stream().filter(y -> y >= 160).sorted().toList();
|
||||
if (heights.isEmpty()) return;
|
||||
Set<Integer> 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<Long> 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<Point> 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<Point> path = smoothRoute(route);
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Set<Long> 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<Long> 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<Long, Cell> 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<Long> growRiverPool(Point center, int level) {
|
||||
Set<Long> result = new HashSet<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
Set<Long> 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<Point> longestRoute(Set<Long> allowed, int level) {
|
||||
Set<Long> remaining = new HashSet<>(allowed);
|
||||
List<Point> longest = List.of();
|
||||
while (!remaining.isEmpty()) {
|
||||
long start = remaining.stream().min(Long::compare).orElseThrow();
|
||||
Map<Long, Long> first = routeTree(start, allowed);
|
||||
remaining.removeAll(first.keySet());
|
||||
if (first.size() < 36) continue;
|
||||
long far = lastKey(first);
|
||||
Map<Long, Long> second = routeTree(far, first.keySet());
|
||||
long end = lastKey(second);
|
||||
Map<Long, Long> shaped = riverRouteTree(far, end, first.keySet(), level);
|
||||
List<Point> 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<Long, Long> riverRouteTree(long start, long end, Set<Long> allowed, int level) {
|
||||
Map<Long, Long> parent = new HashMap<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> 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<Point> smoothRoute(List<Point> coarsePath) {
|
||||
record Smooth(double x, double z) {}
|
||||
List<Smooth> 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<Smooth> 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<Smooth> 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<Point> 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<Long, Long> routeTree(long start, Set<Long> allowed) {
|
||||
Map<Long, Long> parent = new LinkedHashMap<>();
|
||||
ArrayDeque<Long> 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<Long, Long> 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<Cell> 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<Candidate> candidates() {
|
||||
for (int x = -RADIUS; x <= RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS; z <= RADIUS; z += GRID) {
|
||||
if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z));
|
||||
}
|
||||
}
|
||||
List<Candidate> result = new ArrayList<>();
|
||||
for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) {
|
||||
int height = coarse.getOrDefault(key(x, z), -1);
|
||||
if (height < 64 || !inside(x, z, RADIUS - 24)) continue;
|
||||
double average = 0;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1);
|
||||
if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false;
|
||||
average += neighbor * 0.25;
|
||||
}
|
||||
if (safe) result.add(new Candidate(x, z,
|
||||
(average - height) * 2.0 + random(seed + 2, x, z) * 3.0));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
return result;
|
||||
}
|
||||
|
||||
private int coarseTop(int x, int z) {
|
||||
for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private Column column(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Column old = columns.get(key);
|
||||
if (old != null) return old;
|
||||
int top = -1;
|
||||
// Exact scan, including everything above the planned water: a cave roof cannot be
|
||||
// mistaken for a surface. Only candidates and their nearby shore need this refinement.
|
||||
for (int y = 383; y >= 24; y--) {
|
||||
if (solid(x, y, z)) { top = y; break; }
|
||||
}
|
||||
int bottom = top;
|
||||
if (top >= 0) {
|
||||
while (bottom > Math.max(16, top - 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<Long> growBasin(Point center, int level, int target) {
|
||||
if (!eligible(center.x(), center.z(), level)) return null;
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
queue.add(new Node(center.x(), center.z(), 0));
|
||||
costs.put(key(center.x(), center.z()), 0.0);
|
||||
while (!queue.isEmpty() && wet.size() < target) {
|
||||
Node current = queue.remove();
|
||||
if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
if (!eligible(current.x(), current.z(), level)) continue;
|
||||
wet.add(key(current.x(), current.z()));
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = current.x() + d[0], z = current.z() + d[1];
|
||||
if (Math.hypot(x - center.x(), z - center.z()) > 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<Long> work = new ArrayList<>(wet);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long neighbor = key(x, z);
|
||||
if (wet.contains(neighbor) || column(x, z).top() >= level) continue;
|
||||
if (wet.size() >= 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<Long, Cell> waterCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Cell> result = new HashMap<>();
|
||||
for (long key : footprint) {
|
||||
Point p = point(key);
|
||||
if (!eligible(p.x(), p.z(), level)) return null;
|
||||
boolean edge = false;
|
||||
for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1]));
|
||||
Column column = column(p.x(), p.z());
|
||||
int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0));
|
||||
depth = Math.min(depth, 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<Long, Cell> lakeCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Integer> distance = new HashMap<>();
|
||||
ArrayDeque<Long> 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<Long, Cell> 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<Long, Cell> wet) {
|
||||
for (Cell cell : wet.values()) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
if (!solid(cell.x(), y, cell.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
Cell neighbor = wet.get(key(x, z));
|
||||
if (neighbor == null) neighbor = cells.get(key(x, z));
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
if (neighbor != null && neighbor.hasWater()) {
|
||||
if (neighbor.waterY() != cell.waterY()) return false;
|
||||
if (y > neighbor.bedY()) continue;
|
||||
}
|
||||
if (!solid(x, y, z)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addFeature(long id, Kind kind, int level, List<Point> path, Map<Long, Cell> wet) {
|
||||
int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
int count = 0;
|
||||
for (Cell cell : wet.values()) {
|
||||
Cell old = cells.get(key(cell.x(), cell.z()));
|
||||
if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; }
|
||||
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
|
||||
maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z());
|
||||
}
|
||||
features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ)));
|
||||
}
|
||||
|
||||
private static Set<Long> connectedTo(Set<Long> footprint, long start) {
|
||||
Set<Long> connected = new HashSet<>();
|
||||
List<Long> work = new ArrayList<>();
|
||||
connected.add(start); work.add(start);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
long next = key(point.x() + d[0], point.z() + d[1]);
|
||||
if (footprint.contains(next) && connected.add(next)) work.add(next);
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
private record ShoreNode(int x, int z, int level, long feature, double cost) {}
|
||||
|
||||
/** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby
|
||||
* pocket can share the same sheet; the result is not a one-block material ring. */
|
||||
private void addSedimentSheets() {
|
||||
Comparator<ShoreNode> order = Comparator.comparingDouble(ShoreNode::cost)
|
||||
.thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature);
|
||||
Set<Long> rivers = features.stream().filter(f -> f.kind() == Kind.RIVER).map(Feature::id)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
PriorityQueue<ShoreNode> queue = new PriorityQueue<>(order);
|
||||
Map<Long, Double> best = new HashMap<>();
|
||||
for (Cell cell : cells.values()) if (cell.hasWater()) {
|
||||
queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0));
|
||||
best.put(key(cell.x(), cell.z()), 0.0);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
ShoreNode node = queue.remove();
|
||||
if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
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<Position> volume, List<Position> path, FlowBounds bounds) {}
|
||||
|
||||
/** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces
|
||||
* one rock block under an existing roof. Its outlet is already air over an eight-block drop;
|
||||
* no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */
|
||||
private void addSprings() {
|
||||
List<Cliff> cliffs = new ArrayList<>();
|
||||
for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) {
|
||||
if (!inside(x, z, 264)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top < 88) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
int nx = x + d[0] * GRID, nz = z + d[1] * GRID;
|
||||
int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top - lower < 16) continue;
|
||||
cliffs.add(new Cliff(x, z, d[0], d[1],
|
||||
Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3));
|
||||
}
|
||||
}
|
||||
cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed()
|
||||
.thenComparingInt(Cliff::x).thenComparingInt(Cliff::z)
|
||||
.thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz));
|
||||
int attempts = 0;
|
||||
for (Cliff cliff : cliffs) {
|
||||
if (springs.size() >= 3 || attempts++ >= 160) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2, -4, 4}) {
|
||||
for (int along = 0; along < GRID; along++) {
|
||||
int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent;
|
||||
int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent;
|
||||
if (!inside(x, z, 266)) continue;
|
||||
int ox = x + cliff.dx(), oz = z + cliff.dz();
|
||||
Column rock = column(x, z), outletColumn = column(ox, oz);
|
||||
if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue;
|
||||
// Five blocks below the highest natural surface is below the soil cap.
|
||||
int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8));
|
||||
for (int y = rock.top() - 5; y >= floor; y -= 3) {
|
||||
Position source = new Position(x, y, z), outlet = new Position(ox, y, oz);
|
||||
if (!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<Wall> candidates = new ArrayList<>();
|
||||
for (int x = -240; x <= 240; x += 16) for (int z = -240; z <= 240; z += 16) {
|
||||
if (!inside(x, z, 240)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
for (int y = top - 32; y >= 64; y -= 16) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int[] direction : CARDINALS) {
|
||||
if (solid(x + direction[0] * 4, y, z + direction[1] * 4)) continue;
|
||||
candidates.add(new Wall(x, y, z, direction[0], direction[1],
|
||||
random(seed + 1217 + y, x + direction[0], z + direction[1]) * 3
|
||||
+ Math.min(100, top - y) * 0.015));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Wall::score).reversed()
|
||||
.thenComparingInt(Wall::x).thenComparingInt(Wall::z).thenComparingInt(Wall::y)
|
||||
.thenComparingInt(Wall::dx).thenComparingInt(Wall::dz));
|
||||
int attempts = 0;
|
||||
for (Wall candidate : candidates) {
|
||||
if (springs.size() >= 2 || attempts++ >= 96) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2}) {
|
||||
for (int along = 0; along < 4; along++) {
|
||||
int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent;
|
||||
for (int dy : new int[]{0, 3, -3, 6, -6}) {
|
||||
int y = candidate.y() + dy;
|
||||
if (y < 56 || column(x, z).top() - y < 24) continue;
|
||||
Position source = new Position(x, y, z);
|
||||
if (!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<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<FlowNode> queue = new ArrayDeque<>();
|
||||
reach.put(source, 7);
|
||||
queue.add(new FlowNode(source, 7));
|
||||
Position lowest = source;
|
||||
while (!queue.isEmpty()) {
|
||||
FlowNode node = queue.removeFirst();
|
||||
Position at = node.position();
|
||||
if (node.reach() < reach.getOrDefault(at, -1)) continue;
|
||||
if (at.y() < lowest.y()) lowest = at;
|
||||
if (reach.size() > 24000) return null;
|
||||
if (at.y() <= 0) continue;
|
||||
Position below = new Position(at.x(), at.y() - 1, at.z());
|
||||
if (!flowSolid(below, source)) {
|
||||
enqueueFlow(below, 7, at, queue, reach, parent);
|
||||
continue;
|
||||
}
|
||||
if (node.reach() <= 0) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
|
||||
if (flowSolid(next, source)) continue;
|
||||
enqueueFlow(next, node.reach() - 1, at, queue, reach, parent);
|
||||
}
|
||||
}
|
||||
if (!reach.containsKey(outlet)) return null;
|
||||
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY;
|
||||
int minZ = source.z(), maxZ = minZ;
|
||||
for (Position position : reach.keySet()) {
|
||||
minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x());
|
||||
minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y());
|
||||
minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z());
|
||||
}
|
||||
List<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Position source) {
|
||||
if (at.equals(source)) return false;
|
||||
Cell cell = cells.get(key(at.x(), at.z()));
|
||||
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
|
||||
return solid(at.x(), at.y(), at.z());
|
||||
}
|
||||
|
||||
private static void enqueueFlow(Position next, int available, Position from, ArrayDeque<FlowNode> queue,
|
||||
Map<Position, Integer> reach, Map<Position, Position> parent) {
|
||||
if (available <= reach.getOrDefault(next, -1)) return;
|
||||
if (!reach.containsKey(next)) parent.put(next, from);
|
||||
reach.put(next, available);
|
||||
queue.addLast(new FlowNode(next, available));
|
||||
}
|
||||
|
||||
private boolean nearWater(int x, int z, int distance) {
|
||||
for (Feature feature : features) {
|
||||
Point point = feature.path().getFirst();
|
||||
if (Math.hypot(x - point.x(), z - point.z()) < distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; }
|
||||
private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); }
|
||||
|
||||
private double terrainNoise(int x, int z) { return field(x, z, 8, 31); }
|
||||
|
||||
private double field(int x, int z, int scale, long salt) {
|
||||
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
|
||||
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
|
||||
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
|
||||
double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx;
|
||||
double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, gx + 1, gz + 1) * tx;
|
||||
return a * (1 - tz) + b * tz;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static Point point(long key) { return new Point((int) (key >> 32), (int) key); }
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; }
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Alpha.9 only: immutable plans cached by world state, applied to fresh terrain before decoration. */
|
||||
public final class CavernHydrologyRuntime {
|
||||
private static final Map<RandomState, CavernHydrology.Plan> PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private static final Map<RandomState, CavernLavaDeposit.Plan> LAVA_PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private CavernHydrologyRuntime() {}
|
||||
|
||||
public static boolean enabled(NoiseBasedChunkGenerator generator) {
|
||||
return generator.generatorSettings().is(SanctuarySpawn.CAVERN_SETTINGS);
|
||||
}
|
||||
|
||||
public static CavernHydrology.Plan cavernPlan(ServerLevel level) {
|
||||
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static CavernHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
if (!enabled(generator)) throw new IllegalArgumentException("Cavern hydrology is not enabled for this generator");
|
||||
return PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var plan = CavernHydrology.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
|
||||
SanctuaryMod.LOGGER.info("Sanctuary cavern hydrology: seed {}, {} features, {} columns, {} springs, {} ms",
|
||||
state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary cavern catchments: {}", plan.features().stream().map(f -> f.kind() + " Y" + f.waterY() + " water=" + f.waterCells() + " path=" + f.path().size() + " " + f.bounds()).toList());
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
public static CavernLavaDeposit.Plan lavaPlan(ServerLevel level) {
|
||||
return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static CavernLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
// Acquire the water plan before the lava cache lock; the water planner never acquires it.
|
||||
var water = plan(generator, randomState);
|
||||
return LAVA_PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var lava = CavernLavaDeposit.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms",
|
||||
state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000);
|
||||
return lava;
|
||||
});
|
||||
}
|
||||
|
||||
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
|
||||
if (!enabled(generator)) return;
|
||||
var chunkPos = chunk.getPos();
|
||||
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|
||||
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
|
||||
var plan = plan(generator, randomState);
|
||||
var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var outlets = new java.util.ArrayList<>(plan.springs().stream().map(CavernHydrology.Spring::outlet)
|
||||
.filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList());
|
||||
for (var spill : plan.spills()) {
|
||||
var outlet = spill.outlet();
|
||||
if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()) outlets.add(outlet);
|
||||
}
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
for (var fall : lava.falls()) {
|
||||
var outlet = fall.outlet();
|
||||
if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z())
|
||||
outlets.add(new CavernHydrology.Position(outlet.x(), outlet.y(), outlet.z()));
|
||||
}
|
||||
var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z());
|
||||
if (cells.isEmpty() && springs.isEmpty() && outlets.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
// Validate all replacements and two intact support layers before modifying this chunk.
|
||||
for (var cell : cells) {
|
||||
for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
|
||||
requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed());
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed());
|
||||
}
|
||||
for (var support : lavaSupports) {
|
||||
requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed());
|
||||
if (chunk.getBlockState(pos).ignitedByLava()) {
|
||||
throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos);
|
||||
}
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed());
|
||||
}
|
||||
for (var cell : cells) {
|
||||
for (int depth = 0; depth < cell.sedimentDepth(); depth++) {
|
||||
pos.set(cell.x(), cell.bedY() - depth, cell.z());
|
||||
BlockState material = CavernMaterials.sedimentBlock(cell, randomState.seed(), depth);
|
||||
chunk.setBlockState(pos, material, 0);
|
||||
}
|
||||
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY()
|
||||
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
|
||||
if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
pos.set(source.x(), source.y(), source.z());
|
||||
chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0);
|
||||
// Only the source is generated. Vanilla fluid ticks create the descending waterfall.
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
pos.set(cell.x(), cell.y(), cell.z());
|
||||
chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0);
|
||||
if (cell.lava()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
// This existing vanilla queue is consumed after neighbouring chunks finish decoration.
|
||||
// Mark the outlet in its own chunk, including when its source belongs to a neighbour.
|
||||
for (var outlet : outlets) {
|
||||
pos.set(outlet.x(), outlet.y(), outlet.z());
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
|
||||
/** Reserve water, sediment and hot-rock volumes before placing vegetation. */
|
||||
public static boolean protects(NoiseBasedChunkGenerator generator, RandomState randomState, BlockPos base,
|
||||
int radius, int height, int rootDepth) {
|
||||
var water = plan(generator, randomState);
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
int low = base.getY() - rootDepth, high = base.getY() + height;
|
||||
for (int x = base.getX() - radius; x <= base.getX() + radius; x++) {
|
||||
for (int z = base.getZ() - radius; z <= base.getZ() + radius; z++) {
|
||||
for (var cell : water.cellsAt(x, z))
|
||||
if (low <= cell.carveTop() && high >= cell.bedY() - cell.sedimentDepth() - 1) return true;
|
||||
}
|
||||
}
|
||||
for (var spring : water.springs()) {
|
||||
var bounds = spring.flowBounds();
|
||||
if (base.getX() + radius >= bounds.minX() - 2 && base.getX() - radius <= bounds.maxX() + 2
|
||||
&& base.getZ() + radius >= bounds.minZ() - 2 && base.getZ() - radius <= bounds.maxZ() + 2
|
||||
&& low <= bounds.maxY() + 2 && high >= bounds.minY() - 2) return true;
|
||||
var source = spring.source();
|
||||
if (Math.abs(base.getX() - source.x()) <= radius + 3 && Math.abs(base.getZ() - source.z()) <= radius + 3
|
||||
&& low <= source.y() + 3 && high >= source.y() - 3) return true;
|
||||
}
|
||||
for (var bounds : lava.flowBounds()) {
|
||||
if (base.getX() + radius >= bounds.minX() - 8 && base.getX() - radius <= bounds.maxX() + 8
|
||||
&& base.getZ() + radius >= bounds.minZ() - 8 && base.getZ() - radius <= bounds.maxZ() + 8
|
||||
&& low <= bounds.maxY() + 8 && high >= bounds.minY() - 8) return true;
|
||||
}
|
||||
for (var spill : water.spills()) {
|
||||
var bounds = spill.flowBounds();
|
||||
if (base.getX() + radius >= bounds.minX() - 3 && base.getX() - radius <= bounds.maxX() + 3
|
||||
&& base.getZ() + radius >= bounds.minZ() - 3 && base.getZ() - radius <= bounds.maxZ() + 3
|
||||
&& low <= bounds.maxY() + 3 && high >= bounds.minY() - 3) return true;
|
||||
}
|
||||
for (var cell : lava.cells()) if (Math.abs(base.getX() - cell.x()) <= radius + 2 && Math.abs(base.getZ() - cell.z()) <= radius + 2
|
||||
&& low <= cell.y() + 2 && high >= cell.y() - 2) return true;
|
||||
for (var support : lava.supports()) if (Math.abs(base.getX() - support.x()) <= radius + 2 && Math.abs(base.getZ() - support.z()) <= radius + 2
|
||||
&& low <= support.y() + 2 && high >= support.y() - 2) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) {
|
||||
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
|
||||
throw new IllegalStateException("Cavern hydrology differs from terrain at " + pos + " for seed " + seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
|
||||
/** Seeded alpha.9 rift climate, separate from all saved earlier biome-source codecs. */
|
||||
public final class CavernIslandBiomeSource extends BiomeSource {
|
||||
public static final ResourceKey<Biome> OAK_FOREST = key("cavern_oak_forest");
|
||||
public static final ResourceKey<Biome> BIRCH_FOREST = key("cavern_birch_forest");
|
||||
public static final ResourceKey<Biome> CLEARING = key("cavern_clearing");
|
||||
public static final ResourceKey<Biome> DRY_WOODLAND = key("cavern_dry_woodland");
|
||||
public static final ResourceKey<Biome> ROCKY_HEATH = key("cavern_rocky_heath");
|
||||
public static final ResourceKey<Biome> DARK_GROVE = key("cavern_dark_grove");
|
||||
public static final ResourceKey<Biome> BAMBOO_GROVE = key("cavern_bamboo_grove");
|
||||
public static final ResourceKey<Biome> SULFUR_DEPTHS = key("cavern_sulfur_depths");
|
||||
public static final ResourceKey<Biome> LUSH_CAVES = key("cavern_lush_caves");
|
||||
public static final ResourceKey<Biome> DRIPSTONE_CAVES = key("cavern_dripstone_caves");
|
||||
|
||||
public static final MapCodec<CavernIslandBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
Biome.CODEC.fieldOf("oak_forest").forGetter(source -> source.oakForest),
|
||||
Biome.CODEC.fieldOf("birch_forest").forGetter(source -> source.birchForest),
|
||||
Biome.CODEC.fieldOf("clearing").forGetter(source -> source.clearing),
|
||||
Biome.CODEC.fieldOf("dry_woodland").forGetter(source -> source.dryWoodland),
|
||||
Biome.CODEC.fieldOf("rocky_heath").forGetter(source -> source.rockyHeath),
|
||||
Biome.CODEC.fieldOf("dark_grove").forGetter(source -> source.darkGrove),
|
||||
Biome.CODEC.fieldOf("bamboo_grove").forGetter(source -> source.bambooGrove),
|
||||
Biome.CODEC.fieldOf("sulfur_depths").forGetter(source -> source.sulfurDepths),
|
||||
Biome.CODEC.fieldOf("lush_caves").forGetter(source -> source.lushCaves),
|
||||
Biome.CODEC.fieldOf("dripstone_caves").forGetter(source -> source.dripstoneCaves)
|
||||
).apply(instance, CavernIslandBiomeSource::new));
|
||||
|
||||
private final Holder<Biome> oakForest;
|
||||
private final Holder<Biome> birchForest;
|
||||
private final Holder<Biome> clearing;
|
||||
private final Holder<Biome> dryWoodland;
|
||||
private final Holder<Biome> rockyHeath;
|
||||
private final Holder<Biome> darkGrove;
|
||||
private final Holder<Biome> bambooGrove;
|
||||
private final Holder<Biome> sulfurDepths;
|
||||
private final Holder<Biome> lushCaves;
|
||||
private final Holder<Biome> dripstoneCaves;
|
||||
|
||||
public CavernIslandBiomeSource(Holder<Biome> oakForest, Holder<Biome> birchForest, Holder<Biome> clearing,
|
||||
Holder<Biome> dryWoodland, Holder<Biome> rockyHeath, Holder<Biome> darkGrove,
|
||||
Holder<Biome> bambooGrove, Holder<Biome> sulfurDepths, Holder<Biome> lushCaves, Holder<Biome> 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<CavernIslandBiomeSource> codec() { return CODEC; }
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return Stream.of(oakForest, birchForest, clearing, dryWoodland, rockyHeath, darkGrove, bambooGrove, sulfurDepths, lushCaves, dripstoneCaves);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
return (quartX, quartY, quartZ) -> {
|
||||
int x = quartX * 4, y = quartY * 4, z = quartZ * 4;
|
||||
float variation = sampler.temperature().sampleValue(x, 0, z);
|
||||
float moisture = sampler.humidity().sampleValue(x, 0, z);
|
||||
return switch (CavernBiomePalette.select(y, variation, moisture)) {
|
||||
case OAK_FOREST -> oakForest;
|
||||
case BIRCH_FOREST -> birchForest;
|
||||
case CLEARING -> clearing;
|
||||
case DRY_WOODLAND -> dryWoodland;
|
||||
case ROCKY_HEATH -> rockyHeath;
|
||||
case DARK_GROVE -> darkGrove;
|
||||
case BAMBOO_GROVE -> bambooGrove;
|
||||
case SULFUR_DEPTHS -> sulfurDepths;
|
||||
case LUSH_CAVES -> lushCaves;
|
||||
case DRIPSTONE_CAVES -> dripstoneCaves;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
private static ResourceKey<Biome> key(String path) {
|
||||
return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Alpha.9 lava: an optional covered pocket and up to two deep rock outlets.
|
||||
* All sources replace natural rock; Minecraft alone produces the falling lava. */
|
||||
public final class CavernLavaDeposit {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int WATER_CLEARANCE = 32;
|
||||
public static final int LAVA_VOLUME = 18;
|
||||
private static final int ENTRY_DISTANCE = 8;
|
||||
private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final Comparator<Position> POSITION_ORDER = Comparator.comparingInt(Position::x)
|
||||
.thenComparingInt(Position::z).thenComparingInt(Position::y);
|
||||
private static final Comparator<Cell> CELL_ORDER = Comparator.comparingInt(Cell::x)
|
||||
.thenComparingInt(Cell::z).thenComparingInt(Cell::y);
|
||||
|
||||
private CavernLavaDeposit() {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
/** lava=false removes existing rock to open the two-block-high chamber or approach. */
|
||||
public record Cell(int x, int y, int z, boolean lava) {}
|
||||
|
||||
public static final class Plan {
|
||||
private final List<Cell> cells;
|
||||
private final List<Cell> lavaCells;
|
||||
private final List<Position> supports;
|
||||
private final Optional<Position> access;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final Map<Long, List<Position>> supportChunks;
|
||||
private final List<CavernLavaFlows.Fall> falls;
|
||||
private final Map<Long, List<CavernLavaFlows.Fall>> fallChunks;
|
||||
|
||||
private Plan(List<Cell> cells, Set<Position> supports, Position access) {
|
||||
this(cells, supports, access, List.of());
|
||||
}
|
||||
|
||||
private Plan(List<Cell> cells, Set<Position> supports, Position access, List<CavernLavaFlows.Fall> falls) {
|
||||
this.falls = List.copyOf(falls);
|
||||
Map<Long, List<CavernLavaFlows.Fall>> 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<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : this.cells) byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(cell);
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
chunks = Map.copyOf(byChunk);
|
||||
Map<Long, List<Position>> bySupportChunk = new HashMap<>();
|
||||
for (Position support : this.supports) bySupportChunk.computeIfAbsent(key(support.x() >> 4, support.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(support);
|
||||
bySupportChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
supportChunks = Map.copyOf(bySupportChunk);
|
||||
}
|
||||
|
||||
public List<Cell> cells() { return cells; }
|
||||
public List<Cell> lavaCells() { return lavaCells; }
|
||||
public List<Position> supports() { return supports; }
|
||||
public List<CavernLavaFlows.Fall> falls() { return falls; }
|
||||
public List<CavernLavaFlows.Fall> fallsInChunk(int x, int z) { return fallChunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Position> flowSources() { return falls.stream().map(CavernLavaFlows.Fall::source).toList(); }
|
||||
public List<CavernHydrology.FlowBounds> flowBounds() { return falls.stream().map(CavernLavaFlows.Fall::flowBounds).toList(); }
|
||||
/** Player feet on a natural ledge, with three blocks of headroom below a roof or sky. */
|
||||
public Optional<Position> access() { return access; }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Position> supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan hydrology) {
|
||||
Plan pocket = new Search(seed, sampler, hydrology).find();
|
||||
List<CavernLavaFlows.Fall> falls = CavernLavaFlows.create(seed, sampler, hydrology, pocket);
|
||||
List<Cell> cells = new ArrayList<>(pocket.cells());
|
||||
Set<Position> supports = new HashSet<>(pocket.supports());
|
||||
for (var fall : falls) {
|
||||
Position source = fall.source();
|
||||
cells.add(new Cell(source.x(), source.y(), source.z(), true));
|
||||
supports.addAll(fall.supports());
|
||||
}
|
||||
return new Plan(cells, supports, pocket.access().orElse(null), falls);
|
||||
}
|
||||
|
||||
private record Candidate(int x, int z, int dx, int dz, double score) {}
|
||||
private record LowerCandidate(int x, int y, int z, int dx, int dz, double score) {}
|
||||
|
||||
private static final class Search {
|
||||
private final long seed;
|
||||
private final CavernHydrology.Sampler sampler;
|
||||
private final CavernHydrology.Plan hydrology;
|
||||
private final Map<Long, Integer> heights = new HashMap<>();
|
||||
|
||||
Search(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan hydrology) {
|
||||
this.seed = seed;
|
||||
this.sampler = sampler;
|
||||
this.hydrology = hydrology;
|
||||
}
|
||||
|
||||
Plan find() {
|
||||
Plan lower = findLowerPocket();
|
||||
if (lower != null) return lower;
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
for (int x = -184; x <= 184; x += 8) for (int z = -184; z <= 184; z += 8) {
|
||||
if (!inside(x, z, RADIUS - 8)) continue;
|
||||
int ledge = top(x, z);
|
||||
if (ledge < 64 || ledge > 300) continue;
|
||||
for (int[] direction : DIRECTIONS) {
|
||||
int cx = x - direction[0] * ENTRY_DISTANCE, cz = z - direction[1] * ENTRY_DISTANCE;
|
||||
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) continue;
|
||||
int rise = top(cx, cz) - ledge;
|
||||
if (rise < 12) continue;
|
||||
candidates.add(new Candidate(x, z, direction[0], direction[1],
|
||||
random(seed, cx, cz) + Math.min(rise, 40) * 0.015));
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)
|
||||
.thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz));
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (attempts++ >= 192) break;
|
||||
// Refine the coarse edge, including a ledge lying between two eight-block samples.
|
||||
for (int along = 0; along < 8; along++) for (int tangent : new int[]{0, -3, 3}) {
|
||||
int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent;
|
||||
Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz());
|
||||
if (plan != null) return plan;
|
||||
}
|
||||
}
|
||||
return new Plan(List.of(), Set.of(), null);
|
||||
}
|
||||
|
||||
/** 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<LowerCandidate> 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<Cell> cells = new ArrayList<>();
|
||||
Set<Position> changed = new HashSet<>();
|
||||
Set<Position> supports = new HashSet<>();
|
||||
for (int x = cx - 1; x <= cx + 1; x++) for (int z = cz - 1; z <= cz + 1; z++) {
|
||||
for (int y = level - 1; y <= level + 2; y++) {
|
||||
if (!solid(x, y, z)) return null;
|
||||
add(cells, changed, x, y, z, y <= level);
|
||||
}
|
||||
}
|
||||
// A dry lip separates the ledge from the pool. Only the air above it is opened.
|
||||
for (int distance = 2; distance < ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level + 1; y <= level + 2; y++) {
|
||||
if (solid(x, y, z)) add(cells, changed, x, y, z, false);
|
||||
}
|
||||
}
|
||||
// Every carved column must lie under eight continuous natural rock blocks. This
|
||||
// excludes soil-cap excavation at the entrance as well as exposed surface lava.
|
||||
Map<Long, Integer> highest = new HashMap<>();
|
||||
for (Cell cell : cells) highest.merge(key(cell.x(), cell.z()), cell.y(), Math::max);
|
||||
for (var column : highest.entrySet()) {
|
||||
int x = (int) (column.getKey() >> 32), z = (int) (long) column.getKey();
|
||||
for (int y = column.getValue() + 1; y <= column.getValue() + 8; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// Three intact blocks under the pool, two-block-thick side walls, and a solid chamber
|
||||
// surround prevent escape through an adjacent cave. No part of this shell is created.
|
||||
for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 3; z <= cz + 3; z++) {
|
||||
for (int y = level - 4; y <= level + 2; y++) {
|
||||
if (changed.contains(new Position(x, y, z))) continue;
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// The natural roof also covers a conservative envelope around local lava fire spread.
|
||||
// Its thickness keeps later surface vegetation well above the lava chamber.
|
||||
for (int x = cx - 4; x <= cx + 4; x++) for (int z = cz - 4; z <= cz + 4; z++) {
|
||||
for (int y = level + 3; y <= level + 10; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
for (int distance = 2; distance <= ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level - 2; y <= level; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
supports.removeAll(changed);
|
||||
return new Plan(cells, supports, new Position(entryX, level + 1, entryZ));
|
||||
}
|
||||
|
||||
private boolean requireRock(Set<Position> supports, int x, int y, int z) {
|
||||
if (!solid(x, y, z)) return false;
|
||||
supports.add(new Position(x, y, z));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean clearOfWater(int x, int z) {
|
||||
// The extra ten blocks include both the entrance and the complete protected shell.
|
||||
int clearance = WATER_CLEARANCE + 10;
|
||||
for (var cell : hydrology.cells()) {
|
||||
long dx = x - cell.x(), dz = z - cell.z();
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
for (var spring : hydrology.springs()) {
|
||||
var bounds = spring.flowBounds();
|
||||
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
|
||||
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
for (var spill : hydrology.spills()) {
|
||||
var bounds = spill.flowBounds();
|
||||
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
|
||||
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean clearOfWater(int x, int level, int z) {
|
||||
// This box covers chamber, dry approach, intact shell and roof. A lake far above is
|
||||
// allowed; an actual waterfall crossing this level still excludes the entire pocket.
|
||||
var pocket = new CavernHydrology.FlowBounds(x - 10, level - 4, z - 10, x + 10, level + 10, z + 10);
|
||||
for (var cell : hydrology.cells()) {
|
||||
var ground = new CavernHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1,
|
||||
cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z());
|
||||
if (distanceSquared(pocket, ground) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spring : hydrology.springs()) {
|
||||
if (distanceSquared(pocket, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spill : hydrology.spills()) {
|
||||
if (distanceSquared(pocket, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static long distanceSquared(CavernHydrology.FlowBounds a, CavernHydrology.FlowBounds b) {
|
||||
long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX()));
|
||||
long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY()));
|
||||
long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ()));
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
private int top(int x, int z) {
|
||||
return heights.computeIfAbsent(key(x, z), ignored -> {
|
||||
for (int y = 383; y >= 32; y -= 4) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int exact = Math.min(383, y + 3); exact > y; exact--) if (solid(x, exact, z)) return exact;
|
||||
return y;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; }
|
||||
}
|
||||
|
||||
private static void add(List<Cell> cells, Set<Position> changed, int x, int y, int z, boolean lava) {
|
||||
if (changed.add(new Position(x, y, z))) cells.add(new Cell(x, y, z, lava));
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return (long) x * x + (long) z * z < (long) radius * radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static double random(long seed, int x, int z) {
|
||||
long value = seed ^ key(x, z) ^ 0x1A7A5EEDL;
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.CavernLavaDeposit.Position;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Selects a few volcanic seeps in deep exposed rock. Sources replace rock; their outlets and
|
||||
* entire falling columns are left empty for vanilla lava ticks. No retaining wall is generated. */
|
||||
public final class CavernLavaFlows {
|
||||
public static final int MIN_SOURCE_Y = 40;
|
||||
public static final int MAX_SOURCE_Y = 160;
|
||||
public static final int MAX_FALLS = 2;
|
||||
public static final int HORIZONTAL_REACH = 3;
|
||||
public static final int FIRE_CLEARANCE = 8;
|
||||
public static final int WATER_CLEARANCE = 24;
|
||||
private static final int RADIUS = 224;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final Comparator<Position> POSITION_ORDER = Comparator.comparingInt(Position::x)
|
||||
.thenComparingInt(Position::z).thenComparingInt(Position::y);
|
||||
|
||||
private CavernLavaFlows() {}
|
||||
|
||||
/** flowBounds predicts undecorated rock drainage; it does not override Minecraft physics.
|
||||
* supports contains only unchanged natural rock around the source, never the source itself. */
|
||||
public record Fall(long id, Position source, Position outlet, List<Position> flowPath,
|
||||
CavernHydrology.FlowBounds flowBounds, List<Position> supports) {
|
||||
public Fall { flowPath = List.copyOf(flowPath); supports = List.copyOf(supports); }
|
||||
}
|
||||
|
||||
public static List<Fall> create(long seed, CavernHydrology.Sampler sampler,
|
||||
CavernHydrology.Plan water, CavernLavaDeposit.Plan pocket) {
|
||||
return new Search(seed, sampler, water, pocket).find();
|
||||
}
|
||||
|
||||
private record Candidate(int x, int y, int z, int dx, int dz, double score) {}
|
||||
private record Node(Position at, int reach) {}
|
||||
private record Trace(List<Position> path, CavernHydrology.FlowBounds bounds) {}
|
||||
|
||||
private static final class Search {
|
||||
private final long seed;
|
||||
private final CavernHydrology.Sampler sampler;
|
||||
private final CavernHydrology.Plan water;
|
||||
private final CavernLavaDeposit.Plan pocket;
|
||||
private final List<Fall> falls = new ArrayList<>();
|
||||
|
||||
Search(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan water, CavernLavaDeposit.Plan pocket) {
|
||||
this.seed = seed; this.sampler = sampler; this.water = water; this.pocket = pocket;
|
||||
}
|
||||
|
||||
List<Fall> find() {
|
||||
List<Candidate> 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<Position> supports = niche(source, candidate.dx(), candidate.dz());
|
||||
if (supports == null) continue;
|
||||
Position outlet = new Position(x + candidate.dx(), source.y(), z + candidate.dz());
|
||||
Trace flow = trace(source, outlet);
|
||||
if (flow == null || flow.bounds().minY() > source.y() - 12 || !clearOfWater(flow.bounds())) continue;
|
||||
long id = mix(seed ^ (0x1A7AFA11L + falls.size() * 0x9E3779B97F4A7C15L));
|
||||
falls.add(new Fall(id, source, outlet, flow.path(), flow.bounds(), supports));
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
}
|
||||
return List.copyOf(falls);
|
||||
}
|
||||
|
||||
private boolean separated(Position source) {
|
||||
CavernHydrology.FlowBounds point = bounds(source);
|
||||
if (!clearOfWater(point)) return false;
|
||||
for (Fall fall : falls) {
|
||||
if (distanceSquared(source, fall.source()) < 80L * 80
|
||||
|| distanceSquared(point, fall.flowBounds()) < 40L * 40) return false;
|
||||
}
|
||||
for (var cell : pocket.cells()) {
|
||||
if (distanceSquared(source, new Position(cell.x(), cell.y(), cell.z())) < 32L * 32) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private List<Position> niche(Position source, int dx, int dz) {
|
||||
if (!solid(source)) return null;
|
||||
Set<Position> supports = new HashSet<>();
|
||||
// The source sits in rock, with a short intact roof, floor and three back/side faces.
|
||||
// The spill column needs no floor: its absence is precisely what makes a cascade.
|
||||
for (int dy = -3; dy <= 8; dy++) {
|
||||
if (dy == 0) continue;
|
||||
Position at = new Position(source.x(), source.y() + dy, source.z());
|
||||
if (!solid(at)) return null;
|
||||
supports.add(at);
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
if (d[0] == dx && d[1] == dz) continue;
|
||||
for (int depth = 1; depth <= 2; depth++) {
|
||||
Position at = new Position(source.x() + d[0] * depth, source.y(), source.z() + d[1] * depth);
|
||||
if (!solid(at)) return null;
|
||||
supports.add(at);
|
||||
}
|
||||
}
|
||||
for (int dy = 0; dy >= -8; dy--) {
|
||||
if (solid(source.x() + dx, source.y() + dy, source.z() + dz)) return null;
|
||||
}
|
||||
return supports.stream().sorted(POSITION_ORDER).toList();
|
||||
}
|
||||
|
||||
private boolean clearOfWater(CavernHydrology.FlowBounds bounds) {
|
||||
for (var cell : water.cells()) {
|
||||
var column = new CavernHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1,
|
||||
cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z());
|
||||
if (distanceSquared(bounds, column) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spring : water.springs()) {
|
||||
if (distanceSquared(bounds, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spill : water.spills()) {
|
||||
if (distanceSquared(bounds, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Descending lava resets its horizontal reach to three in this non-ultrawarm dimension.
|
||||
* Explore all downhill branches through rock, so selection does not depend on chunk order. */
|
||||
private Trace trace(Position source, Position outlet) {
|
||||
Map<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<Node> 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<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Trace(List.copyOf(path), new CavernHydrology.FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Position source) {
|
||||
if (at.equals(source)) return false;
|
||||
var cell = water.cellAt(at.x(), at.y(), at.z());
|
||||
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
|
||||
return solid(at);
|
||||
}
|
||||
|
||||
private boolean solid(Position at) { return solid(at.x(), at.y(), at.z()); }
|
||||
private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; }
|
||||
}
|
||||
|
||||
private static void enqueue(Position next, int available, Position from, Map<Position, Integer> reach,
|
||||
Map<Position, Position> parent, ArrayDeque<Node> queue) {
|
||||
if (available <= reach.getOrDefault(next, -1)) return;
|
||||
if (!reach.containsKey(next)) parent.put(next, from);
|
||||
reach.put(next, available);
|
||||
queue.addLast(new Node(next, available));
|
||||
}
|
||||
|
||||
private static CavernHydrology.FlowBounds bounds(Position at) {
|
||||
return new CavernHydrology.FlowBounds(at.x(), at.y(), at.z(), at.x(), at.y(), at.z());
|
||||
}
|
||||
|
||||
private static long distanceSquared(Position a, Position b) {
|
||||
long dx = a.x() - b.x(), dy = a.y() - b.y(), dz = a.z() - b.z();
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
private static long distanceSquared(CavernHydrology.FlowBounds a, CavernHydrology.FlowBounds b) {
|
||||
long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX()));
|
||||
long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY()));
|
||||
long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ()));
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
private static double random(long seed, int x, int z) {
|
||||
return (mix(seed ^ ((long) x << 32) ^ (z & 0xffffffffL)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Alpha.9 shore deposits: broad continuous patches through the existing sediment thickness. */
|
||||
public final class CavernMaterials {
|
||||
private CavernMaterials() {}
|
||||
|
||||
public static BlockState sedimentBlock(CavernHydrology.Cell cell, long seed, int depth) {
|
||||
if (cell.material() == CavernHydrology.ShoreMaterial.GRASS) {
|
||||
return (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState();
|
||||
}
|
||||
if (cell.material() == CavernHydrology.ShoreMaterial.CLAY) return Blocks.CLAY.defaultBlockState();
|
||||
double patch = patch(seed, cell.x(), cell.z(), 32);
|
||||
Block material;
|
||||
if (cell.hasWater() && patch < 0.24) material = Blocks.CLAY;
|
||||
else if (patch < 0.53) material = Blocks.SAND;
|
||||
else if (patch < 0.68) material = Blocks.GRAVEL;
|
||||
else {
|
||||
double rock = patch(seed ^ 0x4c6179657273L, cell.x(), cell.z(), 40);
|
||||
material = rock < 0.28 ? Blocks.GRANITE : rock < 0.45 ? Blocks.DIORITE
|
||||
: rock < 0.65 ? Blocks.ANDESITE : Blocks.STONE;
|
||||
}
|
||||
return material.defaultBlockState();
|
||||
}
|
||||
|
||||
private static double patch(long seed, int x, int z, int scale) {
|
||||
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
|
||||
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
|
||||
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
|
||||
double near = lerp(value(seed, gx, gz), value(seed, gx + 1, gz), tx);
|
||||
double far = lerp(value(seed, gx, gz + 1), value(seed, gx + 1, gz + 1), tx);
|
||||
return lerp(near, far, tz);
|
||||
}
|
||||
|
||||
private static double value(long seed, int x, int z) {
|
||||
return (WoodlandGroveGeometry.seed(seed, x, 0, z, 0x53484f5245L) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double lerp(double a, double b, double t) { return a + (b - a) * t; }
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
|
||||
/** A few plants on existing suitable shores, after the forest has been decorated. */
|
||||
public final class CavernShoreSugarCaneFeature implements Feature {
|
||||
public static final MapCodec<CavernShoreSugarCaneFeature> CODEC = MapCodec.unit(CavernShoreSugarCaneFeature::new);
|
||||
|
||||
@Override
|
||||
public MapCodec<CavernShoreSugarCaneFeature> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false;
|
||||
var plan = CavernHydrologyRuntime.plan(noise, level.getLevel().getChunkSource().randomState());
|
||||
var cane = Blocks.SUGAR_CANE.defaultBlockState();
|
||||
boolean placed = false;
|
||||
for (var cell : plan.cellsInChunk(origin.getX() >> 4, origin.getZ() >> 4)) {
|
||||
if (cell.hasWater() || random.nextInt(7) != 0) continue;
|
||||
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
|
||||
if (!level.getBlockState(base).isAir() || !cane.canSurvive(level, base)) continue;
|
||||
int height = 1 + random.nextInt(3);
|
||||
for (int y = 0; y < height; y++) {
|
||||
BlockPos pos = base.above(y);
|
||||
if (!level.getBlockState(pos).isAir() || !cane.canSurvive(level, pos)) break;
|
||||
setBlock(level, pos, cane);
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.VegetationBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
|
||||
/** One-time alpha.9 outlet finishing, using vanilla's persisted and consumed generation queue. */
|
||||
public final class CavernSpringOutlets {
|
||||
public record Pending(List<CavernHydrology.Position> sources, List<CavernHydrology.Position> outlets) {
|
||||
public static final Pending EMPTY = new Pending(List.of(), List.of());
|
||||
public Pending { sources = List.copyOf(sources); outlets = List.copyOf(outlets); }
|
||||
public boolean isEmpty() { return sources.isEmpty() && outlets.isEmpty(); }
|
||||
}
|
||||
|
||||
private CavernSpringOutlets() {}
|
||||
|
||||
/** Called before vanilla consumes the queue; a fully processed/reloaded chunk has no work. */
|
||||
public static Pending capturePending(ServerLevel level, LevelChunk chunk) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|
||||
|| !CavernHydrologyRuntime.enabled(noise)) return Pending.EMPTY;
|
||||
boolean queued = false;
|
||||
for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) {
|
||||
queued = true;
|
||||
break;
|
||||
}
|
||||
if (!queued) return Pending.EMPTY;
|
||||
var plan = CavernHydrologyRuntime.plan(noise, level.getChunkSource().randomState());
|
||||
List<CavernHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
|
||||
for (var spring : plan.springs()) {
|
||||
if (markedHere(chunk, spring.source())) sources.add(spring.source());
|
||||
if (markedHere(chunk, spring.outlet())) outlets.add(spring.outlet());
|
||||
}
|
||||
for (var spill : plan.spills()) {
|
||||
if (markedHere(chunk, spill.source())) sources.add(spill.source());
|
||||
if (markedHere(chunk, spill.outlet())) outlets.add(spill.outlet());
|
||||
}
|
||||
for (var fall : CavernHydrologyRuntime.lavaPlan(noise, level.getChunkSource().randomState()).falls()) {
|
||||
var source = new CavernHydrology.Position(fall.source().x(), fall.source().y(), fall.source().z());
|
||||
var outlet = new CavernHydrology.Position(fall.outlet().x(), fall.outlet().y(), fall.outlet().z());
|
||||
if (markedHere(chunk, source)) sources.add(source);
|
||||
if (markedHere(chunk, outlet)) outlets.add(outlet);
|
||||
}
|
||||
return new Pending(sources, outlets);
|
||||
}
|
||||
|
||||
/** All neighbouring FEATURES have finished before a chunk reaches this stage. */
|
||||
public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) {
|
||||
if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|
||||
|| !CavernHydrologyRuntime.enabled(noise)) return;
|
||||
var random = level.getChunkSource().randomState();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
for (var outlet : pending.outlets()) {
|
||||
if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet);
|
||||
var modified = CavernHydrologyRuntime.plan(noise, random).cellAt(outlet.x(), outlet.y(), outlet.z());
|
||||
boolean carved = modified != null && outlet.y() > modified.waterY() && outlet.y() > modified.bedY()
|
||||
&& outlet.y() <= modified.carveTop();
|
||||
if (!carved && random.sampleBlockValueUncached(density, outlet.x(), outlet.y(), outlet.z()) > 0) {
|
||||
throw new IllegalStateException("A spring outlet must already be natural air: " + outlet);
|
||||
}
|
||||
BlockPos pos = blockPos(outlet);
|
||||
BlockState current = chunk.getBlockState(pos);
|
||||
if (removableDecoration(current)) {
|
||||
// Only this declared air cell is reopened. Neighbour notification wakes the
|
||||
// adjacent source even if its own chunk was post-processed first.
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
|
||||
}
|
||||
}
|
||||
for (var source : pending.sources()) {
|
||||
if (!owns(chunk, source)) throw new IllegalArgumentException("A source belongs to another chunk: " + source);
|
||||
BlockPos pos = blockPos(source);
|
||||
BlockState current = chunk.getBlockState(pos);
|
||||
if (current.getFluidState().isSource()) {
|
||||
if (current.is(Blocks.WATER)) level.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level));
|
||||
else if (current.is(Blocks.LAVA)) level.scheduleTick(pos, Fluids.LAVA, Fluids.LAVA.getTickDelay(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean markedHere(LevelChunk chunk, CavernHydrology.Position position) {
|
||||
if (!owns(chunk, position)) return false;
|
||||
var queue = chunk.getPostProcessing()[chunk.getSectionIndex(position.y())];
|
||||
return queue != null && queue.contains(ProtoChunk.packOffsetCoordinates(blockPos(position)));
|
||||
}
|
||||
|
||||
private static boolean owns(LevelChunk chunk, CavernHydrology.Position position) {
|
||||
return (position.x() >> 4) == chunk.getPos().x() && (position.z() >> 4) == chunk.getPos().z();
|
||||
}
|
||||
|
||||
private static BlockPos blockPos(CavernHydrology.Position position) {
|
||||
return new BlockPos(position.x(), position.y(), position.z());
|
||||
}
|
||||
|
||||
private static boolean removableDecoration(BlockState state) {
|
||||
if (state.hasBlockEntity()) return false;
|
||||
return state.is(Blocks.GLOW_LICHEN) || state.is(Blocks.VINE) || state.is(Blocks.MOSS_CARPET)
|
||||
|| state.getBlock() instanceof VegetationBlock || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.CavernHydrology.*;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Small connected water terraces inside existing cave ledges. Each retained pool is excavated
|
||||
* in rock; one declared edge may overflow. No dam, shelf, cave fill or fluid column is generated. */
|
||||
public final class CavernTerraces {
|
||||
public static final int MIN_Y = 56, MAX_Y = 208;
|
||||
public static final int MAX_GROUPS = 2, MAX_DEPTH = 3, MAX_CARVE = 5;
|
||||
private static final int RADIUS = 216, SEDIMENT = 3;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
|
||||
|
||||
private CavernTerraces() {}
|
||||
|
||||
public record Result(List<Cell> cells, List<Feature> features, List<Terrace> terraces,
|
||||
List<Spill> spills, Set<Position> predictedFlow) {
|
||||
public static final Result EMPTY = new Result(List.of(), List.of(), List.of(), List.of(), Set.of());
|
||||
public Result {
|
||||
cells = List.copyOf(cells); features = List.copyOf(features); terraces = List.copyOf(terraces);
|
||||
spills = List.copyOf(spills); predictedFlow = Set.copyOf(predictedFlow);
|
||||
}
|
||||
}
|
||||
|
||||
public static Result create(long seed, Sampler sampler, CavernHydrology.Plan surface) {
|
||||
return new Search(seed, sampler, surface).build();
|
||||
}
|
||||
|
||||
private record Candidate(int x, int y, int z, int dx, int dz, double score) {}
|
||||
private record Floor(int top, int bottom, int ceiling) {}
|
||||
private record Node(int x, int z, double cost) {}
|
||||
private record FlowNode(Position at, int reach) {}
|
||||
private record Flow(Set<Position> volume, List<Position> path, FlowBounds bounds) {}
|
||||
private record Pool(Map<Long, Cell> cells, Position source, Position outlet) {}
|
||||
private static final Floor NO_FLOOR = new Floor(-1, -1, -1);
|
||||
|
||||
private static final class Search {
|
||||
private final long seed;
|
||||
private final Sampler sampler;
|
||||
private final CavernHydrology.Plan surface;
|
||||
private final Map<Position, Floor> floors = new HashMap<>();
|
||||
private final List<Cell> acceptedCells = new ArrayList<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private final List<Terrace> terraces = new ArrayList<>();
|
||||
private final List<Spill> spills = new ArrayList<>();
|
||||
private final Set<Position> predicted = new HashSet<>();
|
||||
|
||||
Search(long seed, Sampler sampler, CavernHydrology.Plan surface) {
|
||||
this.seed = seed; this.sampler = sampler; this.surface = surface;
|
||||
}
|
||||
|
||||
Result build() {
|
||||
List<Candidate> 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<Position> 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<Cell> 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<Cell> 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<Position> 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<Node> queue = new PriorityQueue<>(Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z));
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
Set<Long> 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<Long, Cell> 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<Cell> cells) {
|
||||
Map<Long, List<Cell>> 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<Cell> group) {
|
||||
Map<Long, List<Cell>> changes = new HashMap<>();
|
||||
Set<Position> 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<Position, Boolean> solidCache = new HashMap<>();
|
||||
Predicate<Position> open = at -> !solidCache.computeIfAbsent(at, p -> flowSolid(p, changes));
|
||||
Predicate<Position> canEnter = at -> !retained.contains(at) && open.test(at);
|
||||
Predicate<Position> hole = at -> open.test(new Position(at.x(), at.y() - 1, at.z()));
|
||||
Map<Position, Integer> reach = new HashMap<>(); Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<FlowNode> 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<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Flow(Set.copyOf(reach.keySet()), List.copyOf(path), new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Map<Long, List<Cell>> 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<Position> downhillChoices(Position at, Predicate<Position> canEnter, Predicate<Position> hole) {
|
||||
List<Position> 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<Position> canEnter, Predicate<Position> 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<Position, Integer> reach,
|
||||
Map<Position, Position> parent, ArrayDeque<FlowNode> 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<Long> connected(Set<Long> footprint, long origin) {
|
||||
if (!footprint.contains(origin)) return Set.of();
|
||||
Set<Long> seen = new HashSet<>(); ArrayDeque<Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** One immutable plan per world random state. The value retains neither that state nor its sampler. */
|
||||
public final class HydrologyRuntime {
|
||||
private static final Map<RandomState, SurfaceHydrology.Plan> PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private HydrologyRuntime() {}
|
||||
|
||||
public static boolean enabled(NoiseBasedChunkGenerator generator) {
|
||||
return generator.generatorSettings().is(SanctuarySpawn.HYDROLOGY_SETTINGS);
|
||||
}
|
||||
|
||||
public static SurfaceHydrology.Plan plan(ServerLevel level) {
|
||||
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static SurfaceHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
if (!enabled(generator)) throw new IllegalArgumentException("Hydrology is not enabled for this generator");
|
||||
return PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var plan = SurfaceHydrology.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
|
||||
SanctuaryMod.LOGGER.info("Sanctuary surface hydrology: seed {}, {} features, {} columns, {} ms",
|
||||
state.seed(), plan.features().size(), plan.cells().size(), (System.nanoTime() - start) / 1_000_000);
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
/** Executed during fresh terrain generation only, before ores, trees and shoreline plants. */
|
||||
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
|
||||
if (!enabled(generator)) return;
|
||||
var chunkPos = chunk.getPos();
|
||||
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|
||||
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
|
||||
var cells = plan(generator, randomState).cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
if (cells.isEmpty()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
// Validate this chunk's entire foundation before the first write. The planner may only
|
||||
// replace existing terrain; a mismatch must fail rather than leave a partially applied bed.
|
||||
for (var cell : cells) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
|
||||
throw new IllegalStateException("Hydrology bed differs from natural terrain at " + pos
|
||||
+ " for seed " + randomState.seed());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var cell : cells) {
|
||||
pos.set(cell.x(), cell.bedY(), cell.z());
|
||||
BlockState material = switch (cell.material()) {
|
||||
case SAND -> Blocks.SAND.defaultBlockState();
|
||||
case GRAVEL -> Blocks.GRAVEL.defaultBlockState();
|
||||
case CLAY -> Blocks.CLAY.defaultBlockState();
|
||||
};
|
||||
chunk.setBlockState(pos, material, 0);
|
||||
if (!cell.hasWater()) continue;
|
||||
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
chunk.setBlockState(pos, y <= cell.waterY()
|
||||
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
|
||||
if (y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
}
|
||||
// buildTerrain's incoming proto status may not yet maintain the WG heightmaps. Decoration
|
||||
// must see the new bed and waterline so trees are placed on dry shores, not below the lake.
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<IslandCapacity> 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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
/** Subtractive sculpting of the legacy seeded field. Nothing here creates terrain or a spawn platform. */
|
||||
public final class IslandShape {
|
||||
public static final int RADIUS = 256;
|
||||
public static final int EDGE_WARP = 32;
|
||||
public static final int TERRAIN_LIMIT = RADIUS + EDGE_WARP;
|
||||
public static final int INNER_RADIUS = 128;
|
||||
public static final int RAISED_HEIGHT = 384;
|
||||
public static final int VERTICAL_OFFSET = 64;
|
||||
|
||||
private IslandShape() {}
|
||||
|
||||
public static float density(int x, int y, int z, float terrain, float distortion) {
|
||||
return density(x, y, z, terrain, distortion, 0, 0);
|
||||
}
|
||||
|
||||
public static float density(int x, int y, int z, float terrain, float distortion, float sculpt, float detail) {
|
||||
double distance = Math.hypot((double) x, (double) z);
|
||||
// Emergency bounds only: the sculpt has already made the field negative before these planes.
|
||||
if (distance >= TERRAIN_LIMIT || y <= 0 || y >= 256) {
|
||||
return -1.0F;
|
||||
}
|
||||
|
||||
double erosion = rimErosion(distance, y, distortion, sculpt, detail);
|
||||
erosion += 2.0 * smoothstep((32.0 - y) / 32.0);
|
||||
erosion += 2.0 * smoothstep((y - 224.0) / 32.0);
|
||||
return bounded(terrain, erosion);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
double distance = Math.hypot((double) x, (double) z);
|
||||
if (distance >= TERRAIN_LIMIT || y <= 0 || y >= height) return -1.0F;
|
||||
double localY = (double) y - offset;
|
||||
double erosion = rimErosion(distance, localY, distortion, sculpt, detail);
|
||||
|
||||
// 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) {
|
||||
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(TERRAIN_LIMIT, RADIUS + coast * 16.0 + coarse * 28.0 + fine * 8.0);
|
||||
double rim = Math.clamp((distance - INNER_RADIUS) / (rimEnd - INNER_RADIUS), 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
/** Broad, seed-warped vertical bands; none changes terrain density or guarantees a resource. */
|
||||
public final class LayeredBiomePalette {
|
||||
public enum Kind { MEADOW, FLOWER_MEADOW, LOWER_GROVE, MOSSY_TERRACES, ROCKY_TERRACES }
|
||||
|
||||
private LayeredBiomePalette() {}
|
||||
|
||||
public static Kind select(int blockY, float variation, float moisture) {
|
||||
double elevation = blockY - Math.clamp(variation, -1.0F, 1.0F) * 28
|
||||
- Math.clamp(moisture, -1.0F, 1.0F) * 12;
|
||||
if (elevation < 154) return moisture < -0.30F ? Kind.ROCKY_TERRACES : Kind.MOSSY_TERRACES;
|
||||
if (elevation < 214) {
|
||||
if (variation > 0.34F) return Kind.ROCKY_TERRACES;
|
||||
return moisture > 0.02F ? Kind.LOWER_GROVE : Kind.MOSSY_TERRACES;
|
||||
}
|
||||
if (variation > 0.22F) return Kind.ROCKY_TERRACES;
|
||||
return moisture > -0.12F ? Kind.FLOWER_MEADOW : Kind.MEADOW;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Set;
|
||||
|
||||
/** Alpha.6 surface basins, rocky shores and layered wall springs.
|
||||
* This version is separate from NaturalHydrology so previously created worlds keep their original terrain.
|
||||
* Results retain no chunks, sampler, random state or Minecraft objects. */
|
||||
public final class LayeredHydrology {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int MAX_CARVE = 4;
|
||||
public static final int MAX_DEPTH = 3;
|
||||
private static final int GRID = 8;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
|
||||
|
||||
private LayeredHydrology() {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Sampler { float sample(int x, int y, int z); }
|
||||
|
||||
public enum Kind { POND, LAKE }
|
||||
public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS }
|
||||
public record Point(int x, int z) {}
|
||||
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
|
||||
public record Spring(long id, Position source, Position outlet, List<Position> flowPath, FlowBounds flowBounds) {
|
||||
public Spring { flowPath = List.copyOf(flowPath); }
|
||||
}
|
||||
|
||||
/** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below.
|
||||
* Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry
|
||||
* terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */
|
||||
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
|
||||
ShoreMaterial material, long featureId, int sedimentDepth) {
|
||||
public boolean hasWater() { return waterY >= 0; }
|
||||
}
|
||||
|
||||
public record Feature(long id, Kind kind, int waterY, List<Point> path, int waterCells, Bounds bounds) {
|
||||
public Feature { path = List.copyOf(path); }
|
||||
}
|
||||
|
||||
public static final class Plan {
|
||||
private final Map<Long, Cell> cells;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final List<Feature> features;
|
||||
private final List<Spring> springs;
|
||||
private final Map<Long, List<Spring>> springChunks;
|
||||
private final Set<Position> springFlow;
|
||||
private final int sampledColumns;
|
||||
private final long densitySamples;
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow, int columns, long samples) {
|
||||
List<Cell> sorted = source.values().stream()
|
||||
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
|
||||
Map<Long, Cell> ordered = new LinkedHashMap<>();
|
||||
Map<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : sorted) {
|
||||
ordered.put(key(cell.x(), cell.z()), cell);
|
||||
byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell);
|
||||
}
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
cells = Collections.unmodifiableMap(ordered);
|
||||
chunks = Map.copyOf(byChunk);
|
||||
this.features = List.copyOf(features);
|
||||
this.springs = List.copyOf(springs);
|
||||
this.springFlow = Set.copyOf(springFlow);
|
||||
Map<Long, List<Spring>> groupedSprings = new HashMap<>();
|
||||
for (Spring spring : springs) groupedSprings.computeIfAbsent(
|
||||
key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring);
|
||||
groupedSprings.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
springChunks = Map.copyOf(groupedSprings);
|
||||
sampledColumns = columns;
|
||||
densitySamples = samples;
|
||||
}
|
||||
|
||||
public Cell cellAt(int x, int z) { return cells.get(key(x, z)); }
|
||||
public Collection<Cell> cells() { return cells.values(); }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Feature> features() { return features; }
|
||||
public List<Spring> springs() { return springs; }
|
||||
public List<Spring> springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); }
|
||||
/** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */
|
||||
public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); }
|
||||
public int sampledColumns() { return sampledColumns; }
|
||||
public long densitySamples() { return densitySamples; }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, Sampler sampler) {
|
||||
return new Planner(seed, sampler).build();
|
||||
}
|
||||
|
||||
private record Column(int top, int solidBottom) {}
|
||||
private record Candidate(int x, int z, double score) {}
|
||||
private record Node(int x, int z, double cost) {}
|
||||
private static final Comparator<Node> NODE_ORDER = Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z);
|
||||
|
||||
private static final class Planner {
|
||||
private final long seed;
|
||||
private final Sampler sampler;
|
||||
private final Map<Long, Column> columns = new HashMap<>();
|
||||
private final Map<Long, Integer> coarse = new HashMap<>();
|
||||
private final Map<Long, Cell> cells = new HashMap<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private final List<Spring> springs = new ArrayList<>();
|
||||
private final Set<Position> springFlow = new HashSet<>();
|
||||
private long samples;
|
||||
|
||||
Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; }
|
||||
|
||||
Plan build() {
|
||||
List<Candidate> candidates = candidates();
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (features.size() >= 5 || attempts++ >= 96) break;
|
||||
Point center = new Point(candidate.x(), candidate.z());
|
||||
if (nearWater(center.x(), center.z(), 38)) continue;
|
||||
Column column = column(center.x(), center.z());
|
||||
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
|
||||
Kind kind = features.size() < 2 ? Kind.LAKE : Kind.POND;
|
||||
int target = kind == Kind.LAKE ? 190 + (int) (random(seed, center.x(), center.z()) * 130)
|
||||
: 55 + (int) (random(seed + 1, center.x(), center.z()) * 55);
|
||||
// Prefer an existing low spot; a shallow cut is the fallback on level terrain.
|
||||
for (int level : new int[]{column.top() + 1, column.top(), column.top() - 1}) {
|
||||
Set<Long> footprint = growBasin(center, level, target);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, kind, level, List.of(center), wet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
addSedimentSheets();
|
||||
addLowerWallSprings();
|
||||
addSprings();
|
||||
return new Plan(cells, features, springs, springFlow, columns.size(), samples);
|
||||
}
|
||||
|
||||
private List<Candidate> candidates() {
|
||||
for (int x = -RADIUS; x <= RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS; z <= RADIUS; z += GRID) {
|
||||
if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z));
|
||||
}
|
||||
}
|
||||
List<Candidate> result = new ArrayList<>();
|
||||
for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) {
|
||||
int height = coarse.getOrDefault(key(x, z), -1);
|
||||
if (height < 64 || !inside(x, z, RADIUS - 24)) continue;
|
||||
double average = 0;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1);
|
||||
if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false;
|
||||
average += neighbor * 0.25;
|
||||
}
|
||||
if (safe) result.add(new Candidate(x, z,
|
||||
(average - height) * 2.0 + random(seed + 2, x, z) * 3.0));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
return result;
|
||||
}
|
||||
|
||||
private int coarseTop(int x, int z) {
|
||||
for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private Column column(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Column old = columns.get(key);
|
||||
if (old != null) return old;
|
||||
int top = -1;
|
||||
// Exact scan, including everything above the planned water: a cave roof cannot be
|
||||
// mistaken for a surface. Only candidates and their nearby shore need this refinement.
|
||||
for (int y = 383; y >= 24; y--) {
|
||||
if (solid(x, y, z)) { top = y; break; }
|
||||
}
|
||||
int bottom = top;
|
||||
if (top >= 0) {
|
||||
while (bottom > Math.max(16, top - 16) && solid(x, bottom - 1, z)) bottom--;
|
||||
}
|
||||
Column created = new Column(top, bottom);
|
||||
columns.put(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private boolean eligible(int x, int z, int level) {
|
||||
if (level < 32 || level >= 383 || !inside(x, z, RADIUS - 2)) return false;
|
||||
Cell old = cells.get(key(x, z));
|
||||
if (old != null && old.hasWater() && old.waterY() != level) return false;
|
||||
Column column = column(x, z);
|
||||
return column.top() >= level - 3 && column.top() < level + MAX_CARVE
|
||||
&& column.solidBottom() <= Math.min(column.top(), level - 1) - 4;
|
||||
}
|
||||
|
||||
private Set<Long> growBasin(Point center, int level, int target) {
|
||||
if (!eligible(center.x(), center.z(), level)) return null;
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
queue.add(new Node(center.x(), center.z(), 0));
|
||||
costs.put(key(center.x(), center.z()), 0.0);
|
||||
while (!queue.isEmpty() && wet.size() < target) {
|
||||
Node current = queue.remove();
|
||||
if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
if (!eligible(current.x(), current.z(), level)) continue;
|
||||
wet.add(key(current.x(), current.z()));
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = current.x() + d[0], z = current.z() + d[1];
|
||||
if (Math.hypot(x - center.x(), z - center.z()) > 28 || wet.contains(key(x, z))) continue;
|
||||
if (!eligible(x, z, level)) continue;
|
||||
double step = Math.hypot(d[0], d[1]);
|
||||
double cost = current.cost() + step * (1 + Math.max(0, column(x, z).top() - level) * 0.8
|
||||
+ terrainNoise(x, z) * 4.5 + field(x + 71, z - 93, 21, 107) * 3.5);
|
||||
if (cost >= costs.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
|
||||
costs.put(key(x, z), cost);
|
||||
queue.add(new Node(x, z, cost));
|
||||
}
|
||||
}
|
||||
wet = connectedTo(wet, key(center.x(), center.z()));
|
||||
if (wet.size() < Math.min(target, 30)) return null;
|
||||
// Filling a hollow requires every lower connected outlet to be included. Abort rather
|
||||
// than place a dam if this natural basin runs to a cliff, cave or an oversized catchment.
|
||||
List<Long> work = new ArrayList<>(wet);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long neighbor = key(x, z);
|
||||
if (wet.contains(neighbor) || column(x, z).top() >= level) continue;
|
||||
if (wet.size() >= 420 || Math.hypot(x - center.x(), z - center.z()) > 32
|
||||
|| !eligible(x, z, level)) return null;
|
||||
wet.add(neighbor);
|
||||
work.add(neighbor);
|
||||
}
|
||||
}
|
||||
return wet;
|
||||
}
|
||||
|
||||
private Map<Long, Cell> waterCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Cell> result = new HashMap<>();
|
||||
for (long key : footprint) {
|
||||
Point p = point(key);
|
||||
if (!eligible(p.x(), p.z(), level)) return null;
|
||||
boolean edge = false;
|
||||
for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1]));
|
||||
Column column = column(p.x(), p.z());
|
||||
int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0));
|
||||
depth = Math.min(depth, MAX_CARVE - Math.max(0, column.top() - level));
|
||||
if (depth < 1 || depth > MAX_DEPTH) return null;
|
||||
int bed = level - depth;
|
||||
if (column.top() < bed || column.solidBottom() > bed - 2) return null;
|
||||
ShoreMaterial material = wetMaterial(p.x(), p.z(), edge);
|
||||
int sedimentDepth = sedimentDepth(p.x(), p.z());
|
||||
if (column.solidBottom() > bed - sedimentDepth - 1) return null;
|
||||
result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()),
|
||||
material, id, sedimentDepth));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean contained(Map<Long, Cell> wet) {
|
||||
for (Cell cell : wet.values()) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
if (!solid(cell.x(), y, cell.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
Cell neighbor = wet.get(key(x, z));
|
||||
if (neighbor == null) neighbor = cells.get(key(x, z));
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
if (neighbor != null && neighbor.hasWater()) {
|
||||
if (neighbor.waterY() != cell.waterY()) return false;
|
||||
if (y > neighbor.bedY()) continue;
|
||||
}
|
||||
if (!solid(x, y, z)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addFeature(long id, Kind kind, int level, List<Point> path, Map<Long, Cell> wet) {
|
||||
int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
int count = 0;
|
||||
for (Cell cell : wet.values()) {
|
||||
Cell old = cells.get(key(cell.x(), cell.z()));
|
||||
if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; }
|
||||
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
|
||||
maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z());
|
||||
}
|
||||
features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ)));
|
||||
}
|
||||
|
||||
private static Set<Long> connectedTo(Set<Long> footprint, long start) {
|
||||
Set<Long> connected = new HashSet<>();
|
||||
List<Long> work = new ArrayList<>();
|
||||
connected.add(start); work.add(start);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
long next = key(point.x() + d[0], point.z() + d[1]);
|
||||
if (footprint.contains(next) && connected.add(next)) work.add(next);
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
private record ShoreNode(int x, int z, int level, long feature, double cost) {}
|
||||
|
||||
/** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby
|
||||
* pocket can share the same sheet; the result is not a one-block material ring. */
|
||||
private void addSedimentSheets() {
|
||||
Comparator<ShoreNode> order = Comparator.comparingDouble(ShoreNode::cost)
|
||||
.thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature);
|
||||
PriorityQueue<ShoreNode> queue = new PriorityQueue<>(order);
|
||||
Map<Long, Double> best = new HashMap<>();
|
||||
for (Cell cell : cells.values()) if (cell.hasWater()) {
|
||||
queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0));
|
||||
best.put(key(cell.x(), cell.z()), 0.0);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
ShoreNode node = queue.remove();
|
||||
if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
Column column = column(node.x(), node.z());
|
||||
Cell existing = cells.get(key(node.x(), node.z()));
|
||||
if (existing == null) {
|
||||
double extent = 9 + field(node.x(), node.z(), 25, 131) * 10;
|
||||
if (node.cost() > extent) continue;
|
||||
int delta = column.top() - node.level();
|
||||
if (delta < 0 || delta > 5) continue;
|
||||
// Low shore: waterline, then one-block steps. Cut at most two existing blocks;
|
||||
// far deposits keep the original landform and feather back into grass/stone.
|
||||
int terrace = node.level() + (int) Math.floor(node.cost() / 4.5);
|
||||
int bed = Math.max(node.level(), Math.max(column.top() - 2, Math.min(column.top(), terrace)));
|
||||
int depth = sedimentDepth(node.x(), node.z());
|
||||
if (column.solidBottom() > bed - depth - 1) continue;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
Cell next = cells.get(key(node.x() + d[0], node.z() + d[1]));
|
||||
if (next != null && next.hasWater() && bed < next.waterY()) safe = false;
|
||||
}
|
||||
if (!safe) continue;
|
||||
ShoreMaterial material = dryMaterial(node.x(), node.z(), node.cost() / extent);
|
||||
cells.put(key(node.x(), node.z()), new Cell(node.x(), node.z(), -1, bed,
|
||||
column.top(), material, node.feature(), depth));
|
||||
}
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = node.x() + d[0], z = node.z() + d[1];
|
||||
if (!inside(x, z, RADIUS - 2)) continue;
|
||||
Cell water = cells.get(key(x, z));
|
||||
if (water != null && water.hasWater()) continue;
|
||||
Column next = column(x, z);
|
||||
if (next.top() < node.level() || next.top() > node.level() + 5) continue;
|
||||
double cost = node.cost() + Math.hypot(d[0], d[1])
|
||||
* (0.7 + Math.abs(next.top() - column.top()) * 0.32 + field(x, z, 12, 211) * 0.6);
|
||||
if (cost > 19 || cost >= best.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
|
||||
best.put(key(x, z), cost);
|
||||
queue.add(new ShoreNode(x, z, node.level(), node.feature(), cost));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int sedimentDepth(int x, int z) { return 3 + (int) (field(x, z, 15, 319) * 2.999); }
|
||||
|
||||
private ShoreMaterial wetMaterial(int x, int z, boolean edge) {
|
||||
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
|
||||
if (geology < 0.46) return ShoreMaterial.STONE;
|
||||
if (geology < 0.65) return ShoreMaterial.GRAVEL;
|
||||
if (!edge && geology > 0.78) return ShoreMaterial.CLAY;
|
||||
return ShoreMaterial.SAND;
|
||||
}
|
||||
|
||||
private ShoreMaterial dryMaterial(int x, int z, double distance) {
|
||||
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
|
||||
double grass = field(x + 103, z - 137, 19, 521);
|
||||
if (grass > 0.62 || distance > 0.60 + grass * 0.65) return ShoreMaterial.GRASS;
|
||||
if (geology < 0.46) return ShoreMaterial.STONE;
|
||||
if (geology < 0.65) return ShoreMaterial.GRAVEL;
|
||||
return ShoreMaterial.SAND;
|
||||
}
|
||||
|
||||
private record Cliff(int x, int z, int dx, int dz, double score) {}
|
||||
private record FlowNode(Position position, int reach) {}
|
||||
private record Flow(Set<Position> volume, List<Position> path, FlowBounds bounds) {}
|
||||
|
||||
/** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces
|
||||
* one rock block under an existing roof. Its outlet is already air over an eight-block drop;
|
||||
* no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */
|
||||
private void addSprings() {
|
||||
List<Cliff> cliffs = new ArrayList<>();
|
||||
for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) {
|
||||
if (!inside(x, z, 264)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top < 88) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
int nx = x + d[0] * GRID, nz = z + d[1] * GRID;
|
||||
int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top - lower < 16) continue;
|
||||
cliffs.add(new Cliff(x, z, d[0], d[1],
|
||||
Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3));
|
||||
}
|
||||
}
|
||||
cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed()
|
||||
.thenComparingInt(Cliff::x).thenComparingInt(Cliff::z)
|
||||
.thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz));
|
||||
int attempts = 0;
|
||||
for (Cliff cliff : cliffs) {
|
||||
if (springs.size() >= 3 || attempts++ >= 160) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2, -4, 4}) {
|
||||
for (int along = 0; along < GRID; along++) {
|
||||
int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent;
|
||||
int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent;
|
||||
if (!inside(x, z, 266)) continue;
|
||||
int ox = x + cliff.dx(), oz = z + cliff.dz();
|
||||
Column rock = column(x, z), outletColumn = column(ox, oz);
|
||||
if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue;
|
||||
// Five blocks below the highest natural surface is below the soil cap.
|
||||
int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8));
|
||||
for (int y = rock.top() - 5; y >= floor; y -= 3) {
|
||||
Position source = new Position(x, y, z), outlet = new Position(ox, y, oz);
|
||||
if (nearModifiedGround(source, 32) || !separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz())) continue;
|
||||
Flow flow = traceSpring(source, outlet);
|
||||
if (flow == null || flow.bounds().minY() > y - 12) continue;
|
||||
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
|
||||
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
|
||||
springFlow.addAll(flow.volume());
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean nearModifiedGround(Position source, int distance) {
|
||||
for (Cell cell : cells.values()) {
|
||||
int dy = Math.max(Math.max(cell.bedY() - cell.sedimentDepth() - source.y(), 0),
|
||||
source.y() - cell.carveTop());
|
||||
double horizontal = Math.hypot(source.x() - cell.x(), source.z() - cell.z());
|
||||
if (Math.hypot(horizontal, dy) < distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean separatedSpring(Position source) {
|
||||
for (Spring spring : springs) {
|
||||
double horizontal = Math.hypot(source.x() - spring.source().x(), source.z() - spring.source().z());
|
||||
int dy = Math.abs(source.y() - spring.source().y());
|
||||
if (Math.hypot(horizontal, dy) < 64 || dy < 12) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private record Wall(int x, int y, int z, int dx, int dz, double score) {}
|
||||
|
||||
/** Probe a few exposed faces below the highest surface as well. This finds overhangs and
|
||||
* lower strata that a highest-column heightmap cannot represent, without inventing a ledge. */
|
||||
private void addLowerWallSprings() {
|
||||
List<Wall> candidates = new ArrayList<>();
|
||||
for (int x = -240; x <= 240; x += 16) for (int z = -240; z <= 240; z += 16) {
|
||||
if (!inside(x, z, 240)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
for (int y = top - 32; y >= 64; y -= 16) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int[] direction : CARDINALS) {
|
||||
if (solid(x + direction[0] * 4, y, z + direction[1] * 4)) continue;
|
||||
candidates.add(new Wall(x, y, z, direction[0], direction[1],
|
||||
random(seed + 1217 + y, x + direction[0], z + direction[1]) * 3
|
||||
+ Math.min(100, top - y) * 0.015));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Wall::score).reversed()
|
||||
.thenComparingInt(Wall::x).thenComparingInt(Wall::z).thenComparingInt(Wall::y)
|
||||
.thenComparingInt(Wall::dx).thenComparingInt(Wall::dz));
|
||||
int attempts = 0;
|
||||
for (Wall candidate : candidates) {
|
||||
if (springs.size() >= 2 || attempts++ >= 96) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2}) {
|
||||
for (int along = 0; along < 4; along++) {
|
||||
int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent;
|
||||
for (int dy : new int[]{0, 3, -3, 6, -6}) {
|
||||
int y = candidate.y() + dy;
|
||||
if (y < 56 || column(x, z).top() - y < 24) continue;
|
||||
Position source = new Position(x, y, z);
|
||||
if (nearModifiedGround(source, 32) || !separatedSpring(source)
|
||||
|| !springNiche(source, candidate.dx(), candidate.dz())) continue;
|
||||
Position outlet = new Position(x + candidate.dx(), y, z + candidate.dz());
|
||||
Flow flow = traceSpring(source, outlet);
|
||||
if (flow == null || flow.bounds().minY() > y - 12) continue;
|
||||
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
|
||||
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
|
||||
springFlow.addAll(flow.volume());
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean springNiche(Position source, int dx, int dz) {
|
||||
for (int y = source.y() - 3; y <= source.y() + 1; y++) {
|
||||
if (!solid(source.x(), y, source.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
if (d[0] == dx && d[1] == dz) continue;
|
||||
if (!solid(source.x() + d[0], source.y(), source.z() + d[1])) return false;
|
||||
}
|
||||
for (int y = source.y(); y >= source.y() - 8; y--) {
|
||||
if (solid(source.x() + dx, y, source.z() + dz)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Predict drainage through undecorated rock to select springs and inspection bounds.
|
||||
* Descend through air first, then explore seven horizontal steps on a shelf. Later trees
|
||||
* can divert the actual fluid: this prediction is not a bound on decorated-world physics.
|
||||
* Integration tests trace actual water back to its source within the inspected chunk halo. */
|
||||
private Flow traceSpring(Position source, Position outlet) {
|
||||
Map<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<FlowNode> queue = new ArrayDeque<>();
|
||||
reach.put(source, 7);
|
||||
queue.add(new FlowNode(source, 7));
|
||||
Position lowest = source;
|
||||
while (!queue.isEmpty()) {
|
||||
FlowNode node = queue.removeFirst();
|
||||
Position at = node.position();
|
||||
if (node.reach() < reach.getOrDefault(at, -1)) continue;
|
||||
if (at.y() < lowest.y()) lowest = at;
|
||||
if (reach.size() > 24000) return null;
|
||||
if (at.y() <= 0) continue;
|
||||
Position below = new Position(at.x(), at.y() - 1, at.z());
|
||||
if (!flowSolid(below, source)) {
|
||||
enqueueFlow(below, 7, at, queue, reach, parent);
|
||||
continue;
|
||||
}
|
||||
if (node.reach() <= 0) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
|
||||
if (flowSolid(next, source)) continue;
|
||||
enqueueFlow(next, node.reach() - 1, at, queue, reach, parent);
|
||||
}
|
||||
}
|
||||
if (!reach.containsKey(outlet)) return null;
|
||||
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY;
|
||||
int minZ = source.z(), maxZ = minZ;
|
||||
for (Position position : reach.keySet()) {
|
||||
minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x());
|
||||
minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y());
|
||||
minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z());
|
||||
}
|
||||
List<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Position source) {
|
||||
if (at.equals(source)) return false;
|
||||
Cell cell = cells.get(key(at.x(), at.z()));
|
||||
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
|
||||
return solid(at.x(), at.y(), at.z());
|
||||
}
|
||||
|
||||
private static void enqueueFlow(Position next, int available, Position from, ArrayDeque<FlowNode> queue,
|
||||
Map<Position, Integer> reach, Map<Position, Position> parent) {
|
||||
if (available <= reach.getOrDefault(next, -1)) return;
|
||||
if (!reach.containsKey(next)) parent.put(next, from);
|
||||
reach.put(next, available);
|
||||
queue.addLast(new FlowNode(next, available));
|
||||
}
|
||||
|
||||
private boolean nearWater(int x, int z, int distance) {
|
||||
for (Feature feature : features) {
|
||||
Point point = feature.path().getFirst();
|
||||
if (Math.hypot(x - point.x(), z - point.z()) < distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; }
|
||||
private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); }
|
||||
|
||||
private double terrainNoise(int x, int z) { return field(x, z, 8, 31); }
|
||||
|
||||
private double field(int x, int z, int scale, long salt) {
|
||||
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
|
||||
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
|
||||
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
|
||||
double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx;
|
||||
double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, gx + 1, gz + 1) * tx;
|
||||
return a * (1 - tz) + b * tz;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static Point point(long key) { return new Point((int) (key >> 32), (int) key); }
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; }
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Alpha.6 only: immutable plans cached by world state, applied to fresh terrain before decoration. */
|
||||
public final class LayeredHydrologyRuntime {
|
||||
private static final Map<RandomState, LayeredHydrology.Plan> PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private static final Map<RandomState, LayeredLavaDeposit.Plan> LAVA_PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private LayeredHydrologyRuntime() {}
|
||||
|
||||
public static boolean enabled(NoiseBasedChunkGenerator generator) {
|
||||
return generator.generatorSettings().is(SanctuarySpawn.LAYERED_SETTINGS);
|
||||
}
|
||||
|
||||
public static LayeredHydrology.Plan layeredPlan(ServerLevel level) {
|
||||
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static LayeredHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
if (!enabled(generator)) throw new IllegalArgumentException("Layered hydrology is not enabled for this generator");
|
||||
return PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var plan = LayeredHydrology.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
|
||||
SanctuaryMod.LOGGER.info("Sanctuary layered hydrology: seed {}, {} features, {} columns, {} springs, {} ms",
|
||||
state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
public static LayeredLavaDeposit.Plan lavaPlan(ServerLevel level) {
|
||||
return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static LayeredLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
// Acquire the water plan before the lava cache lock; the water planner never acquires it.
|
||||
var water = plan(generator, randomState);
|
||||
return LAVA_PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var lava = LayeredLavaDeposit.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms",
|
||||
state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000);
|
||||
return lava;
|
||||
});
|
||||
}
|
||||
|
||||
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
|
||||
if (!enabled(generator)) return;
|
||||
var chunkPos = chunk.getPos();
|
||||
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|
||||
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
|
||||
var plan = plan(generator, randomState);
|
||||
var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var outlets = plan.springs().stream().map(LayeredHydrology.Spring::outlet)
|
||||
.filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList();
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z());
|
||||
if (cells.isEmpty() && springs.isEmpty() && outlets.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
// Validate all replacements and two intact support layers before modifying this chunk.
|
||||
for (var cell : cells) {
|
||||
for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
|
||||
requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed());
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed());
|
||||
}
|
||||
for (var support : lavaSupports) {
|
||||
requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed());
|
||||
if (chunk.getBlockState(pos).ignitedByLava()) {
|
||||
throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos);
|
||||
}
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed());
|
||||
}
|
||||
for (var cell : cells) {
|
||||
for (int depth = 0; depth < cell.sedimentDepth(); depth++) {
|
||||
pos.set(cell.x(), cell.bedY() - depth, cell.z());
|
||||
BlockState material = switch (cell.material()) {
|
||||
case SAND -> Blocks.SAND.defaultBlockState();
|
||||
case GRAVEL -> Blocks.GRAVEL.defaultBlockState();
|
||||
case CLAY -> Blocks.CLAY.defaultBlockState();
|
||||
case STONE -> Blocks.STONE.defaultBlockState();
|
||||
case GRASS -> (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState();
|
||||
};
|
||||
chunk.setBlockState(pos, material, 0);
|
||||
}
|
||||
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY()
|
||||
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
|
||||
if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
pos.set(source.x(), source.y(), source.z());
|
||||
chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0);
|
||||
// Only the source is generated. Vanilla fluid ticks create the descending waterfall.
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
pos.set(cell.x(), cell.y(), cell.z());
|
||||
chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0);
|
||||
if (cell.lava()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
// This existing vanilla queue is consumed after neighbouring chunks finish decoration.
|
||||
// Mark the outlet in its own chunk, including when its source belongs to a neighbour.
|
||||
for (var outlet : outlets) {
|
||||
pos.set(outlet.x(), outlet.y(), outlet.z());
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
|
||||
private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) {
|
||||
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
|
||||
throw new IllegalStateException("Layered hydrology differs from terrain at " + pos + " for seed " + seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
|
||||
/** Alpha.6 palette: flowered upper meadows and distinct humid lower terraces. */
|
||||
public final class LayeredIslandBiomeSource extends BiomeSource {
|
||||
public static final ResourceKey<Biome> MEADOW = key("layered_meadow");
|
||||
public static final ResourceKey<Biome> FLOWER_MEADOW = key("layered_flower_meadow");
|
||||
public static final ResourceKey<Biome> LOWER_GROVE = key("layered_lower_grove");
|
||||
public static final ResourceKey<Biome> MOSSY_TERRACES = key("layered_mossy_terraces");
|
||||
public static final ResourceKey<Biome> ROCKY_TERRACES = key("layered_rocky_terraces");
|
||||
|
||||
public static final MapCodec<LayeredIslandBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
Biome.CODEC.fieldOf("meadow").forGetter(source -> source.meadow),
|
||||
Biome.CODEC.fieldOf("flower_meadow").forGetter(source -> source.flowerMeadow),
|
||||
Biome.CODEC.fieldOf("lower_grove").forGetter(source -> source.lowerGrove),
|
||||
Biome.CODEC.fieldOf("mossy_terraces").forGetter(source -> source.mossyTerraces),
|
||||
Biome.CODEC.fieldOf("rocky_terraces").forGetter(source -> source.rockyTerraces)
|
||||
).apply(instance, LayeredIslandBiomeSource::new));
|
||||
|
||||
private final Holder<Biome> meadow;
|
||||
private final Holder<Biome> flowerMeadow;
|
||||
private final Holder<Biome> lowerGrove;
|
||||
private final Holder<Biome> mossyTerraces;
|
||||
private final Holder<Biome> rockyTerraces;
|
||||
|
||||
public LayeredIslandBiomeSource(Holder<Biome> meadow, Holder<Biome> flowerMeadow,
|
||||
Holder<Biome> lowerGrove, Holder<Biome> mossyTerraces, Holder<Biome> rockyTerraces) {
|
||||
this.meadow = meadow;
|
||||
this.flowerMeadow = flowerMeadow;
|
||||
this.lowerGrove = lowerGrove;
|
||||
this.mossyTerraces = mossyTerraces;
|
||||
this.rockyTerraces = rockyTerraces;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MapCodec<LayeredIslandBiomeSource> codec() { return CODEC; }
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return Stream.of(meadow, flowerMeadow, lowerGrove, mossyTerraces, rockyTerraces);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
return (quartX, quartY, quartZ) -> {
|
||||
int x = quartX * 4, y = quartY * 4, z = quartZ * 4;
|
||||
float variation = sampler.temperature().sampleValue(x, 0, z);
|
||||
float moisture = sampler.humidity().sampleValue(x, 0, z);
|
||||
return switch (LayeredBiomePalette.select(y, variation, moisture)) {
|
||||
case MEADOW -> meadow;
|
||||
case FLOWER_MEADOW -> flowerMeadow;
|
||||
case LOWER_GROVE -> lowerGrove;
|
||||
case MOSSY_TERRACES -> mossyTerraces;
|
||||
case ROCKY_TERRACES -> rockyTerraces;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
private static ResourceKey<Biome> key(String path) {
|
||||
return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** One small, covered lava pocket in existing rock, reached from an existing outdoor ledge.
|
||||
* No solid blocks are added, and unsuitable seeds are allowed to produce no deposit. */
|
||||
public final class LayeredLavaDeposit {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int WATER_CLEARANCE = 32;
|
||||
public static final int LAVA_VOLUME = 18;
|
||||
private static final int ENTRY_DISTANCE = 8;
|
||||
private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final Comparator<Position> POSITION_ORDER = Comparator.comparingInt(Position::x)
|
||||
.thenComparingInt(Position::z).thenComparingInt(Position::y);
|
||||
private static final Comparator<Cell> CELL_ORDER = Comparator.comparingInt(Cell::x)
|
||||
.thenComparingInt(Cell::z).thenComparingInt(Cell::y);
|
||||
|
||||
private LayeredLavaDeposit() {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
/** lava=false removes existing rock to open the two-block-high chamber or approach. */
|
||||
public record Cell(int x, int y, int z, boolean lava) {}
|
||||
|
||||
public static final class Plan {
|
||||
private final List<Cell> cells;
|
||||
private final List<Cell> lavaCells;
|
||||
private final List<Position> supports;
|
||||
private final Optional<Position> access;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final Map<Long, List<Position>> supportChunks;
|
||||
|
||||
private Plan(List<Cell> cells, Set<Position> supports, Position access) {
|
||||
this.cells = cells.stream().sorted(CELL_ORDER).toList();
|
||||
lavaCells = this.cells.stream().filter(Cell::lava).toList();
|
||||
this.supports = supports.stream().sorted(POSITION_ORDER).toList();
|
||||
this.access = Optional.ofNullable(access);
|
||||
Map<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : this.cells) byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(cell);
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
chunks = Map.copyOf(byChunk);
|
||||
Map<Long, List<Position>> bySupportChunk = new HashMap<>();
|
||||
for (Position support : this.supports) bySupportChunk.computeIfAbsent(key(support.x() >> 4, support.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(support);
|
||||
bySupportChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
supportChunks = Map.copyOf(bySupportChunk);
|
||||
}
|
||||
|
||||
public List<Cell> cells() { return cells; }
|
||||
public List<Cell> lavaCells() { return lavaCells; }
|
||||
public List<Position> supports() { return supports; }
|
||||
/** Player feet on the existing ledge; this column was naturally open to the sky. */
|
||||
public Optional<Position> access() { return access; }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Position> supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, LayeredHydrology.Sampler sampler, LayeredHydrology.Plan hydrology) {
|
||||
return new Search(seed, sampler, hydrology).find();
|
||||
}
|
||||
|
||||
private record Candidate(int x, int z, int dx, int dz, double score) {}
|
||||
|
||||
private static final class Search {
|
||||
private final long seed;
|
||||
private final LayeredHydrology.Sampler sampler;
|
||||
private final LayeredHydrology.Plan hydrology;
|
||||
private final Map<Long, Integer> heights = new HashMap<>();
|
||||
|
||||
Search(long seed, LayeredHydrology.Sampler sampler, LayeredHydrology.Plan hydrology) {
|
||||
this.seed = seed;
|
||||
this.sampler = sampler;
|
||||
this.hydrology = hydrology;
|
||||
}
|
||||
|
||||
Plan find() {
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
for (int x = -184; x <= 184; x += 8) for (int z = -184; z <= 184; z += 8) {
|
||||
if (!inside(x, z, RADIUS - 8)) continue;
|
||||
int ledge = top(x, z);
|
||||
if (ledge < 64 || ledge > 300) continue;
|
||||
for (int[] direction : DIRECTIONS) {
|
||||
int cx = x - direction[0] * ENTRY_DISTANCE, cz = z - direction[1] * ENTRY_DISTANCE;
|
||||
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) continue;
|
||||
int rise = top(cx, cz) - ledge;
|
||||
if (rise < 12) continue;
|
||||
candidates.add(new Candidate(x, z, direction[0], direction[1],
|
||||
random(seed, cx, cz) + Math.min(rise, 40) * 0.015));
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)
|
||||
.thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz));
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (attempts++ >= 192) break;
|
||||
// Refine the coarse edge, including a ledge lying between two eight-block samples.
|
||||
for (int along = 0; along < 8; along++) for (int tangent : new int[]{0, -3, 3}) {
|
||||
int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent;
|
||||
Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz());
|
||||
if (plan != null) return plan;
|
||||
}
|
||||
}
|
||||
return new Plan(List.of(), Set.of(), null);
|
||||
}
|
||||
|
||||
private Plan tryNiche(int entryX, int entryZ, int dx, int dz) {
|
||||
int cx = entryX - dx * ENTRY_DISTANCE, cz = entryZ - dz * ENTRY_DISTANCE;
|
||||
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) return null;
|
||||
int level = top(entryX, entryZ);
|
||||
if (level < 64 || level > 300 || top(cx, cz) < level + 12) return null;
|
||||
// The player can discover and enter from an actual outdoor ledge, not a sealed cave.
|
||||
for (int y = level + 1; y < 384; y++) if (solid(entryX, y, entryZ)) return null;
|
||||
for (int y = level - 2; y <= level; y++) if (!solid(entryX, y, entryZ)) return null;
|
||||
|
||||
List<Cell> cells = new ArrayList<>();
|
||||
Set<Position> changed = new HashSet<>();
|
||||
Set<Position> supports = new HashSet<>();
|
||||
for (int x = cx - 1; x <= cx + 1; x++) for (int z = cz - 1; z <= cz + 1; z++) {
|
||||
for (int y = level - 1; y <= level + 2; y++) {
|
||||
if (!solid(x, y, z)) return null;
|
||||
add(cells, changed, x, y, z, y <= level);
|
||||
}
|
||||
}
|
||||
// A dry lip separates the ledge from the pool. Only the air above it is opened.
|
||||
for (int distance = 2; distance < ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level + 1; y <= level + 2; y++) {
|
||||
if (solid(x, y, z)) add(cells, changed, x, y, z, false);
|
||||
}
|
||||
}
|
||||
// Every carved column must lie under eight continuous natural rock blocks. This
|
||||
// excludes soil-cap excavation at the entrance as well as exposed surface lava.
|
||||
Map<Long, Integer> highest = new HashMap<>();
|
||||
for (Cell cell : cells) highest.merge(key(cell.x(), cell.z()), cell.y(), Math::max);
|
||||
for (var column : highest.entrySet()) {
|
||||
int x = (int) (column.getKey() >> 32), z = (int) (long) column.getKey();
|
||||
for (int y = column.getValue() + 1; y <= column.getValue() + 8; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// Three intact blocks under the pool, two-block-thick side walls, and a solid chamber
|
||||
// surround prevent escape through an adjacent cave. No part of this shell is created.
|
||||
for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 3; z <= cz + 3; z++) {
|
||||
for (int y = level - 4; y <= level + 2; y++) {
|
||||
if (changed.contains(new Position(x, y, z))) continue;
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// The natural roof also covers a conservative envelope around local lava fire spread.
|
||||
// Its thickness keeps later surface vegetation well above the lava chamber.
|
||||
for (int x = cx - 4; x <= cx + 4; x++) for (int z = cz - 4; z <= cz + 4; z++) {
|
||||
for (int y = level + 3; y <= level + 10; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
for (int distance = 2; distance <= ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level - 2; y <= level; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
supports.removeAll(changed);
|
||||
return new Plan(cells, supports, new Position(entryX, level + 1, entryZ));
|
||||
}
|
||||
|
||||
private boolean requireRock(Set<Position> supports, int x, int y, int z) {
|
||||
if (!solid(x, y, z)) return false;
|
||||
supports.add(new Position(x, y, z));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean clearOfWater(int x, int z) {
|
||||
// The extra ten blocks include both the entrance and the complete protected shell.
|
||||
int clearance = WATER_CLEARANCE + 10;
|
||||
for (var cell : hydrology.cells()) {
|
||||
long dx = x - cell.x(), dz = z - cell.z();
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
for (var spring : hydrology.springs()) {
|
||||
var bounds = spring.flowBounds();
|
||||
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
|
||||
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int top(int x, int z) {
|
||||
return heights.computeIfAbsent(key(x, z), ignored -> {
|
||||
for (int y = 383; y >= 32; y -= 4) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int exact = Math.min(383, y + 3); exact > y; exact--) if (solid(x, exact, z)) return exact;
|
||||
return y;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; }
|
||||
}
|
||||
|
||||
private static void add(List<Cell> cells, Set<Position> changed, int x, int y, int z, boolean lava) {
|
||||
if (changed.add(new Position(x, y, z))) cells.add(new Cell(x, y, z, lava));
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return (long) x * x + (long) z * z < (long) radius * radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static double random(long seed, int x, int z) {
|
||||
long value = seed ^ key(x, z) ^ 0x1A7A5EEDL;
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
|
||||
/** A few plants on existing suitable shores, after the forest has been decorated. */
|
||||
public final class LayeredShoreSugarCaneFeature implements Feature {
|
||||
public static final MapCodec<LayeredShoreSugarCaneFeature> CODEC = MapCodec.unit(LayeredShoreSugarCaneFeature::new);
|
||||
|
||||
@Override
|
||||
public MapCodec<LayeredShoreSugarCaneFeature> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !LayeredHydrologyRuntime.enabled(noise)) return false;
|
||||
var plan = LayeredHydrologyRuntime.plan(noise, level.getLevel().getChunkSource().randomState());
|
||||
var cane = Blocks.SUGAR_CANE.defaultBlockState();
|
||||
boolean placed = false;
|
||||
for (var cell : plan.cellsInChunk(origin.getX() >> 4, origin.getZ() >> 4)) {
|
||||
if (cell.hasWater() || random.nextInt(7) != 0) continue;
|
||||
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
|
||||
if (!level.getBlockState(base).isAir() || !cane.canSurvive(level, base)) continue;
|
||||
int height = 1 + random.nextInt(3);
|
||||
for (int y = 0; y < height; y++) {
|
||||
BlockPos pos = base.above(y);
|
||||
if (!level.getBlockState(pos).isAir() || !cane.canSurvive(level, pos)) break;
|
||||
setBlock(level, pos, cane);
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.VegetationBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
|
||||
/** One-time alpha.6 outlet finishing, using vanilla's persisted and consumed generation queue. */
|
||||
public final class LayeredSpringOutlets {
|
||||
public record Pending(List<LayeredHydrology.Position> sources, List<LayeredHydrology.Position> outlets) {
|
||||
public static final Pending EMPTY = new Pending(List.of(), List.of());
|
||||
public Pending { sources = List.copyOf(sources); outlets = List.copyOf(outlets); }
|
||||
public boolean isEmpty() { return sources.isEmpty() && outlets.isEmpty(); }
|
||||
}
|
||||
|
||||
private LayeredSpringOutlets() {}
|
||||
|
||||
/** Called before vanilla consumes the queue; a fully processed/reloaded chunk has no work. */
|
||||
public static Pending capturePending(ServerLevel level, LevelChunk chunk) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|
||||
|| !LayeredHydrologyRuntime.enabled(noise)) return Pending.EMPTY;
|
||||
boolean queued = false;
|
||||
for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) {
|
||||
queued = true;
|
||||
break;
|
||||
}
|
||||
if (!queued) return Pending.EMPTY;
|
||||
var plan = LayeredHydrologyRuntime.plan(noise, level.getChunkSource().randomState());
|
||||
List<LayeredHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
|
||||
for (var spring : plan.springs()) {
|
||||
if (markedHere(chunk, spring.source())) sources.add(spring.source());
|
||||
if (markedHere(chunk, spring.outlet())) outlets.add(spring.outlet());
|
||||
}
|
||||
return new Pending(sources, outlets);
|
||||
}
|
||||
|
||||
/** All neighbouring FEATURES have finished before a chunk reaches this stage. */
|
||||
public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) {
|
||||
if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|
||||
|| !LayeredHydrologyRuntime.enabled(noise)) return;
|
||||
var random = level.getChunkSource().randomState();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
for (var outlet : pending.outlets()) {
|
||||
if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet);
|
||||
if (random.sampleBlockValueUncached(density, outlet.x(), outlet.y(), outlet.z()) > 0) {
|
||||
throw new IllegalStateException("A spring outlet must already be natural air: " + outlet);
|
||||
}
|
||||
BlockPos pos = blockPos(outlet);
|
||||
BlockState current = chunk.getBlockState(pos);
|
||||
if (removableDecoration(current)) {
|
||||
// Only this declared air cell is reopened. Neighbour notification wakes the
|
||||
// adjacent source even if its own chunk was post-processed first.
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
|
||||
}
|
||||
}
|
||||
for (var source : pending.sources()) {
|
||||
if (!owns(chunk, source)) throw new IllegalArgumentException("A source belongs to another chunk: " + source);
|
||||
BlockPos pos = blockPos(source);
|
||||
BlockState current = chunk.getBlockState(pos);
|
||||
if (current.is(Blocks.WATER) && current.getFluidState().isSource()) {
|
||||
level.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean markedHere(LevelChunk chunk, LayeredHydrology.Position position) {
|
||||
if (!owns(chunk, position)) return false;
|
||||
var queue = chunk.getPostProcessing()[chunk.getSectionIndex(position.y())];
|
||||
return queue != null && queue.contains(ProtoChunk.packOffsetCoordinates(blockPos(position)));
|
||||
}
|
||||
|
||||
private static boolean owns(LevelChunk chunk, LayeredHydrology.Position position) {
|
||||
return (position.x() >> 4) == chunk.getPos().x() && (position.z() >> 4) == chunk.getPos().z();
|
||||
}
|
||||
|
||||
private static BlockPos blockPos(LayeredHydrology.Position position) {
|
||||
return new BlockPos(position.x(), position.y(), position.z());
|
||||
}
|
||||
|
||||
private static boolean removableDecoration(BlockState state) {
|
||||
if (state.hasBlockEntity()) return false;
|
||||
return state.is(Blocks.GLOW_LICHEN) || state.is(Blocks.VINE) || state.is(Blocks.MOSS_CARPET)
|
||||
|| state.getBlock() instanceof VegetationBlock || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
/** Geometry checks shared by lower-ledge decoration and density diagnostics. */
|
||||
public final class LowerTerraceEcology {
|
||||
@FunctionalInterface
|
||||
public interface Sampler { float sample(int x, int y, int z); }
|
||||
|
||||
private LowerTerraceEcology() {}
|
||||
|
||||
public static int highestNaturalSurface(Sampler sampler, int x, int z) {
|
||||
for (int y = 383; y >= 24; y--) if (sampler.sample(x, y, z) > 0) return y;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** A lower natural floor: three solid blocks, headroom, and real terrain at least eight blocks above. */
|
||||
public static boolean isLowerTerrace(Sampler sampler, int x, int bedY, int z, int highestNaturalSurface) {
|
||||
if (bedY < 32 || bedY > 312 || highestNaturalSurface < bedY + 8) return false;
|
||||
for (int y = bedY - 2; y <= bedY; y++) if (sampler.sample(x, y, z) <= 0) return false;
|
||||
return sampler.sample(x, bedY + 1, z) <= 0 && sampler.sample(x, bedY + 2, z) <= 0;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
|
||||
/** Decorates actual lower natural floors after the ordinary highest-surface vegetation.
|
||||
* Each chunk owns its columns; planned water, sediments, lava and their supports are excluded. */
|
||||
public final class LowerTerraceVegetationFeature implements Feature {
|
||||
public static final MapCodec<LowerTerraceVegetationFeature> CODEC = MapCodec.unit(LowerTerraceVegetationFeature::new);
|
||||
|
||||
@Override
|
||||
public MapCodec<LowerTerraceVegetationFeature> codec() { return CODEC; }
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !LayeredHydrologyRuntime.enabled(noise)) return false;
|
||||
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
|
||||
if (Math.abs((long) minX) > 288 || Math.abs((long) minZ) > 288) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
var water = LayeredHydrologyRuntime.plan(noise, state);
|
||||
var lava = LayeredHydrologyRuntime.lavaPlan(noise, state);
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
LowerTerraceEcology.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
var climate = state.createClimateSampler(SamplerContext.EMPTY_UNCACHED);
|
||||
Set<Long> lavaColumns = new HashSet<>();
|
||||
for (var cell : lava.cells()) protectColumn(lavaColumns, cell.x(), cell.z());
|
||||
for (var support : lava.supports()) protectColumn(lavaColumns, support.x(), support.z());
|
||||
boolean changed = false;
|
||||
BlockPos.MutableBlockPos floor = new BlockPos.MutableBlockPos();
|
||||
for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) {
|
||||
if (blockedColumn(water, lavaColumns, x, z)) continue;
|
||||
// A coherent moisture patch controls coverage, rather than an alternating block grid.
|
||||
float growth = climate.humidity().sampleValue(x + 317, 0, z - 191);
|
||||
if (growth < -0.30F) continue;
|
||||
int highest = -1, terraces = 0;
|
||||
for (int y = 319; y >= 32; y--) {
|
||||
floor.set(x, y, z);
|
||||
BlockState ground = level.getBlockState(floor);
|
||||
if (!replaceableGround(ground)) continue;
|
||||
if (highest < 0) {
|
||||
if (sampler.sample(x, y, z) > 0) highest = y;
|
||||
continue;
|
||||
}
|
||||
if (y > highest - 8) continue;
|
||||
BlockPos plantPos = floor.above();
|
||||
if (!level.getBlockState(plantPos).isAir() || !level.getBlockState(plantPos.above()).isAir()) continue;
|
||||
if (!LowerTerraceEcology.isLowerTerrace(sampler, x, y, z, highest)) continue;
|
||||
if (!ground.isCollisionShapeFullBlock(level, floor)
|
||||
|| !level.getBlockState(floor.below()).isCollisionShapeFullBlock(level, floor.below())
|
||||
|| !level.getBlockState(floor.below(2)).isCollisionShapeFullBlock(level, floor.below(2))) continue;
|
||||
var biome = level.getBiome(plantPos);
|
||||
if (biome.is(LayeredIslandBiomeSource.ROCKY_TERRACES)) continue;
|
||||
boolean mossy = biome.is(LayeredIslandBiomeSource.MOSSY_TERRACES);
|
||||
// Moss survives shaded ledges without needing artificial lighting. Existing grass
|
||||
// is retained outside the moss biome; no supporting terrain is added to air.
|
||||
if (mossy || !ground.is(Blocks.GRASS_BLOCK)) {
|
||||
setBlock(level, floor, Blocks.MOSS_BLOCK.defaultBlockState());
|
||||
changed = true;
|
||||
}
|
||||
if (random.nextInt(5) < 2) {
|
||||
BlockState plant = plant(mossy, random.nextInt(10));
|
||||
if (plant.canSurvive(level, plantPos)) {
|
||||
setBlock(level, plantPos, plant);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (++terraces >= 4) break;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static BlockState plant(boolean mossy, int choice) {
|
||||
if (mossy) return switch (choice) {
|
||||
case 0 -> Blocks.FLOWERING_AZALEA.defaultBlockState();
|
||||
case 1 -> Blocks.AZALEA.defaultBlockState();
|
||||
case 2, 3 -> Blocks.FERN.defaultBlockState();
|
||||
default -> Blocks.MOSS_CARPET.defaultBlockState();
|
||||
};
|
||||
return switch (choice) {
|
||||
case 0 -> Blocks.OXEYE_DAISY.defaultBlockState();
|
||||
case 1 -> Blocks.CORNFLOWER.defaultBlockState();
|
||||
case 2 -> Blocks.AZURE_BLUET.defaultBlockState();
|
||||
case 3 -> Blocks.DANDELION.defaultBlockState();
|
||||
case 4 -> Blocks.BUSH.defaultBlockState();
|
||||
default -> Blocks.SHORT_GRASS.defaultBlockState();
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean replaceableGround(BlockState state) {
|
||||
return state.is(Blocks.STONE) || state.is(Blocks.GRANITE) || state.is(Blocks.DIORITE)
|
||||
|| state.is(Blocks.ANDESITE) || state.is(Blocks.TUFF) || state.is(Blocks.DIRT)
|
||||
|| state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.ROOTED_DIRT) || state.is(Blocks.COARSE_DIRT)
|
||||
|| state.is(Blocks.MOSS_BLOCK);
|
||||
}
|
||||
|
||||
private static boolean blockedColumn(LayeredHydrology.Plan water, Set<Long> lavaColumns, int x, int z) {
|
||||
if (lavaColumns.contains(key(x, z))) return true;
|
||||
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
|
||||
if (water.cellAt(x + dx, z + dz) != null) return true;
|
||||
}
|
||||
for (var spring : water.springs()) if (inside(spring.flowBounds(), x, z)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean inside(LayeredHydrology.FlowBounds bounds, int x, int z) {
|
||||
return x >= bounds.minX() - 2 && x <= bounds.maxX() + 2
|
||||
&& z >= bounds.minZ() - 2 && z <= bounds.maxZ() + 2;
|
||||
}
|
||||
|
||||
private static void protectColumn(Set<Long> columns, int x, int z) {
|
||||
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) columns.add(key(x + dx, z + dz));
|
||||
}
|
||||
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import net.minecraft.util.Interval;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunctions;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
|
||||
/** Compiled density API. Legacy defaults preserve the generator saved by alpha.2 worlds. */
|
||||
public record MainIslandDensity(DensityFunction terrain, DensityFunction distortion,
|
||||
DensityFunction sculpt, DensityFunction detail,
|
||||
int verticalOffset, int height, DensityFunction underside) implements DensityFunction {
|
||||
public static final MapCodec<MainIslandDensity> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
DensityFunction.CODEC.fieldOf("terrain").forGetter(MainIslandDensity::terrain),
|
||||
DensityFunction.CODEC.fieldOf("distortion").forGetter(MainIslandDensity::distortion),
|
||||
DensityFunction.CODEC.optionalFieldOf("sculpt", DensityFunctions.zero()).forGetter(MainIslandDensity::sculpt),
|
||||
DensityFunction.CODEC.optionalFieldOf("detail", DensityFunctions.zero()).forGetter(MainIslandDensity::detail),
|
||||
Codec.intRange(0, 128).optionalFieldOf("vertical_offset", 0).forGetter(MainIslandDensity::verticalOffset),
|
||||
Codec.intRange(256, 384).optionalFieldOf("height", 256).forGetter(MainIslandDensity::height),
|
||||
DensityFunction.CODEC.optionalFieldOf("underside", DensityFunctions.zero()).forGetter(MainIslandDensity::underside)
|
||||
).apply(instance, MainIslandDensity::new));
|
||||
|
||||
public MainIslandDensity(DensityFunction terrain, DensityFunction distortion) {
|
||||
this(terrain, distortion, DensityFunctions.zero(), DensityFunctions.zero());
|
||||
}
|
||||
|
||||
public MainIslandDensity(DensityFunction terrain, DensityFunction distortion,
|
||||
DensityFunction sculpt, DensityFunction detail) {
|
||||
this(terrain, distortion, sculpt, detail, 0, 256, DensityFunctions.zero());
|
||||
}
|
||||
|
||||
@Override
|
||||
public DensitySampler compileSampler(CompileContext context) {
|
||||
DensitySampler terrainSampler = terrain.compileSampler(context);
|
||||
DensitySampler distortionSampler = distortion.compileSampler(context);
|
||||
DensitySampler sculptSampler = sculpt.compileSampler(context);
|
||||
DensitySampler detailSampler = detail.compileSampler(context);
|
||||
DensitySampler undersideSampler = underside.compileSampler(context);
|
||||
return new DensitySampler() {
|
||||
@Override
|
||||
public float sampleValue(SamplerContext samplerContext, int x, int y, int z) {
|
||||
if (Math.hypot((double) x, (double) z) >= IslandShape.TERRAIN_LIMIT || y <= 0 || y >= height) {
|
||||
return -1.0F;
|
||||
}
|
||||
int sampleY = y - verticalOffset;
|
||||
float natural = terrainSampler.sampleValue(samplerContext, x, sampleY, z);
|
||||
float coast = distortionSampler.sampleValue(samplerContext, x, 0, z);
|
||||
float coarse = sculptSampler.sampleValue(samplerContext, x, sampleY, z);
|
||||
float fine = detailSampler.sampleValue(samplerContext, x, sampleY, z);
|
||||
if (verticalOffset == 0 && height == 256) {
|
||||
return IslandShape.density(x, y, z, natural, coast, coarse, fine);
|
||||
}
|
||||
return IslandShape.raisedDensity(x, y, z, natural, coast, coarse, fine,
|
||||
undersideSampler.sampleValue(samplerContext, x, sampleY, z), verticalOffset, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sampleVolume(SamplerContext context, DensityBuffer buffer, DensityVolume volume) {
|
||||
DensitySampler.sampleVolumeNaive(context, buffer, volume, this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public DensityFunction rewriteChildren(DfRewriteRule rule) {
|
||||
return new MainIslandDensity(rule.rewrite(terrain), rule.rewrite(distortion),
|
||||
rule.rewrite(sculpt), rule.rewrite(detail), verticalOffset, height, rule.rewrite(underside));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Interval range() {
|
||||
return Interval.of(-1.0F, 1.0F);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int domainAxes() {
|
||||
return ALL_AXES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MapCodec<MainIslandDensity> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Set;
|
||||
|
||||
/** Alpha.5 surface water, broad natural sediment sheets and occasional exposed rock springs.
|
||||
* This version is separate from SurfaceHydrology so previously created worlds keep alpha.4 terrain.
|
||||
* Results retain no chunks, sampler, random state or Minecraft objects. */
|
||||
public final class NaturalHydrology {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int MAX_CARVE = 4;
|
||||
public static final int MAX_DEPTH = 3;
|
||||
private static final int GRID = 8;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
|
||||
|
||||
private NaturalHydrology() {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Sampler { float sample(int x, int y, int z); }
|
||||
|
||||
public enum Kind { POND, LAKE, STREAM }
|
||||
public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS }
|
||||
public record Point(int x, int z) {}
|
||||
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
|
||||
public record Spring(long id, Position source, Position outlet, List<Position> flowPath, FlowBounds flowBounds) {
|
||||
public Spring { flowPath = List.copyOf(flowPath); }
|
||||
}
|
||||
|
||||
/** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below.
|
||||
* Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry
|
||||
* terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */
|
||||
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
|
||||
ShoreMaterial material, long featureId, int sedimentDepth) {
|
||||
public boolean hasWater() { return waterY >= 0; }
|
||||
}
|
||||
|
||||
public record Feature(long id, Kind kind, int waterY, List<Point> path, int waterCells, Bounds bounds) {
|
||||
public Feature { path = List.copyOf(path); }
|
||||
}
|
||||
|
||||
public static final class Plan {
|
||||
private final Map<Long, Cell> cells;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final List<Feature> features;
|
||||
private final List<Spring> springs;
|
||||
private final Map<Long, List<Spring>> springChunks;
|
||||
private final Set<Position> springFlow;
|
||||
private final int sampledColumns;
|
||||
private final long densitySamples;
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow, int columns, long samples) {
|
||||
List<Cell> sorted = source.values().stream()
|
||||
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
|
||||
Map<Long, Cell> ordered = new LinkedHashMap<>();
|
||||
Map<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : sorted) {
|
||||
ordered.put(key(cell.x(), cell.z()), cell);
|
||||
byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell);
|
||||
}
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
cells = Collections.unmodifiableMap(ordered);
|
||||
chunks = Map.copyOf(byChunk);
|
||||
this.features = List.copyOf(features);
|
||||
this.springs = List.copyOf(springs);
|
||||
this.springFlow = Set.copyOf(springFlow);
|
||||
Map<Long, List<Spring>> groupedSprings = new HashMap<>();
|
||||
for (Spring spring : springs) groupedSprings.computeIfAbsent(
|
||||
key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring);
|
||||
groupedSprings.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
springChunks = Map.copyOf(groupedSprings);
|
||||
sampledColumns = columns;
|
||||
densitySamples = samples;
|
||||
}
|
||||
|
||||
public Cell cellAt(int x, int z) { return cells.get(key(x, z)); }
|
||||
public Collection<Cell> cells() { return cells.values(); }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Feature> features() { return features; }
|
||||
public List<Spring> springs() { return springs; }
|
||||
public List<Spring> springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); }
|
||||
/** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */
|
||||
public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); }
|
||||
public int sampledColumns() { return sampledColumns; }
|
||||
public long densitySamples() { return densitySamples; }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, Sampler sampler) {
|
||||
return new Planner(seed, sampler).build();
|
||||
}
|
||||
|
||||
private record Column(int top, int solidBottom) {}
|
||||
private record Candidate(int x, int z, double score) {}
|
||||
private record Node(int x, int z, double cost) {}
|
||||
private static final Comparator<Node> NODE_ORDER = Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z);
|
||||
|
||||
private static final class Planner {
|
||||
private final long seed;
|
||||
private final Sampler sampler;
|
||||
private final Map<Long, Column> columns = new HashMap<>();
|
||||
private final Map<Long, Integer> coarse = new HashMap<>();
|
||||
private final Map<Long, Cell> cells = new HashMap<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private final List<Spring> springs = new ArrayList<>();
|
||||
private final Set<Position> springFlow = new HashSet<>();
|
||||
private long samples;
|
||||
|
||||
Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; }
|
||||
|
||||
Plan build() {
|
||||
List<Candidate> candidates = candidates();
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (features.size() >= 5 || attempts++ >= 96) break;
|
||||
Point center = new Point(candidate.x(), candidate.z());
|
||||
if (nearWater(center.x(), center.z(), 38)) continue;
|
||||
Column column = column(center.x(), center.z());
|
||||
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
|
||||
Kind kind = features.size() < 2 ? Kind.LAKE : Kind.POND;
|
||||
int target = kind == Kind.LAKE ? 190 + (int) (random(seed, center.x(), center.z()) * 130)
|
||||
: 55 + (int) (random(seed + 1, center.x(), center.z()) * 55);
|
||||
// Prefer an existing low spot; a shallow cut is the fallback on level terrain.
|
||||
for (int level : new int[]{column.top() + 1, column.top(), column.top() - 1}) {
|
||||
Set<Long> footprint = growBasin(center, level, target);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, kind, level, List.of(center), wet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// One quiet stream connects a basin to a nearby surface refuge at the same water level.
|
||||
// Its route follows low excavation cost through the relief; it has no falling outlets.
|
||||
boolean stream = false;
|
||||
for (Feature source : List.copyOf(features)) if (addStream(source)) { stream = true; break; }
|
||||
if (!stream) addPlateauStream(candidates);
|
||||
addSedimentSheets();
|
||||
addSprings();
|
||||
return new Plan(cells, features, springs, springFlow, columns.size(), samples);
|
||||
}
|
||||
|
||||
private List<Candidate> candidates() {
|
||||
for (int x = -RADIUS; x <= RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS; z <= RADIUS; z += GRID) {
|
||||
if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z));
|
||||
}
|
||||
}
|
||||
List<Candidate> result = new ArrayList<>();
|
||||
for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) {
|
||||
int height = coarse.getOrDefault(key(x, z), -1);
|
||||
if (height < 64 || !inside(x, z, RADIUS - 24)) continue;
|
||||
double average = 0;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1);
|
||||
if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false;
|
||||
average += neighbor * 0.25;
|
||||
}
|
||||
if (safe) result.add(new Candidate(x, z,
|
||||
(average - height) * 2.0 + random(seed + 2, x, z) * 3.0));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
return result;
|
||||
}
|
||||
|
||||
private int coarseTop(int x, int z) {
|
||||
for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private Column column(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Column old = columns.get(key);
|
||||
if (old != null) return old;
|
||||
int top = -1;
|
||||
// Exact scan, including everything above the planned water: a cave roof cannot be
|
||||
// mistaken for a surface. Only candidates and their nearby shore need this refinement.
|
||||
for (int y = 383; y >= 24; y--) {
|
||||
if (solid(x, y, z)) { top = y; break; }
|
||||
}
|
||||
int bottom = top;
|
||||
if (top >= 0) {
|
||||
while (bottom > Math.max(16, top - 16) && solid(x, bottom - 1, z)) bottom--;
|
||||
}
|
||||
Column created = new Column(top, bottom);
|
||||
columns.put(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private boolean eligible(int x, int z, int level) {
|
||||
if (level < 32 || level >= 383 || !inside(x, z, RADIUS - 2)) return false;
|
||||
Cell old = cells.get(key(x, z));
|
||||
if (old != null && old.hasWater() && old.waterY() != level) return false;
|
||||
Column column = column(x, z);
|
||||
return column.top() >= level - 3 && column.top() < level + MAX_CARVE
|
||||
&& column.solidBottom() <= Math.min(column.top(), level - 1) - 4;
|
||||
}
|
||||
|
||||
private Set<Long> growBasin(Point center, int level, int target) {
|
||||
if (!eligible(center.x(), center.z(), level)) return null;
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
queue.add(new Node(center.x(), center.z(), 0));
|
||||
costs.put(key(center.x(), center.z()), 0.0);
|
||||
while (!queue.isEmpty() && wet.size() < target) {
|
||||
Node current = queue.remove();
|
||||
if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
if (!eligible(current.x(), current.z(), level)) continue;
|
||||
wet.add(key(current.x(), current.z()));
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = current.x() + d[0], z = current.z() + d[1];
|
||||
if (Math.hypot(x - center.x(), z - center.z()) > 28 || wet.contains(key(x, z))) continue;
|
||||
if (!eligible(x, z, level)) continue;
|
||||
double step = Math.hypot(d[0], d[1]);
|
||||
double cost = current.cost() + step * (1 + Math.max(0, column(x, z).top() - level) * 0.8
|
||||
+ terrainNoise(x, z) * 4.5 + field(x + 71, z - 93, 21, 107) * 3.5);
|
||||
if (cost >= costs.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
|
||||
costs.put(key(x, z), cost);
|
||||
queue.add(new Node(x, z, cost));
|
||||
}
|
||||
}
|
||||
wet = connectedTo(wet, key(center.x(), center.z()));
|
||||
if (wet.size() < Math.min(target, 30)) return null;
|
||||
// Filling a hollow requires every lower connected outlet to be included. Abort rather
|
||||
// than place a dam if this natural basin runs to a cliff, cave or an oversized catchment.
|
||||
List<Long> work = new ArrayList<>(wet);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long neighbor = key(x, z);
|
||||
if (wet.contains(neighbor) || column(x, z).top() >= level) continue;
|
||||
if (wet.size() >= 420 || Math.hypot(x - center.x(), z - center.z()) > 32
|
||||
|| !eligible(x, z, level)) return null;
|
||||
wet.add(neighbor);
|
||||
work.add(neighbor);
|
||||
}
|
||||
}
|
||||
return wet;
|
||||
}
|
||||
|
||||
private Map<Long, Cell> waterCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Cell> result = new HashMap<>();
|
||||
for (long key : footprint) {
|
||||
Point p = point(key);
|
||||
if (!eligible(p.x(), p.z(), level)) return null;
|
||||
boolean edge = false;
|
||||
for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1]));
|
||||
Column column = column(p.x(), p.z());
|
||||
int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0));
|
||||
depth = Math.min(depth, MAX_CARVE - Math.max(0, column.top() - level));
|
||||
if (depth < 1 || depth > MAX_DEPTH) return null;
|
||||
int bed = level - depth;
|
||||
if (column.top() < bed || column.solidBottom() > bed - 2) return null;
|
||||
ShoreMaterial material = wetMaterial(p.x(), p.z(), edge);
|
||||
int sedimentDepth = sedimentDepth(p.x(), p.z());
|
||||
if (column.solidBottom() > bed - sedimentDepth - 1) return null;
|
||||
result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()),
|
||||
material, id, sedimentDepth));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean contained(Map<Long, Cell> wet) {
|
||||
for (Cell cell : wet.values()) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
if (!solid(cell.x(), y, cell.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
Cell neighbor = wet.get(key(x, z));
|
||||
if (neighbor == null) neighbor = cells.get(key(x, z));
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
if (neighbor != null && neighbor.hasWater()) {
|
||||
if (neighbor.waterY() != cell.waterY()) return false;
|
||||
if (y > neighbor.bedY()) continue;
|
||||
}
|
||||
if (!solid(x, y, z)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addFeature(long id, Kind kind, int level, List<Point> path, Map<Long, Cell> wet) {
|
||||
int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
int count = 0;
|
||||
for (Cell cell : wet.values()) {
|
||||
Cell old = cells.get(key(cell.x(), cell.z()));
|
||||
if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; }
|
||||
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
|
||||
maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z());
|
||||
}
|
||||
features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ)));
|
||||
}
|
||||
|
||||
private boolean addStream(Feature source) {
|
||||
Point start = source.path().getFirst();
|
||||
int level = source.waterY();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
Map<Long, Long> parent = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
List<Node> refuges = new ArrayList<>();
|
||||
long startKey = key(start.x(), start.z());
|
||||
costs.put(startKey, 0.0);
|
||||
queue.add(new Node(start.x(), start.z(), 0));
|
||||
int visited = 0;
|
||||
while (!queue.isEmpty() && visited++ < 1200) {
|
||||
Node node = queue.remove();
|
||||
long at = key(node.x(), node.z());
|
||||
if (node.cost() > costs.getOrDefault(at, Double.POSITIVE_INFINITY)) continue;
|
||||
double distance = Math.hypot(node.x() - start.x(), node.z() - start.z());
|
||||
if (distance >= 44 && distance <= 76 && column(node.x(), node.z()).top() <= level + 3) refuges.add(node);
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = node.x() + d[0] * 4, z = node.z() + d[1] * 4;
|
||||
if (Math.hypot(x - start.x(), z - start.z()) > 88 || !eligible(x, z, level)) continue;
|
||||
long next = key(x, z);
|
||||
double cost = node.cost() + Math.hypot(d[0], d[1])
|
||||
* (4 + Math.abs(column(x, z).top() - level) * 1.4 + terrainNoise(x, z) * 4);
|
||||
if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue;
|
||||
costs.put(next, cost); parent.put(next, at); queue.add(new Node(x, z, cost));
|
||||
}
|
||||
}
|
||||
refuges.sort(Comparator.comparingDouble((Node n) -> n.cost() / Math.hypot(n.x() - start.x(), n.z() - start.z()))
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z));
|
||||
int attempts = 0;
|
||||
for (Node refuge : refuges) {
|
||||
if (attempts++ >= 24) break;
|
||||
List<Point> path = new ArrayList<>();
|
||||
long cursor = key(refuge.x(), refuge.z());
|
||||
while (cursor != startKey) { path.add(point(cursor)); cursor = parent.get(cursor); }
|
||||
path.add(start); Collections.reverse(path);
|
||||
Set<Long> corridor = new HashSet<>();
|
||||
for (int i = 1; i < path.size(); i++) {
|
||||
Point a = path.get(i - 1), b = path.get(i);
|
||||
int length = Math.max(Math.abs(b.x() - a.x()), Math.abs(b.z() - a.z()));
|
||||
for (int step = 0; step <= length; step++) {
|
||||
int x = a.x() + (b.x() - a.x()) * step / length;
|
||||
int z = a.z() + (b.z() - a.z()) * step / length;
|
||||
int width = terrainNoise(x + 17, z + 29) > 0.65 ? 2 : 1;
|
||||
for (int dx = -width; dx <= width; dx++) for (int dz = -width; dz <= width; dz++) {
|
||||
if (dx * dx + dz * dz <= width * width + 1) corridor.add(key(x + dx, z + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<Long> refugePool = growBasin(new Point(refuge.x(), refuge.z()), level, 36);
|
||||
if (refugePool == null) continue;
|
||||
corridor.addAll(refugePool);
|
||||
if (!closeStreamOutlets(corridor, level)) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(corridor, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, Kind.STREAM, level, path, wet);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Low lake floors may be trapped below every nearby plateau. Try a small natural surface
|
||||
* refuge as a source too, retaining it only if a fully contained stream can actually leave it. */
|
||||
private boolean addPlateauStream(List<Candidate> candidates) {
|
||||
List<Candidate> plateaus = candidates.stream().filter(candidate -> flatness(candidate.x(), candidate.z()) <= 6)
|
||||
.sorted(Comparator.comparingDouble((Candidate c) -> flatness(c.x(), c.z())
|
||||
- random(seed + 91, c.x(), c.z()) * 2).thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)).toList();
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : plateaus) {
|
||||
if (attempts++ >= 20) break;
|
||||
Point center = new Point(candidate.x(), candidate.z());
|
||||
if (nearWater(center.x(), center.z(), 24)) continue;
|
||||
Column column = column(center.x(), center.z());
|
||||
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
|
||||
for (int level : new int[]{column.top(), column.top() - 1}) {
|
||||
Set<Long> footprint = growBasin(center, level, 40);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, Kind.POND, level, List.of(center), wet);
|
||||
if (addStream(features.getLast())) return true;
|
||||
features.removeLast();
|
||||
cells.values().removeIf(cell -> cell.featureId() == id);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private int flatness(int x, int z) {
|
||||
int low = coarse.getOrDefault(key(x, z), -1000), high = low;
|
||||
for (int[] d : CARDINALS) {
|
||||
int value = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1000);
|
||||
low = Math.min(low, value); high = Math.max(high, value);
|
||||
}
|
||||
return high - low;
|
||||
}
|
||||
|
||||
/** Include small connected low spots beside a channel; reject large/deep/open outlets.
|
||||
* This adds water to a verified natural hollow, never rock to close a leak. */
|
||||
private boolean closeStreamOutlets(Set<Long> corridor, int level) {
|
||||
int maximum = Math.min(900, corridor.size() + 180);
|
||||
List<Long> work = new ArrayList<>(corridor);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long next = key(x, z);
|
||||
Cell old = cells.get(next);
|
||||
if (corridor.contains(next) || old != null && old.hasWater() && old.waterY() == level
|
||||
|| column(x, z).top() >= level) continue;
|
||||
if (corridor.size() >= maximum || !eligible(x, z, level)) return false;
|
||||
corridor.add(next); work.add(next);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Set<Long> connectedTo(Set<Long> footprint, long start) {
|
||||
Set<Long> connected = new HashSet<>();
|
||||
List<Long> work = new ArrayList<>();
|
||||
connected.add(start); work.add(start);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
long next = key(point.x() + d[0], point.z() + d[1]);
|
||||
if (footprint.contains(next) && connected.add(next)) work.add(next);
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
private record ShoreNode(int x, int z, int level, long feature, double cost) {}
|
||||
|
||||
/** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby
|
||||
* pocket can share the same sheet; the result is not a one-block material ring. */
|
||||
private void addSedimentSheets() {
|
||||
Comparator<ShoreNode> order = Comparator.comparingDouble(ShoreNode::cost)
|
||||
.thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature);
|
||||
PriorityQueue<ShoreNode> queue = new PriorityQueue<>(order);
|
||||
Map<Long, Double> best = new HashMap<>();
|
||||
for (Cell cell : cells.values()) if (cell.hasWater()) {
|
||||
queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0));
|
||||
best.put(key(cell.x(), cell.z()), 0.0);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
ShoreNode node = queue.remove();
|
||||
if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
Column column = column(node.x(), node.z());
|
||||
Cell existing = cells.get(key(node.x(), node.z()));
|
||||
if (existing == null) {
|
||||
double extent = 9 + field(node.x(), node.z(), 25, 131) * 10;
|
||||
if (node.cost() > extent) continue;
|
||||
int delta = column.top() - node.level();
|
||||
if (delta < 0 || delta > 5) continue;
|
||||
// Low shore: waterline, then one-block steps. Cut at most two existing blocks;
|
||||
// far deposits keep the original landform and feather back into grass/stone.
|
||||
int terrace = node.level() + (int) Math.floor(node.cost() / 4.5);
|
||||
int bed = Math.max(node.level(), Math.max(column.top() - 2, Math.min(column.top(), terrace)));
|
||||
int depth = sedimentDepth(node.x(), node.z());
|
||||
if (column.solidBottom() > bed - depth - 1) continue;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
Cell next = cells.get(key(node.x() + d[0], node.z() + d[1]));
|
||||
if (next != null && next.hasWater() && bed < next.waterY()) safe = false;
|
||||
}
|
||||
if (!safe) continue;
|
||||
ShoreMaterial material = dryMaterial(node.x(), node.z(), node.cost() / extent);
|
||||
cells.put(key(node.x(), node.z()), new Cell(node.x(), node.z(), -1, bed,
|
||||
column.top(), material, node.feature(), depth));
|
||||
}
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = node.x() + d[0], z = node.z() + d[1];
|
||||
if (!inside(x, z, RADIUS - 2)) continue;
|
||||
Cell water = cells.get(key(x, z));
|
||||
if (water != null && water.hasWater()) continue;
|
||||
Column next = column(x, z);
|
||||
if (next.top() < node.level() || next.top() > node.level() + 5) continue;
|
||||
double cost = node.cost() + Math.hypot(d[0], d[1])
|
||||
* (0.7 + Math.abs(next.top() - column.top()) * 0.32 + field(x, z, 12, 211) * 0.6);
|
||||
if (cost > 19 || cost >= best.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
|
||||
best.put(key(x, z), cost);
|
||||
queue.add(new ShoreNode(x, z, node.level(), node.feature(), cost));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int sedimentDepth(int x, int z) { return 3 + (int) (field(x, z, 15, 319) * 2.999); }
|
||||
|
||||
private ShoreMaterial wetMaterial(int x, int z, boolean edge) {
|
||||
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
|
||||
if (geology < 0.29) return ShoreMaterial.STONE;
|
||||
if (geology < 0.50) return ShoreMaterial.GRAVEL;
|
||||
if (!edge && geology > 0.78) return ShoreMaterial.CLAY;
|
||||
return ShoreMaterial.SAND;
|
||||
}
|
||||
|
||||
private ShoreMaterial dryMaterial(int x, int z, double distance) {
|
||||
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
|
||||
double grass = field(x + 103, z - 137, 19, 521);
|
||||
if (grass > 0.68 || distance > 0.60 + grass * 0.65) return ShoreMaterial.GRASS;
|
||||
if (geology < 0.29) return ShoreMaterial.STONE;
|
||||
if (geology < 0.50) return ShoreMaterial.GRAVEL;
|
||||
return ShoreMaterial.SAND;
|
||||
}
|
||||
|
||||
private record Cliff(int x, int z, int dx, int dz, double score) {}
|
||||
private record FlowNode(Position position, int reach) {}
|
||||
private record Flow(Set<Position> volume, List<Position> path, FlowBounds bounds) {}
|
||||
|
||||
/** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces
|
||||
* one rock block under an existing roof. Its outlet is already air over an eight-block drop;
|
||||
* no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */
|
||||
private void addSprings() {
|
||||
List<Cliff> cliffs = new ArrayList<>();
|
||||
for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) {
|
||||
if (!inside(x, z, 264)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top < 88) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
int nx = x + d[0] * GRID, nz = z + d[1] * GRID;
|
||||
int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top - lower < 16) continue;
|
||||
cliffs.add(new Cliff(x, z, d[0], d[1],
|
||||
Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3));
|
||||
}
|
||||
}
|
||||
cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed()
|
||||
.thenComparingInt(Cliff::x).thenComparingInt(Cliff::z)
|
||||
.thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz));
|
||||
int attempts = 0;
|
||||
for (Cliff cliff : cliffs) {
|
||||
if (springs.size() >= 3 || attempts++ >= 160) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2, -4, 4}) {
|
||||
for (int along = 0; along < GRID; along++) {
|
||||
int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent;
|
||||
int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent;
|
||||
if (!inside(x, z, 266) || nearModifiedGround(x, z, 32)) continue;
|
||||
int ox = x + cliff.dx(), oz = z + cliff.dz();
|
||||
Column rock = column(x, z), outletColumn = column(ox, oz);
|
||||
if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue;
|
||||
// Five blocks below the highest natural surface is below the soil cap.
|
||||
int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8));
|
||||
for (int y = rock.top() - 5; y >= floor; y -= 3) {
|
||||
Position source = new Position(x, y, z), outlet = new Position(ox, y, oz);
|
||||
if (!separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz())) continue;
|
||||
Flow flow = traceSpring(source, outlet);
|
||||
if (flow == null || flow.bounds().minY() > y - 12) continue;
|
||||
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
|
||||
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
|
||||
springFlow.addAll(flow.volume());
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean nearModifiedGround(int x, int z, int distance) {
|
||||
for (Cell cell : cells.values()) if (Math.hypot(x - cell.x(), z - cell.z()) < distance) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean separatedSpring(Position source) {
|
||||
for (Spring spring : springs) {
|
||||
if (Math.hypot(source.x() - spring.source().x(), source.z() - spring.source().z()) < 64
|
||||
|| Math.abs(source.y() - spring.source().y()) < 12) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean springNiche(Position source, int dx, int dz) {
|
||||
for (int y = source.y() - 3; y <= source.y() + 1; y++) {
|
||||
if (!solid(source.x(), y, source.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
if (d[0] == dx && d[1] == dz) continue;
|
||||
if (!solid(source.x() + d[0], source.y(), source.z() + d[1])) return false;
|
||||
}
|
||||
for (int y = source.y(); y >= source.y() - 8; y--) {
|
||||
if (solid(source.x() + dx, y, source.z() + dz)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Predict drainage through undecorated rock to select springs and inspection bounds.
|
||||
* Descend through air first, then explore seven horizontal steps on a shelf. Later trees
|
||||
* can divert the actual fluid: this prediction is not a bound on decorated-world physics.
|
||||
* Integration tests trace actual water back to its source within the inspected chunk halo. */
|
||||
private Flow traceSpring(Position source, Position outlet) {
|
||||
Map<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<FlowNode> queue = new ArrayDeque<>();
|
||||
reach.put(source, 7);
|
||||
queue.add(new FlowNode(source, 7));
|
||||
Position lowest = source;
|
||||
while (!queue.isEmpty()) {
|
||||
FlowNode node = queue.removeFirst();
|
||||
Position at = node.position();
|
||||
if (node.reach() < reach.getOrDefault(at, -1)) continue;
|
||||
if (at.y() < lowest.y()) lowest = at;
|
||||
if (reach.size() > 24000) return null;
|
||||
if (at.y() <= 0) continue;
|
||||
Position below = new Position(at.x(), at.y() - 1, at.z());
|
||||
if (!flowSolid(below, source)) {
|
||||
enqueueFlow(below, 7, at, queue, reach, parent);
|
||||
continue;
|
||||
}
|
||||
if (node.reach() <= 0) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
|
||||
if (flowSolid(next, source)) continue;
|
||||
enqueueFlow(next, node.reach() - 1, at, queue, reach, parent);
|
||||
}
|
||||
}
|
||||
if (!reach.containsKey(outlet)) return null;
|
||||
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY;
|
||||
int minZ = source.z(), maxZ = minZ;
|
||||
for (Position position : reach.keySet()) {
|
||||
minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x());
|
||||
minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y());
|
||||
minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z());
|
||||
}
|
||||
List<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Position source) {
|
||||
if (at.equals(source)) return false;
|
||||
Cell cell = cells.get(key(at.x(), at.z()));
|
||||
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
|
||||
return solid(at.x(), at.y(), at.z());
|
||||
}
|
||||
|
||||
private static void enqueueFlow(Position next, int available, Position from, ArrayDeque<FlowNode> queue,
|
||||
Map<Position, Integer> reach, Map<Position, Position> parent) {
|
||||
if (available <= reach.getOrDefault(next, -1)) return;
|
||||
if (!reach.containsKey(next)) parent.put(next, from);
|
||||
reach.put(next, available);
|
||||
queue.addLast(new FlowNode(next, available));
|
||||
}
|
||||
|
||||
private boolean nearWater(int x, int z, int distance) {
|
||||
for (Feature feature : features) {
|
||||
Point point = feature.path().getFirst();
|
||||
if (Math.hypot(x - point.x(), z - point.z()) < distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; }
|
||||
private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); }
|
||||
|
||||
private double terrainNoise(int x, int z) { return field(x, z, 8, 31); }
|
||||
|
||||
private double field(int x, int z, int scale, long salt) {
|
||||
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
|
||||
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
|
||||
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
|
||||
double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx;
|
||||
double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, gx + 1, gz + 1) * tx;
|
||||
return a * (1 - tz) + b * tz;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static Point point(long key) { return new Point((int) (key >> 32), (int) key); }
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; }
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Alpha.5 only: immutable plans cached by world state, applied to fresh terrain before decoration. */
|
||||
public final class NaturalHydrologyRuntime {
|
||||
private static final Map<RandomState, NaturalHydrology.Plan> PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private static final Map<RandomState, StarterLavaDeposit.Plan> LAVA_PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private NaturalHydrologyRuntime() {}
|
||||
|
||||
public static boolean enabled(NoiseBasedChunkGenerator generator) {
|
||||
return generator.generatorSettings().is(SanctuarySpawn.NATURAL_SETTINGS);
|
||||
}
|
||||
|
||||
public static NaturalHydrology.Plan naturalPlan(ServerLevel level) {
|
||||
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static NaturalHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
if (!enabled(generator)) throw new IllegalArgumentException("Natural hydrology is not enabled for this generator");
|
||||
return PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var plan = NaturalHydrology.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
|
||||
SanctuaryMod.LOGGER.info("Sanctuary natural hydrology: seed {}, {} features, {} columns, {} springs, {} ms",
|
||||
state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
public static StarterLavaDeposit.Plan lavaPlan(ServerLevel level) {
|
||||
return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static StarterLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
// Acquire the water plan before the lava cache lock; the water planner never acquires it.
|
||||
var water = plan(generator, randomState);
|
||||
return LAVA_PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var lava = StarterLavaDeposit.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms",
|
||||
state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000);
|
||||
return lava;
|
||||
});
|
||||
}
|
||||
|
||||
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
|
||||
if (!enabled(generator)) return;
|
||||
var chunkPos = chunk.getPos();
|
||||
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|
||||
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
|
||||
var plan = plan(generator, randomState);
|
||||
var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z());
|
||||
if (cells.isEmpty() && springs.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
// Validate all replacements and two intact support layers before modifying this chunk.
|
||||
for (var cell : cells) {
|
||||
for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
|
||||
requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed());
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed());
|
||||
}
|
||||
for (var support : lavaSupports) {
|
||||
requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed());
|
||||
if (chunk.getBlockState(pos).ignitedByLava()) {
|
||||
throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos);
|
||||
}
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed());
|
||||
}
|
||||
for (var cell : cells) {
|
||||
for (int depth = 0; depth < cell.sedimentDepth(); depth++) {
|
||||
pos.set(cell.x(), cell.bedY() - depth, cell.z());
|
||||
BlockState material = switch (cell.material()) {
|
||||
case SAND -> Blocks.SAND.defaultBlockState();
|
||||
case GRAVEL -> Blocks.GRAVEL.defaultBlockState();
|
||||
case CLAY -> Blocks.CLAY.defaultBlockState();
|
||||
case STONE -> Blocks.STONE.defaultBlockState();
|
||||
case GRASS -> (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState();
|
||||
};
|
||||
chunk.setBlockState(pos, material, 0);
|
||||
}
|
||||
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY()
|
||||
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
|
||||
if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
pos.set(source.x(), source.y(), source.z());
|
||||
chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0);
|
||||
// Only the source is generated. Vanilla fluid ticks create the descending waterfall.
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
pos.set(cell.x(), cell.y(), cell.z());
|
||||
chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0);
|
||||
if (cell.lava()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
|
||||
private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) {
|
||||
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
|
||||
throw new IllegalStateException("Natural hydrology differs from terrain at " + pos + " for seed " + seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
|
||||
/** A few plants on existing suitable shores, after the forest has been decorated. */
|
||||
public final class NaturalShoreSugarCaneFeature implements Feature {
|
||||
public static final MapCodec<NaturalShoreSugarCaneFeature> CODEC = MapCodec.unit(NaturalShoreSugarCaneFeature::new);
|
||||
|
||||
@Override
|
||||
public MapCodec<NaturalShoreSugarCaneFeature> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !NaturalHydrologyRuntime.enabled(noise)) return false;
|
||||
var plan = NaturalHydrologyRuntime.plan(noise, level.getLevel().getChunkSource().randomState());
|
||||
var cane = Blocks.SUGAR_CANE.defaultBlockState();
|
||||
boolean placed = false;
|
||||
for (var cell : plan.cellsInChunk(origin.getX() >> 4, origin.getZ() >> 4)) {
|
||||
if (cell.hasWater() || random.nextInt(7) != 0) continue;
|
||||
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
|
||||
if (!level.getBlockState(base).isAir() || !cane.canSurvive(level, base)) continue;
|
||||
int height = 1 + random.nextInt(3);
|
||||
for (int y = 0; y < height; y++) {
|
||||
BlockPos pos = base.above(y);
|
||||
if (!level.getBlockState(pos).isAir() || !cane.canSurvive(level, pos)) break;
|
||||
setBlock(level, pos, cane);
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+297
@@ -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<PopulationDecorationsFeature> CODEC = MapCodec.unit(PopulationDecorationsFeature::new);
|
||||
private static final ResourceKey<Feature> 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<PopulationDecorationsFeature> codec() { return CODEC; }
|
||||
|
||||
/** Pure, bounded probes for development diagnostics; no chunk loads or successful-placement promise. */
|
||||
public static List<Candidate> 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<Candidate> 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<BlockPos, BlockState> 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<BlockPos, BlockState> 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<BlockPos> geodeEntrance(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler,
|
||||
BlockPos start, BlockPos origin, Map<BlockPos, BlockState> before) {
|
||||
var previous = new java.util.HashMap<BlockPos, BlockPos>();
|
||||
var distances = new java.util.HashMap<BlockPos, Integer>();
|
||||
var queue = new java.util.ArrayDeque<BlockPos>();
|
||||
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<BlockPos>();
|
||||
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<BlockPos, BlockState> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<PopulationGrovesFeature> CODEC = MapCodec.unit(PopulationGrovesFeature::new);
|
||||
private static final Map<RandomState, Optional<RemarkableTree>> 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<Feature> 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<PopulationGrovesFeature> codec() { return CODEC; }
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(ServerLevel level) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty();
|
||||
return remarkablePlan(noise, level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static Optional<RemarkableTree> 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<Candidate>();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+240
@@ -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<RandomState, Map<IslandCapacity.Region, RegionPlan>> 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<IslandCapacity.Region, RegionPlan> regions(RandomState state) {
|
||||
synchronized (WORLDS) { return WORLDS.computeIfAbsent(state, ignored -> new LinkedHashMap<>()); }
|
||||
}
|
||||
public static int cachedRegionCount(RandomState state) {
|
||||
Map<IslandCapacity.Region, RegionPlan> 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<RegionPlan> 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<RegionPlan> 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<PopulationHydrology.Cell> 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<PopulationHydrology.Cell> 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<Long, long[]> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -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<Biome> OAK_FOREST = key("population_oak_forest");
|
||||
public static final ResourceKey<Biome> BIRCH_FOREST = key("population_birch_forest");
|
||||
public static final ResourceKey<Biome> CLEARING = key("population_clearing");
|
||||
public static final ResourceKey<Biome> DRY_WOODLAND = key("population_dry_woodland");
|
||||
public static final ResourceKey<Biome> ROCKY_HEATH = key("population_rocky_heath");
|
||||
public static final ResourceKey<Biome> DARK_GROVE = key("population_dark_grove");
|
||||
public static final ResourceKey<Biome> BAMBOO_GROVE = key("population_bamboo_grove");
|
||||
public static final ResourceKey<Biome> SULFUR_DEPTHS = key("population_sulfur_depths");
|
||||
public static final ResourceKey<Biome> LUSH_CAVES = key("population_lush_caves");
|
||||
public static final ResourceKey<Biome> DRIPSTONE_CAVES = key("population_dripstone_caves");
|
||||
|
||||
public static final MapCodec<PopulationIslandBiomeSource> 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<Biome> oakForest;
|
||||
private final Holder<Biome> birchForest;
|
||||
private final Holder<Biome> clearing;
|
||||
private final Holder<Biome> dryWoodland;
|
||||
private final Holder<Biome> rockyHeath;
|
||||
private final Holder<Biome> darkGrove;
|
||||
private final Holder<Biome> bambooGrove;
|
||||
private final Holder<Biome> sulfurDepths;
|
||||
private final Holder<Biome> lushCaves;
|
||||
private final Holder<Biome> dripstoneCaves;
|
||||
|
||||
public PopulationIslandBiomeSource(Holder<Biome> oakForest, Holder<Biome> birchForest, Holder<Biome> clearing,
|
||||
Holder<Biome> dryWoodland, Holder<Biome> rockyHeath, Holder<Biome> darkGrove,
|
||||
Holder<Biome> bambooGrove, Holder<Biome> sulfurDepths, Holder<Biome> lushCaves, Holder<Biome> 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<PopulationIslandBiomeSource> codec() { return CODEC; }
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> 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<Biome> key(String path) {
|
||||
return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path));
|
||||
}
|
||||
}
|
||||
@@ -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<PopulationIslandDensity> 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<IslandCapacity.Region, RiftShape.Layout>();
|
||||
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<PopulationIslandDensity> codec() { return CODEC; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> POSITION_ORDER = Comparator.comparingInt(Position::x)
|
||||
.thenComparingInt(Position::z).thenComparingInt(Position::y);
|
||||
private static final Comparator<Cell> CELL_ORDER = Comparator.comparingInt(Cell::x)
|
||||
.thenComparingInt(Cell::z).thenComparingInt(Cell::y);
|
||||
|
||||
private 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<Cell> cells;
|
||||
private final List<Cell> lavaCells;
|
||||
private final List<Position> supports;
|
||||
private final Optional<Position> access;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final Map<Long, List<Position>> supportChunks;
|
||||
private final List<PopulationLavaFlows.Fall> falls;
|
||||
private final Map<Long, List<PopulationLavaFlows.Fall>> fallChunks;
|
||||
private final List<Cell> pocketCells;
|
||||
private final Set<Position> pocketSupports;
|
||||
|
||||
private Plan(List<Cell> cells, Set<Position> supports, Position access) {
|
||||
this(cells, supports, access, List.of(), cells, supports);
|
||||
}
|
||||
|
||||
private Plan(List<Cell> cells, Set<Position> supports, Position access, List<PopulationLavaFlows.Fall> falls,
|
||||
List<Cell> pocketCells, Set<Position> pocketSupports) {
|
||||
this.pocketCells = pocketCells.stream().sorted(CELL_ORDER).toList();
|
||||
this.pocketSupports = Set.copyOf(pocketSupports);
|
||||
this.falls = List.copyOf(falls);
|
||||
Map<Long, List<PopulationLavaFlows.Fall>> 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<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : this.cells) byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(cell);
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
chunks = Map.copyOf(byChunk);
|
||||
Map<Long, List<Position>> bySupportChunk = new HashMap<>();
|
||||
for (Position support : this.supports) bySupportChunk.computeIfAbsent(key(support.x() >> 4, support.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(support);
|
||||
bySupportChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
supportChunks = Map.copyOf(bySupportChunk);
|
||||
}
|
||||
|
||||
public List<Cell> cells() { return cells; }
|
||||
public List<Cell> lavaCells() { return lavaCells; }
|
||||
public List<Position> supports() { return supports; }
|
||||
public List<PopulationLavaFlows.Fall> falls() { return falls; }
|
||||
public List<PopulationLavaFlows.Fall> fallsInChunk(int x, int z) { return fallChunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Position> flowSources() { return falls.stream().map(PopulationLavaFlows.Fall::source).toList(); }
|
||||
public List<PopulationHydrology.FlowBounds> 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<Position> access() { return access; }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Position> supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); }
|
||||
|
||||
/** 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<PopulationLavaFlows.Fall> 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<Cell> movedPocket = pocketCells.stream().map(c -> new Cell(Math.addExact(c.x(), dx), c.y(),
|
||||
Math.addExact(c.z(), dz), c.lava())).toList();
|
||||
Set<Position> movedSupports = pocketSupports.stream().map(p -> move(p, dx, dz))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
List<PopulationLavaFlows.Fall> 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<Cell> pocket, Set<Position> support, Position access,
|
||||
List<PopulationLavaFlows.Fall> selectedFalls) {
|
||||
List<Cell> combined = new ArrayList<>(pocket);
|
||||
Set<Position> 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<PopulationLavaFlows.Fall> 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<Long, Integer> 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<Candidate> candidates = new ArrayList<>();
|
||||
for (int x = -184; x <= 184; x += 8) for (int z = -184; z <= 184; z += 8) {
|
||||
if (!inside(x, z, RADIUS - 8)) continue;
|
||||
int ledge = top(x, z);
|
||||
if (ledge < 64 || ledge > 300) continue;
|
||||
for (int[] direction : DIRECTIONS) {
|
||||
int cx = x - direction[0] * ENTRY_DISTANCE, cz = z - direction[1] * ENTRY_DISTANCE;
|
||||
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) continue;
|
||||
int rise = top(cx, cz) - ledge;
|
||||
if (rise < 12) continue;
|
||||
candidates.add(new Candidate(x, z, direction[0], direction[1],
|
||||
random(seed, cx, cz) + Math.min(rise, 40) * 0.015));
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)
|
||||
.thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz));
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (attempts++ >= 192) break;
|
||||
// Refine the coarse edge, including a ledge lying between two eight-block samples.
|
||||
for (int along = 0; along < 8; along++) for (int tangent : new int[]{0, -3, 3}) {
|
||||
int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent;
|
||||
Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz());
|
||||
if (plan != null) return plan;
|
||||
}
|
||||
}
|
||||
return new Plan(List.of(), Set.of(), null);
|
||||
}
|
||||
|
||||
/** 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<LowerCandidate> 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<Cell> cells = new ArrayList<>();
|
||||
Set<Position> changed = new HashSet<>();
|
||||
Set<Position> supports = new HashSet<>();
|
||||
for (int x = cx - 1; x <= cx + 1; x++) for (int z = cz - 1; z <= cz + 1; z++) {
|
||||
for (int y = level - 1; y <= level + 2; y++) {
|
||||
if (!solid(x, y, z)) return null;
|
||||
add(cells, changed, x, y, z, y <= level);
|
||||
}
|
||||
}
|
||||
// A dry lip separates the ledge from the pool. Only the air above it is opened.
|
||||
for (int distance = 2; distance < ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level + 1; y <= level + 2; y++) {
|
||||
if (solid(x, y, z)) add(cells, changed, x, y, z, false);
|
||||
}
|
||||
}
|
||||
// Every carved column must lie under eight continuous natural rock blocks. This
|
||||
// excludes soil-cap excavation at the entrance as well as exposed surface lava.
|
||||
Map<Long, Integer> highest = new HashMap<>();
|
||||
for (Cell cell : cells) highest.merge(key(cell.x(), cell.z()), cell.y(), Math::max);
|
||||
for (var column : highest.entrySet()) {
|
||||
int x = (int) (column.getKey() >> 32), z = (int) (long) column.getKey();
|
||||
for (int y = column.getValue() + 1; y <= column.getValue() + 8; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// Three intact blocks under the pool, two-block-thick side walls, and a solid chamber
|
||||
// surround prevent escape through an adjacent cave. No part of this shell is created.
|
||||
for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 3; z <= cz + 3; z++) {
|
||||
for (int y = level - 4; y <= level + 2; y++) {
|
||||
if (changed.contains(new Position(x, y, z))) continue;
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// The natural roof also covers a conservative envelope around local lava fire spread.
|
||||
// Its thickness keeps later surface vegetation well above the lava chamber.
|
||||
for (int x = cx - 4; x <= cx + 4; x++) for (int z = cz - 4; z <= cz + 4; z++) {
|
||||
for (int y = level + 3; y <= level + 10; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
for (int distance = 2; distance <= ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level - 2; y <= level; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
supports.removeAll(changed);
|
||||
return new Plan(cells, supports, new Position(entryX, level + 1, entryZ));
|
||||
}
|
||||
|
||||
private boolean requireRock(Set<Position> supports, int x, int y, int z) {
|
||||
if (!solid(x, y, z)) return false;
|
||||
supports.add(new Position(x, y, z));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean clearOfWater(int x, int z) {
|
||||
// The extra ten blocks include both the entrance and the complete protected shell.
|
||||
int clearance = WATER_CLEARANCE + 10;
|
||||
for (var cell : hydrology.cells()) {
|
||||
long dx = x - cell.x(), dz = z - cell.z();
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
for (var spring : hydrology.springs()) {
|
||||
var bounds = spring.flowBounds();
|
||||
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
|
||||
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
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<Cell> cells, Set<Position> changed, int x, int y, int z, boolean lava) {
|
||||
if (changed.add(new Position(x, y, z))) cells.add(new Cell(x, y, z, lava));
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return (long) x * x + (long) z * z < (long) radius * radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static double random(long seed, int x, int z) {
|
||||
long value = seed ^ key(x, z) ^ 0x1A7A5EEDL;
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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> 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<Position> flowPath,
|
||||
PopulationHydrology.FlowBounds flowBounds, List<Position> supports) {
|
||||
public Fall { flowPath = List.copyOf(flowPath); supports = List.copyOf(supports); }
|
||||
}
|
||||
|
||||
public static List<Fall> 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<Position> 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<Fall> 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<Fall> find() {
|
||||
List<Candidate> 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<Position> 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<Position> niche(Position source, int dx, int dz) {
|
||||
if (!solid(source)) return null;
|
||||
Set<Position> 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<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<Node> 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<Position> 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<Position, Integer> reach,
|
||||
Map<Position, Position> parent, ArrayDeque<Node> 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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<PopulationShoreSugarCaneFeature> CODEC = MapCodec.unit(PopulationShoreSugarCaneFeature::new);
|
||||
|
||||
@Override
|
||||
public MapCodec<PopulationShoreSugarCaneFeature> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<PopulationHydrology.Position> sources, List<PopulationHydrology.Position> outlets) {
|
||||
public static final Pending EMPTY = new Pending(List.of(), List.of());
|
||||
public Pending { sources = List.copyOf(sources); outlets = List.copyOf(outlets); }
|
||||
public boolean isEmpty() { return sources.isEmpty() && outlets.isEmpty(); }
|
||||
}
|
||||
|
||||
private 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<PopulationHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
|
||||
for (var spring : plan.springs()) {
|
||||
if (markedHere(chunk, spring.source())) sources.add(spring.source());
|
||||
if (markedHere(chunk, spring.outlet())) outlets.add(spring.outlet());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Cell> cells, List<Feature> features, List<Terrace> terraces,
|
||||
List<Spill> spills, Set<Position> 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<Position> volume, List<Position> path, FlowBounds bounds) {}
|
||||
private record Pool(Map<Long, Cell> 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<Position, Floor> floors = new HashMap<>();
|
||||
private final List<Cell> acceptedCells = new ArrayList<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private final List<Terrace> terraces = new ArrayList<>();
|
||||
private final List<Spill> spills = new ArrayList<>();
|
||||
private final Set<Position> predicted = new HashSet<>();
|
||||
|
||||
Search(long seed, Sampler sampler, PopulationHydrology.Plan surface) {
|
||||
this.seed = seed; this.sampler = sampler; this.surface = surface;
|
||||
}
|
||||
|
||||
Result build() {
|
||||
List<Candidate> 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<Position> 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<Cell> 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<Cell> 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<Position> 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<Node> queue = new PriorityQueue<>(Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z));
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
Set<Long> 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<Long, Cell> 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<Cell> cells) {
|
||||
Map<Long, List<Cell>> 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<Cell> group) {
|
||||
Map<Long, List<Cell>> changes = new HashMap<>();
|
||||
Set<Position> 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<Position, Boolean> solidCache = new HashMap<>();
|
||||
Predicate<Position> open = at -> !solidCache.computeIfAbsent(at, p -> flowSolid(p, changes));
|
||||
Predicate<Position> canEnter = at -> !retained.contains(at) && open.test(at);
|
||||
Predicate<Position> hole = at -> open.test(new Position(at.x(), at.y() - 1, at.z()));
|
||||
Map<Position, Integer> reach = new HashMap<>(); Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<FlowNode> 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<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Flow(Set.copyOf(reach.keySet()), List.copyOf(path), new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Map<Long, List<Cell>> 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<Position> downhillChoices(Position at, Predicate<Position> canEnter, Predicate<Position> hole) {
|
||||
List<Position> 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<Position> canEnter, Predicate<Position> 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<Position, Integer> reach,
|
||||
Map<Position, Position> parent, ArrayDeque<FlowNode> 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<Long> connected(Set<Long> footprint, long origin) {
|
||||
if (!footprint.contains(origin)) return Set.of();
|
||||
Set<Long> seen = new HashSet<>(); ArrayDeque<Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
/** Alpha.8 retains the upper woodlands and brings mineral pockets into reachable lower layers. */
|
||||
public final class RiftBiomePalette {
|
||||
public enum Kind {
|
||||
OAK_FOREST, BIRCH_FOREST, CLEARING, DRY_WOODLAND, ROCKY_HEATH,
|
||||
DARK_GROVE, BAMBOO_GROVE, SULFUR_DEPTHS
|
||||
}
|
||||
|
||||
private RiftBiomePalette() {}
|
||||
|
||||
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;
|
||||
// A broad dry mineral province below the inhabited upper layers, with no fluid features.
|
||||
if (elevation < 184 && variation > 0.16F && moisture < 0.35F) return Kind.SULFUR_DEPTHS;
|
||||
// Keep alpha.7's horizontal outcrops; altitude alone never removes the upper forest.
|
||||
if (variation > 0.48F) return Kind.ROCKY_HEATH;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.data.worldgen.features.TreeFeatures;
|
||||
import net.minecraft.data.worldgen.features.VegetationFeatures;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.VegetationBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature;
|
||||
|
||||
/** Alpha.8: 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 RiftGrovesFeature implements Feature {
|
||||
public static final MapCodec<RiftGrovesFeature> CODEC = MapCodec.unit(RiftGrovesFeature::new);
|
||||
private static final Map<RandomState, Optional<RemarkableTree>> 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<Feature> 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<RiftGrovesFeature> codec() { return CODEC; }
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(ServerLevel level) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty();
|
||||
return remarkablePlan(noise, level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) {
|
||||
if (!RiftHydrologyRuntime.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) ->
|
||||
RiftHydrologyRuntime.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<Candidate>();
|
||||
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) || !RiftHydrologyRuntime.enabled(noise)) return false;
|
||||
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
|
||||
if (Math.abs((long) minX) > 272 || Math.abs((long) minZ) > 272) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
Protection protection = (base, radius, height, rootDepth) ->
|
||||
RiftHydrologyRuntime.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(RiftIslandBiomeSource.DARK_GROVE);
|
||||
boolean bamboo = biome.is(RiftIslandBiomeSource.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(RiftIslandBiomeSource.DARK_GROVE) && !biome.is(RiftIslandBiomeSource.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(RiftIslandBiomeSource.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(RiftIslandBiomeSource.DARK_GROVE) || biome.is(RiftIslandBiomeSource.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
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.8 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 RiftHydrology {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int MAX_CARVE = 16;
|
||||
public static final int MAX_DEPTH = 5;
|
||||
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 RiftHydrology() {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Sampler { float sample(int x, int y, int z); }
|
||||
|
||||
public enum Kind { POND, LAKE, RIVER }
|
||||
public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS }
|
||||
public record Point(int x, int z) {}
|
||||
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
|
||||
public record Spring(long id, Position source, Position outlet, List<Position> flowPath, FlowBounds flowBounds) {
|
||||
public Spring { flowPath = List.copyOf(flowPath); }
|
||||
}
|
||||
|
||||
/** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below.
|
||||
* Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry
|
||||
* terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */
|
||||
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
|
||||
ShoreMaterial material, long featureId, int sedimentDepth) {
|
||||
public boolean hasWater() { return waterY >= 0; }
|
||||
}
|
||||
|
||||
public record Feature(long id, Kind kind, int waterY, List<Point> path, int waterCells, Bounds bounds) {
|
||||
public Feature { path = List.copyOf(path); }
|
||||
}
|
||||
|
||||
public static final class Plan {
|
||||
private final Map<Long, Cell> cells;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final List<Feature> features;
|
||||
private final List<Spring> springs;
|
||||
private final Map<Long, List<Spring>> springChunks;
|
||||
private final Set<Position> springFlow;
|
||||
private final int sampledColumns;
|
||||
private final long densitySamples;
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow, int columns, long samples) {
|
||||
List<Cell> sorted = source.values().stream()
|
||||
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
|
||||
Map<Long, Cell> ordered = new LinkedHashMap<>();
|
||||
Map<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : sorted) {
|
||||
ordered.put(key(cell.x(), cell.z()), cell);
|
||||
byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell);
|
||||
}
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
cells = Collections.unmodifiableMap(ordered);
|
||||
chunks = Map.copyOf(byChunk);
|
||||
this.features = List.copyOf(features);
|
||||
this.springs = List.copyOf(springs);
|
||||
this.springFlow = Set.copyOf(springFlow);
|
||||
Map<Long, List<Spring>> groupedSprings = new HashMap<>();
|
||||
for (Spring spring : springs) groupedSprings.computeIfAbsent(
|
||||
key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring);
|
||||
groupedSprings.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
springChunks = Map.copyOf(groupedSprings);
|
||||
sampledColumns = columns;
|
||||
densitySamples = samples;
|
||||
}
|
||||
|
||||
public Cell cellAt(int x, int z) { return cells.get(key(x, z)); }
|
||||
public Collection<Cell> cells() { return cells.values(); }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Feature> features() { return features; }
|
||||
public boolean isRiverCell(Cell cell) { return features.stream().anyMatch(f -> f.kind() == Kind.RIVER && f.id() == cell.featureId()); }
|
||||
public List<Spring> springs() { return springs; }
|
||||
public List<Spring> springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); }
|
||||
/** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */
|
||||
public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); }
|
||||
public int sampledColumns() { return sampledColumns; }
|
||||
public long densitySamples() { return densitySamples; }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, Sampler sampler) {
|
||||
return new Planner(seed, sampler).build();
|
||||
}
|
||||
|
||||
private record Column(int top, int solidBottom) {}
|
||||
private record Candidate(int x, int z, double score) {}
|
||||
private record Node(int x, int z, double cost) {}
|
||||
private static final Comparator<Node> NODE_ORDER = Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z);
|
||||
|
||||
private static final class Planner {
|
||||
private final long seed;
|
||||
private final Sampler sampler;
|
||||
private final Map<Long, Column> columns = new HashMap<>();
|
||||
private final Map<Long, Integer> coarse = new HashMap<>();
|
||||
private final Map<Long, Cell> cells = new HashMap<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private final List<Spring> springs = new ArrayList<>();
|
||||
private final Set<Position> springFlow = new HashSet<>();
|
||||
private long samples;
|
||||
|
||||
Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; }
|
||||
|
||||
Plan build() {
|
||||
List<Candidate> candidates = candidates();
|
||||
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 ? 420 + (int) (random(seed, center.x(), center.z()) * 280)
|
||||
: 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<Long> footprint = growBasin(center, level, target);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, kind, level, List.of(center), wet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
addRiverSpring();
|
||||
addSedimentSheets();
|
||||
addLowerWallSprings();
|
||||
addSprings();
|
||||
return new Plan(cells, features, springs, springFlow, columns.size(), samples);
|
||||
}
|
||||
|
||||
/** 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<Integer> heights = coarse.values().stream().filter(y -> y >= 160).sorted().toList();
|
||||
if (heights.isEmpty()) return;
|
||||
Set<Integer> 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<Long> 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<Point> 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<Point> path = smoothRoute(route);
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Set<Long> 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<Long> 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<Long, Cell> 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<Long> growRiverPool(Point center, int level) {
|
||||
Set<Long> result = new HashSet<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
Set<Long> 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<Point> longestRoute(Set<Long> allowed, int level) {
|
||||
Set<Long> remaining = new HashSet<>(allowed);
|
||||
List<Point> longest = List.of();
|
||||
while (!remaining.isEmpty()) {
|
||||
long start = remaining.stream().min(Long::compare).orElseThrow();
|
||||
Map<Long, Long> first = routeTree(start, allowed);
|
||||
remaining.removeAll(first.keySet());
|
||||
if (first.size() < 36) continue;
|
||||
long far = lastKey(first);
|
||||
Map<Long, Long> second = routeTree(far, first.keySet());
|
||||
long end = lastKey(second);
|
||||
Map<Long, Long> shaped = riverRouteTree(far, end, first.keySet(), level);
|
||||
List<Point> 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<Long, Long> riverRouteTree(long start, long end, Set<Long> allowed, int level) {
|
||||
Map<Long, Long> parent = new HashMap<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> 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<Point> smoothRoute(List<Point> coarsePath) {
|
||||
record Smooth(double x, double z) {}
|
||||
List<Smooth> 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<Smooth> 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<Smooth> 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<Point> 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<Long, Long> routeTree(long start, Set<Long> allowed) {
|
||||
Map<Long, Long> parent = new LinkedHashMap<>();
|
||||
ArrayDeque<Long> 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<Long, Long> 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<Cell> 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<Candidate> candidates() {
|
||||
for (int x = -RADIUS; x <= RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS; z <= RADIUS; z += GRID) {
|
||||
if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z));
|
||||
}
|
||||
}
|
||||
List<Candidate> result = new ArrayList<>();
|
||||
for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) {
|
||||
int height = coarse.getOrDefault(key(x, z), -1);
|
||||
if (height < 64 || !inside(x, z, RADIUS - 24)) continue;
|
||||
double average = 0;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1);
|
||||
if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false;
|
||||
average += neighbor * 0.25;
|
||||
}
|
||||
if (safe) result.add(new Candidate(x, z,
|
||||
(average - height) * 2.0 + random(seed + 2, x, z) * 3.0));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
return result;
|
||||
}
|
||||
|
||||
private int coarseTop(int x, int z) {
|
||||
for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private Column column(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Column old = columns.get(key);
|
||||
if (old != null) return old;
|
||||
int top = -1;
|
||||
// Exact scan, including everything above the planned water: a cave roof cannot be
|
||||
// mistaken for a surface. Only candidates and their nearby shore need this refinement.
|
||||
for (int y = 383; y >= 24; y--) {
|
||||
if (solid(x, y, z)) { top = y; break; }
|
||||
}
|
||||
int bottom = top;
|
||||
if (top >= 0) {
|
||||
while (bottom > Math.max(16, top - 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<Long> growBasin(Point center, int level, int target) {
|
||||
if (!eligible(center.x(), center.z(), level)) return null;
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
queue.add(new Node(center.x(), center.z(), 0));
|
||||
costs.put(key(center.x(), center.z()), 0.0);
|
||||
while (!queue.isEmpty() && wet.size() < target) {
|
||||
Node current = queue.remove();
|
||||
if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
if (!eligible(current.x(), current.z(), level)) continue;
|
||||
wet.add(key(current.x(), current.z()));
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = current.x() + d[0], z = current.z() + d[1];
|
||||
if (Math.hypot(x - center.x(), z - center.z()) > 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<Long> work = new ArrayList<>(wet);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long neighbor = key(x, z);
|
||||
if (wet.contains(neighbor) || column(x, z).top() >= level) continue;
|
||||
if (wet.size() >= 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<Long, Cell> waterCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Cell> result = new HashMap<>();
|
||||
for (long key : footprint) {
|
||||
Point p = point(key);
|
||||
if (!eligible(p.x(), p.z(), level)) return null;
|
||||
boolean edge = false;
|
||||
for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1]));
|
||||
Column column = column(p.x(), p.z());
|
||||
int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0));
|
||||
depth = Math.min(depth, 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;
|
||||
}
|
||||
|
||||
private boolean contained(Map<Long, Cell> wet) {
|
||||
for (Cell cell : wet.values()) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
if (!solid(cell.x(), y, cell.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
Cell neighbor = wet.get(key(x, z));
|
||||
if (neighbor == null) neighbor = cells.get(key(x, z));
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
if (neighbor != null && neighbor.hasWater()) {
|
||||
if (neighbor.waterY() != cell.waterY()) return false;
|
||||
if (y > neighbor.bedY()) continue;
|
||||
}
|
||||
if (!solid(x, y, z)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addFeature(long id, Kind kind, int level, List<Point> path, Map<Long, Cell> wet) {
|
||||
int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
int count = 0;
|
||||
for (Cell cell : wet.values()) {
|
||||
Cell old = cells.get(key(cell.x(), cell.z()));
|
||||
if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; }
|
||||
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
|
||||
maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z());
|
||||
}
|
||||
features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ)));
|
||||
}
|
||||
|
||||
private static Set<Long> connectedTo(Set<Long> footprint, long start) {
|
||||
Set<Long> connected = new HashSet<>();
|
||||
List<Long> work = new ArrayList<>();
|
||||
connected.add(start); work.add(start);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
long next = key(point.x() + d[0], point.z() + d[1]);
|
||||
if (footprint.contains(next) && connected.add(next)) work.add(next);
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
private record ShoreNode(int x, int z, int level, long feature, double cost) {}
|
||||
|
||||
/** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby
|
||||
* pocket can share the same sheet; the result is not a one-block material ring. */
|
||||
private void addSedimentSheets() {
|
||||
Comparator<ShoreNode> order = Comparator.comparingDouble(ShoreNode::cost)
|
||||
.thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature);
|
||||
Set<Long> rivers = features.stream().filter(f -> f.kind() == Kind.RIVER).map(Feature::id)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
PriorityQueue<ShoreNode> queue = new PriorityQueue<>(order);
|
||||
Map<Long, Double> best = new HashMap<>();
|
||||
for (Cell cell : cells.values()) if (cell.hasWater()) {
|
||||
queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0));
|
||||
best.put(key(cell.x(), cell.z()), 0.0);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
ShoreNode node = queue.remove();
|
||||
if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
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<Position> volume, List<Position> path, FlowBounds bounds) {}
|
||||
|
||||
/** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces
|
||||
* one rock block under an existing roof. Its outlet is already air over an eight-block drop;
|
||||
* no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */
|
||||
private void addSprings() {
|
||||
List<Cliff> cliffs = new ArrayList<>();
|
||||
for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) {
|
||||
if (!inside(x, z, 264)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top < 88) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
int nx = x + d[0] * GRID, nz = z + d[1] * GRID;
|
||||
int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top - lower < 16) continue;
|
||||
cliffs.add(new Cliff(x, z, d[0], d[1],
|
||||
Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3));
|
||||
}
|
||||
}
|
||||
cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed()
|
||||
.thenComparingInt(Cliff::x).thenComparingInt(Cliff::z)
|
||||
.thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz));
|
||||
int attempts = 0;
|
||||
for (Cliff cliff : cliffs) {
|
||||
if (springs.size() >= 3 || attempts++ >= 160) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2, -4, 4}) {
|
||||
for (int along = 0; along < GRID; along++) {
|
||||
int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent;
|
||||
int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent;
|
||||
if (!inside(x, z, 266)) continue;
|
||||
int ox = x + cliff.dx(), oz = z + cliff.dz();
|
||||
Column rock = column(x, z), outletColumn = column(ox, oz);
|
||||
if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue;
|
||||
// Five blocks below the highest natural surface is below the soil cap.
|
||||
int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8));
|
||||
for (int y = rock.top() - 5; y >= floor; y -= 3) {
|
||||
Position source = new Position(x, y, z), outlet = new Position(ox, y, oz);
|
||||
if (!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<Wall> candidates = new ArrayList<>();
|
||||
for (int x = -240; x <= 240; x += 16) for (int z = -240; z <= 240; z += 16) {
|
||||
if (!inside(x, z, 240)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
for (int y = top - 32; y >= 64; y -= 16) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int[] direction : CARDINALS) {
|
||||
if (solid(x + direction[0] * 4, y, z + direction[1] * 4)) continue;
|
||||
candidates.add(new Wall(x, y, z, direction[0], direction[1],
|
||||
random(seed + 1217 + y, x + direction[0], z + direction[1]) * 3
|
||||
+ Math.min(100, top - y) * 0.015));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Wall::score).reversed()
|
||||
.thenComparingInt(Wall::x).thenComparingInt(Wall::z).thenComparingInt(Wall::y)
|
||||
.thenComparingInt(Wall::dx).thenComparingInt(Wall::dz));
|
||||
int attempts = 0;
|
||||
for (Wall candidate : candidates) {
|
||||
if (springs.size() >= 2 || attempts++ >= 96) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2}) {
|
||||
for (int along = 0; along < 4; along++) {
|
||||
int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent;
|
||||
for (int dy : new int[]{0, 3, -3, 6, -6}) {
|
||||
int y = candidate.y() + dy;
|
||||
if (y < 56 || column(x, z).top() - y < 24) continue;
|
||||
Position source = new Position(x, y, z);
|
||||
if (!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<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<FlowNode> queue = new ArrayDeque<>();
|
||||
reach.put(source, 7);
|
||||
queue.add(new FlowNode(source, 7));
|
||||
Position lowest = source;
|
||||
while (!queue.isEmpty()) {
|
||||
FlowNode node = queue.removeFirst();
|
||||
Position at = node.position();
|
||||
if (node.reach() < reach.getOrDefault(at, -1)) continue;
|
||||
if (at.y() < lowest.y()) lowest = at;
|
||||
if (reach.size() > 24000) return null;
|
||||
if (at.y() <= 0) continue;
|
||||
Position below = new Position(at.x(), at.y() - 1, at.z());
|
||||
if (!flowSolid(below, source)) {
|
||||
enqueueFlow(below, 7, at, queue, reach, parent);
|
||||
continue;
|
||||
}
|
||||
if (node.reach() <= 0) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
|
||||
if (flowSolid(next, source)) continue;
|
||||
enqueueFlow(next, node.reach() - 1, at, queue, reach, parent);
|
||||
}
|
||||
}
|
||||
if (!reach.containsKey(outlet)) return null;
|
||||
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY;
|
||||
int minZ = source.z(), maxZ = minZ;
|
||||
for (Position position : reach.keySet()) {
|
||||
minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x());
|
||||
minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y());
|
||||
minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z());
|
||||
}
|
||||
List<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Position source) {
|
||||
if (at.equals(source)) return false;
|
||||
Cell cell = cells.get(key(at.x(), at.z()));
|
||||
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
|
||||
return solid(at.x(), at.y(), at.z());
|
||||
}
|
||||
|
||||
private static void enqueueFlow(Position next, int available, Position from, ArrayDeque<FlowNode> queue,
|
||||
Map<Position, Integer> reach, Map<Position, Position> parent) {
|
||||
if (available <= reach.getOrDefault(next, -1)) return;
|
||||
if (!reach.containsKey(next)) parent.put(next, from);
|
||||
reach.put(next, available);
|
||||
queue.addLast(new FlowNode(next, available));
|
||||
}
|
||||
|
||||
private boolean nearWater(int x, int z, int distance) {
|
||||
for (Feature feature : features) {
|
||||
Point point = feature.path().getFirst();
|
||||
if (Math.hypot(x - point.x(), z - point.z()) < distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; }
|
||||
private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); }
|
||||
|
||||
private double terrainNoise(int x, int z) { return field(x, z, 8, 31); }
|
||||
|
||||
private double field(int x, int z, int scale, long salt) {
|
||||
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
|
||||
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
|
||||
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
|
||||
double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx;
|
||||
double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, gx + 1, gz + 1) * tx;
|
||||
return a * (1 - tz) + b * tz;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static Point point(long key) { return new Point((int) (key >> 32), (int) key); }
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; }
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Alpha.8 only: immutable plans cached by world state, applied to fresh terrain before decoration. */
|
||||
public final class RiftHydrologyRuntime {
|
||||
private static final Map<RandomState, RiftHydrology.Plan> PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private static final Map<RandomState, RiftLavaDeposit.Plan> LAVA_PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private RiftHydrologyRuntime() {}
|
||||
|
||||
public static boolean enabled(NoiseBasedChunkGenerator generator) {
|
||||
return generator.generatorSettings().is(SanctuarySpawn.RIFT_SETTINGS);
|
||||
}
|
||||
|
||||
public static RiftHydrology.Plan riftPlan(ServerLevel level) {
|
||||
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static RiftHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
if (!enabled(generator)) throw new IllegalArgumentException("Rift hydrology is not enabled for this generator");
|
||||
return PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var plan = RiftHydrology.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
|
||||
SanctuaryMod.LOGGER.info("Sanctuary rift hydrology: seed {}, {} features, {} columns, {} springs, {} ms",
|
||||
state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary rift catchments: {}", plan.features().stream().map(f -> f.kind() + " Y" + f.waterY() + " water=" + f.waterCells() + " path=" + f.path().size() + " " + f.bounds()).toList());
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
public static RiftLavaDeposit.Plan lavaPlan(ServerLevel level) {
|
||||
return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static RiftLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
// Acquire the water plan before the lava cache lock; the water planner never acquires it.
|
||||
var water = plan(generator, randomState);
|
||||
return LAVA_PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var lava = RiftLavaDeposit.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms",
|
||||
state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000);
|
||||
return lava;
|
||||
});
|
||||
}
|
||||
|
||||
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
|
||||
if (!enabled(generator)) return;
|
||||
var chunkPos = chunk.getPos();
|
||||
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|
||||
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
|
||||
var plan = plan(generator, randomState);
|
||||
var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var outlets = new java.util.ArrayList<>(plan.springs().stream().map(RiftHydrology.Spring::outlet)
|
||||
.filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList());
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
for (var fall : lava.falls()) {
|
||||
var outlet = fall.outlet();
|
||||
if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z())
|
||||
outlets.add(new RiftHydrology.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 = RiftMaterials.sedimentBlock(cell, randomState.seed(), depth);
|
||||
chunk.setBlockState(pos, material, 0);
|
||||
}
|
||||
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY()
|
||||
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
|
||||
if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
pos.set(source.x(), source.y(), source.z());
|
||||
chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0);
|
||||
// Only the source is generated. Vanilla fluid ticks create the descending waterfall.
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
pos.set(cell.x(), cell.y(), cell.z());
|
||||
chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0);
|
||||
if (cell.lava()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
// This existing vanilla queue is consumed after neighbouring chunks finish decoration.
|
||||
// Mark the outlet in its own chunk, including when its source belongs to a neighbour.
|
||||
for (var outlet : outlets) {
|
||||
pos.set(outlet.x(), outlet.y(), outlet.z());
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
|
||||
/** Reserve water, sediment and hot-rock volumes before placing vegetation. */
|
||||
public static boolean protects(NoiseBasedChunkGenerator generator, RandomState randomState, BlockPos base,
|
||||
int radius, int height, int rootDepth) {
|
||||
var water = plan(generator, randomState);
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
int low = base.getY() - rootDepth, high = base.getY() + height;
|
||||
for (int x = base.getX() - radius; x <= base.getX() + radius; x++) {
|
||||
for (int z = base.getZ() - radius; z <= base.getZ() + radius; z++) {
|
||||
var cell = water.cellAt(x, z);
|
||||
if (cell != null && 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 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("Rift hydrology differs from terrain at " + pos + " for seed " + seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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.8 rift climate, separate from all saved earlier biome-source codecs. */
|
||||
public final class RiftIslandBiomeSource extends BiomeSource {
|
||||
public static final ResourceKey<Biome> OAK_FOREST = key("rift_oak_forest");
|
||||
public static final ResourceKey<Biome> BIRCH_FOREST = key("rift_birch_forest");
|
||||
public static final ResourceKey<Biome> CLEARING = key("rift_clearing");
|
||||
public static final ResourceKey<Biome> DRY_WOODLAND = key("rift_dry_woodland");
|
||||
public static final ResourceKey<Biome> ROCKY_HEATH = key("rift_rocky_heath");
|
||||
public static final ResourceKey<Biome> DARK_GROVE = key("rift_dark_grove");
|
||||
public static final ResourceKey<Biome> BAMBOO_GROVE = key("rift_bamboo_grove");
|
||||
public static final ResourceKey<Biome> SULFUR_DEPTHS = key("rift_sulfur_depths");
|
||||
|
||||
public static final MapCodec<RiftIslandBiomeSource> 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)
|
||||
).apply(instance, RiftIslandBiomeSource::new));
|
||||
|
||||
private final Holder<Biome> oakForest;
|
||||
private final Holder<Biome> birchForest;
|
||||
private final Holder<Biome> clearing;
|
||||
private final Holder<Biome> dryWoodland;
|
||||
private final Holder<Biome> rockyHeath;
|
||||
private final Holder<Biome> darkGrove;
|
||||
private final Holder<Biome> bambooGrove;
|
||||
private final Holder<Biome> sulfurDepths;
|
||||
|
||||
public RiftIslandBiomeSource(Holder<Biome> oakForest, Holder<Biome> birchForest, Holder<Biome> clearing,
|
||||
Holder<Biome> dryWoodland, Holder<Biome> rockyHeath, Holder<Biome> darkGrove,
|
||||
Holder<Biome> bambooGrove, Holder<Biome> sulfurDepths) {
|
||||
this.oakForest = oakForest;
|
||||
this.birchForest = birchForest;
|
||||
this.clearing = clearing;
|
||||
this.dryWoodland = dryWoodland;
|
||||
this.rockyHeath = rockyHeath;
|
||||
this.darkGrove = darkGrove;
|
||||
this.bambooGrove = bambooGrove;
|
||||
this.sulfurDepths = sulfurDepths;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MapCodec<RiftIslandBiomeSource> codec() { return CODEC; }
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return Stream.of(oakForest, birchForest, clearing, dryWoodland, rockyHeath, darkGrove, bambooGrove, sulfurDepths);
|
||||
}
|
||||
|
||||
@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 (RiftBiomePalette.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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
private static ResourceKey<Biome> key(String path) {
|
||||
return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user