Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e94c1c539 |
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -29,11 +29,13 @@ décrits dans la vision ne sont pas encore implémentés.
|
||||
- Nouveaux mondes sur 384 blocs (`Y=0..383`), île relevée de 64 blocs,
|
||||
dessous sculpté et nuages au-dessus du terrain. Les anciennes sauvegardes
|
||||
conservent leurs paramètres de génération.
|
||||
- Première hydrologie de surface : étangs, petits lacs et cours d’eau calmes,
|
||||
avec des berges de sable et de la canne à sucre lorsque le terrain s’y prête.
|
||||
|
||||
L'île a une emprise nominale de 512 blocs de diamètre, avec une bordure sculptée
|
||||
par des bruits liés à la seed. Aucune masse centrale n'est ajoutée pour forcer
|
||||
le terrain : le spawn cherche un emplacement naturel. Le catalogue
|
||||
TerraMix, les continents déverrouillables, les lacs et rivières, les ruines et
|
||||
TerraMix, les continents déverrouillables, les grandes rivières et cascades, les ruines et
|
||||
les autres fonctionnalités restent à construire par tickets.
|
||||
|
||||
Le premier champ de recherche climatique est défini : nord froid, sud chaud,
|
||||
@@ -58,7 +60,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes.
|
||||
| Fabric API | 0.160.0+26.3 |
|
||||
| Fabric Loom | 1.17.20 |
|
||||
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
|
||||
| Sanctuary / pack | 0.1.0-alpha.3 |
|
||||
| Sanctuary / pack | 0.1.0-alpha.4 |
|
||||
|
||||
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
|
||||
uniquement la bibliothèque standard et repère aussi une installation Python
|
||||
@@ -85,7 +87,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.3.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.4.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).
|
||||
|
||||
@@ -122,6 +122,10 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
|
||||
- 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, documentée
|
||||
dans [Génération](worldgen.md). Ce travail prépare les eaux des continents sans
|
||||
clore WG-06 : océans, grandes rivières et cascades restent à développer.
|
||||
|
||||
### WG-07 — Introduire un premier biome distinct et une structure
|
||||
|
||||
**Dépendance :** WG-06.
|
||||
|
||||
+5
-3
@@ -19,7 +19,7 @@ Après les vérifications, le commit et le push de la branche du ticket, le scri
|
||||
de publication réalise les étapes de release et de canal ci-dessous :
|
||||
|
||||
```sh
|
||||
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.3.mrpack
|
||||
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.4.mrpack
|
||||
```
|
||||
|
||||
Les options sont facultatives. Ce script **publie** sur le Git configuré dans
|
||||
@@ -38,7 +38,7 @@ La procédure complète, également utilisable manuellement :
|
||||
3. Préparer les métadonnées de la livraison, avec l'URL exacte du futur JAR :
|
||||
|
||||
```sh
|
||||
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.3/sanctuary-0.1.0-alpha.3.jar
|
||||
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.4/sanctuary-0.1.0-alpha.4.jar
|
||||
```
|
||||
|
||||
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
|
||||
@@ -115,7 +115,9 @@ n'autorisent pas à supprimer ou régénérer un monde.
|
||||
|
||||
La hauteur de 384 blocs, le dessous sculpté et le ciel de l'alpha.3 se testent
|
||||
dans un **nouveau monde** avec le preset **Sanctuary**. Les anciens mondes gardent
|
||||
leurs identifiants de génération et leurs hauteurs. Les détails des versions de génération sont dans
|
||||
leurs identifiants de génération et leurs hauteurs. 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 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.
|
||||
|
||||
|
||||
+97
-1
@@ -27,6 +27,12 @@ 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.
|
||||
|
||||
## Tests dans le moteur Minecraft
|
||||
|
||||
`runGameTest` démarre le serveur de test headless officiel de Fabric dans
|
||||
@@ -52,7 +58,7 @@ Le framework déplace aussi le spawn vers une grille de tests située loin du ce
|
||||
Le test capture donc le spawn juste avant ce déplacement, après l'initialisation
|
||||
normale du monde, et examine les coordonnées absolues de l'île.
|
||||
|
||||
Les sept tests Sanctuary vérifient :
|
||||
Les neuf tests Sanctuary vérifient :
|
||||
|
||||
1. Le vrai générateur Sanctuary est chargé et le spawn collectif repose sur une
|
||||
surface déjà présente dans la densité naturelle, avec un sol plein de 3×3
|
||||
@@ -72,6 +78,12 @@ Les sept tests Sanctuary vérifient :
|
||||
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 :
|
||||
eau retenue, fonds naturels sur trois blocs, parois continues, ouverture au
|
||||
ciel, raccords entre chunks et canne à sucre pouvant survivre. Après des
|
||||
mises à jour réelles de fluides, chaque bloc d’eau doit rester dans ce plan.
|
||||
9. Les paramètres et le biome des anciennes sauvegardes n’activent ni le
|
||||
traitement hydrologique ni la nouvelle décoration des berges.
|
||||
|
||||
La recherche de spawn parcourt l'île finie, par anneaux de quatre blocs jusqu'à
|
||||
288 blocs du centre. Elle examine d'abord la hauteur brute pour éviter de
|
||||
@@ -109,6 +121,40 @@ Référence du workflow : [tests automatiques Fabric](https://docs.fabricmc.net/
|
||||
Les signatures propres à `26.3-pre-2` ont été vérifiées dans les dépendances locales,
|
||||
car la documentation publiée vise actuellement `26.2`.
|
||||
|
||||
## Diagnostic hydrologique
|
||||
|
||||
Les essais hydrologiques demandent les chunks contenant tous les sites du plan
|
||||
et un halo 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.
|
||||
|
||||
Les chunks de l’île sont maintenus actifs pendant le test. Chaque position
|
||||
d’eau doit être dans un chunk où les blocs sont effectivement simulés. Un petit
|
||||
témoin d’eau temporaire, enfermé en hauteur dans un de ces mêmes chunks, doit
|
||||
réellement couler ; il est ensuite entièrement retiré. Les sources des sites
|
||||
reçoivent elles aussi des ticks, puis la rétention est revérifiée au tick 200.
|
||||
Attendre près de la grille éloignée du framework ne remplacerait pas ce contrôle.
|
||||
|
||||
Les fichiers `surface-hydrology-seed-<graine>-generated.json/.png` et
|
||||
`surface-hydrology-seed-<graine>-after-200-ticks.json/.png` sont écrits dans
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/`. Ils décrivent les sites et leur
|
||||
nature, leurs coordonnées, le volume d’eau réel, la canne à sucre, les raccords
|
||||
entre chunks, les berges et les bornes inspectées. Le nombre de blocs d’eau
|
||||
n’est pas une mesure de 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. Elles gardent les assertions de densité et tous les tests de
|
||||
comportement, y compris les cartes et contrôles hydrologiques. Le parcours
|
||||
standard conserve les exports complets de densité pour la graine 0.
|
||||
|
||||
## Vérification client facultative
|
||||
|
||||
```sh
|
||||
@@ -127,6 +173,56 @@ Pour cet essai visuel en monde de développement créatif, comparer la même vue
|
||||
Y=63 ni assombrissement d'altitude au passage de Y=32. Les brouillards de l'eau,
|
||||
de la météo et des effets d'aveuglement ou d'obscurité doivent rester présents.
|
||||
|
||||
## 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 :
|
||||
|
||||
+51
-6
@@ -34,7 +34,8 @@ de test et les sauvegardes personnelles doivent rester distincts.
|
||||
- Le terrain naturel reste libre de laisser un trou à l'origine, de former des
|
||||
surplombs ou de se séparer en fragments. **Aucun noyau ellipsoïdal, cylindre ou
|
||||
plateforme de départ n'est ajouté.** Au-dessus de `Y=144`, le relief de la
|
||||
génération précédente est conservé, simplement relevé de 64 blocs.
|
||||
génération précédente est conservé dans la densité, simplement relevé de 64
|
||||
blocs. L’hydrologie v4 creuse ensuite localement les lits de 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.
|
||||
@@ -46,7 +47,7 @@ de test et les sauvegardes personnelles doivent rester distincts.
|
||||
Il n'y a ni plancher de bedrock,
|
||||
ni mer globale, ni reprise du terrain à distance. Le Nether et l'End restent
|
||||
ceux de Minecraft.
|
||||
- `sanctuary:starter_forest` reprend végétation, minerais et animaux de la forêt
|
||||
- `sanctuary:starter_forest_hydrology` reprend végétation, minerais et animaux de la forêt
|
||||
vanilla 26.3. Ses blocs sont exclusivement vanilla. C'est un biome distinct
|
||||
pour ne pas hériter des tags de structures vanilla et de leurs apparitions
|
||||
possibles dans le vide.
|
||||
@@ -54,9 +55,9 @@ de test et les sauvegardes personnelles doivent rester distincts.
|
||||
forêt initiale. Les minerais remplacent la roche existante et les arbres ont
|
||||
besoin de sol. Aucun mod de biomes externe n'est nécessaire.
|
||||
|
||||
Ce premier prototype ne fournit pas encore d'eau : hydrologie, répartition
|
||||
complète des ressources de survie et variété de biomes constituent le prochain
|
||||
ticket de terrain. Les minerais vanilla conservent leurs plages d'altitude ;
|
||||
L’hydrologie v4 fournit les premières eaux de surface décrites ci-dessous. La
|
||||
répartition complète des ressources de survie et la variété de biomes restent
|
||||
à développer. Les minerais vanilla conservent leurs plages d’altitude ;
|
||||
leur abondance n'est donc pas encore équilibrée pour une île flottante.
|
||||
|
||||
## Sculpture du pourtour — conservée depuis la génération v2
|
||||
@@ -134,6 +135,49 @@ ou régénéré. Il n'existe pas de migration automatique des anciens mondes ver
|
||||
dimension de 384 blocs. Changer manuellement leurs références de générateur ou
|
||||
de dimension sort de ce contrat de compatibilité.
|
||||
|
||||
## Hydrologie de surface — génération v4
|
||||
|
||||
La version **0.1.0-alpha.4** conserve la densité, la hauteur et le ciel de
|
||||
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. Les grandes rivières en pente, cascades, océans, aquifères,
|
||||
climats multiples et continents restent des incréments distincts.
|
||||
**Créer un nouveau monde Sanctuary pour tester cette hydrologie.**
|
||||
|
||||
## Apparition commune
|
||||
|
||||
Une injection limitée à la création initiale cherche du sol naturel depuis
|
||||
@@ -142,7 +186,8 @@ une surface stable de 3 × 3 blocs, avec deux blocs libres en hauteur. En
|
||||
l'absence d'une telle surface, elle retient une colonne de sol praticable ; si
|
||||
aucun emplacement naturel sûr n'existe, la création signale la seed concernée
|
||||
plutôt que d'ajouter une plateforme. Elle ne s'applique qu'à l'Overworld
|
||||
utilisant les paramètres `sanctuary:sanctuary`.
|
||||
utilisant les paramètres `sanctuary:sanctuary`, `sanctuary:sanctuary_384` ou
|
||||
`sanctuary:sanctuary_hydrology`.
|
||||
Elle enregistre le spawn partagé dans la sauvegarde et ne le réinitialise pas
|
||||
au redémarrage : les modifications administratives de `/setworldspawn` restent
|
||||
conservées. Les lits et la dispersion habituelle du spawn suivent encore les
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.3
|
||||
|
||||
mod_version=0.1.0-alpha.3
|
||||
pack_version=0.1.0-alpha.3
|
||||
mod_version=0.1.0-alpha.4
|
||||
pack_version=0.1.0-alpha.4
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
@@ -34,6 +34,7 @@ tasks.named('runGameTest') {
|
||||
dependsOn('cleanGameTestWorld')
|
||||
systemProperty('sanctuary.test.seed', providers.gradleProperty('sanctuaryTestSeed').getOrElse('0'))
|
||||
systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample'))
|
||||
systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true'))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -76,5 +77,15 @@ tasks.register('worldgenSmoke', JavaExec) {
|
||||
|
||||
tasks.named('check') { dependsOn('worldgenSmoke') }
|
||||
|
||||
tasks.register('hydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check deterministic surface drainage, natural support and bounded excavation.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.SurfaceHydrologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('hydrologySmoke') }
|
||||
|
||||
// src/test contains a JavaExec assertion harness; engine tests use Fabric's gametest source set.
|
||||
tasks.named('test') { failOnNoDiscoveredTests = false }
|
||||
|
||||
@@ -44,7 +44,7 @@ final class DensityDiagnostics {
|
||||
var noises = registries.lookupOrThrow(Registries.NOISE);
|
||||
// The default run exports all three seeds once. Additional complete-world runs only
|
||||
// repeat the sparse assertions, avoiding redundant image generation.
|
||||
boolean export = level.getSeed() == 0L;
|
||||
boolean export = level.getSeed() == 0L && Boolean.parseBoolean(System.getProperty("sanctuary.test.densityMaps", "true"));
|
||||
boolean foundUnforcedCentralVoid = false;
|
||||
for (long seed : new long[]{0L, 42L, 8675309L}) {
|
||||
RandomState state = RandomState.create(noises, seed, settings);
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -23,6 +23,17 @@ import net.minecraft.world.level.storage.LevelData;
|
||||
public final class SanctuaryWorldGameTests {
|
||||
public static LevelData.RespawnData initialSpawn;
|
||||
|
||||
@GameTest(maxTicks = 400)
|
||||
public void generatedSurfaceWaterRemainsContained(GameTestHelper helper) throws java.io.IOException {
|
||||
HydrologyDiagnostics.start(helper);
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) {
|
||||
HydrologyDiagnostics.verifyLegacyIsolation(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void generatedIslandAndInitialSpawn(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fr.koka.sanctuary;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.MainIslandDensity;
|
||||
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
@@ -19,6 +20,7 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("shore_sugar_cane"), ShoreSugarCaneFeature.CODEC);
|
||||
LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
|
||||
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 (HydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
HydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** One immutable plan per world random state. The value retains neither that state nor its sampler. */
|
||||
public final class HydrologyRuntime {
|
||||
private static final Map<RandomState, SurfaceHydrology.Plan> PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private HydrologyRuntime() {}
|
||||
|
||||
public static boolean enabled(NoiseBasedChunkGenerator generator) {
|
||||
return generator.generatorSettings().is(SanctuarySpawn.HYDROLOGY_SETTINGS);
|
||||
}
|
||||
|
||||
public static SurfaceHydrology.Plan plan(ServerLevel level) {
|
||||
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static SurfaceHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
if (!enabled(generator)) throw new IllegalArgumentException("Hydrology is not enabled for this generator");
|
||||
return PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var plan = SurfaceHydrology.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
|
||||
SanctuaryMod.LOGGER.info("Sanctuary surface hydrology: seed {}, {} features, {} columns, {} ms",
|
||||
state.seed(), plan.features().size(), plan.cells().size(), (System.nanoTime() - start) / 1_000_000);
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
/** Executed during fresh terrain generation only, before ores, trees and shoreline plants. */
|
||||
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
|
||||
if (!enabled(generator)) return;
|
||||
var chunkPos = chunk.getPos();
|
||||
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|
||||
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
|
||||
var cells = plan(generator, randomState).cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
if (cells.isEmpty()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
// Validate this chunk's entire foundation before the first write. The planner may only
|
||||
// replace existing terrain; a mismatch must fail rather than leave a partially applied bed.
|
||||
for (var cell : cells) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
|
||||
throw new IllegalStateException("Hydrology bed differs from natural terrain at " + pos
|
||||
+ " for seed " + randomState.seed());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var cell : cells) {
|
||||
pos.set(cell.x(), cell.bedY(), cell.z());
|
||||
BlockState material = switch (cell.material()) {
|
||||
case SAND -> Blocks.SAND.defaultBlockState();
|
||||
case GRAVEL -> Blocks.GRAVEL.defaultBlockState();
|
||||
case CLAY -> Blocks.CLAY.defaultBlockState();
|
||||
};
|
||||
chunk.setBlockState(pos, material, 0);
|
||||
if (!cell.hasWater()) continue;
|
||||
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
chunk.setBlockState(pos, y <= cell.waterY()
|
||||
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
|
||||
if (y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
}
|
||||
// buildTerrain's incoming proto status may not yet maintain the WG heightmaps. Decoration
|
||||
// must see the new bed and waterline so trees are placed on dry shores, not below the lake.
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,16 @@ public final class SanctuarySpawn {
|
||||
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary"));
|
||||
public static final ResourceKey<NoiseGeneratorSettings> RAISED_SETTINGS =
|
||||
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_384"));
|
||||
public static final ResourceKey<NoiseGeneratorSettings> HYDROLOGY_SETTINGS =
|
||||
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("sanctuary_hydrology"));
|
||||
|
||||
private SanctuarySpawn() {}
|
||||
|
||||
public static boolean usesSanctuaryGenerator(ServerLevel level) {
|
||||
return level.dimension() == Level.OVERWORLD
|
||||
&& level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator generator
|
||||
&& (generator.generatorSettings().is(SETTINGS) || generator.generatorSettings().is(RAISED_SETTINGS));
|
||||
&& (generator.generatorSettings().is(SETTINGS) || generator.generatorSettings().is(RAISED_SETTINGS)
|
||||
|| generator.generatorSettings().is(HYDROLOGY_SETTINGS));
|
||||
}
|
||||
|
||||
/** Called only during first creation; saved spawns and /setworldspawn survive subsequent loads. */
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
|
||||
/** A few plants on existing suitable shores, after the forest has been decorated. */
|
||||
public final class ShoreSugarCaneFeature implements Feature {
|
||||
public static final MapCodec<ShoreSugarCaneFeature> CODEC = MapCodec.unit(ShoreSugarCaneFeature::new);
|
||||
|
||||
@Override
|
||||
public MapCodec<ShoreSugarCaneFeature> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !HydrologyRuntime.enabled(noise)) return false;
|
||||
var plan = HydrologyRuntime.plan(noise, level.getLevel().getChunkSource().randomState());
|
||||
var cane = Blocks.SUGAR_CANE.defaultBlockState();
|
||||
boolean placed = false;
|
||||
for (var cell : plan.cellsInChunk(origin.getX() >> 4, origin.getZ() >> 4)) {
|
||||
if (cell.hasWater() || random.nextInt(7) != 0) continue;
|
||||
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
|
||||
if (!level.getBlockState(base).isAir() || !cane.canSurvive(level, base)) continue;
|
||||
int height = 1 + random.nextInt(3);
|
||||
for (int y = 0; y < height; y++) {
|
||||
BlockPos pos = base.above(y);
|
||||
if (!level.getBlockState(pos).isAir() || !cane.canSurvive(level, pos)) break;
|
||||
setBlock(level, pos, cane);
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Set;
|
||||
|
||||
/** A seed-only surface-water plan. No chunks, random generation order or Minecraft objects are retained. */
|
||||
public final class SurfaceHydrology {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int MAX_CARVE = 6;
|
||||
public static final int MAX_DEPTH = 4;
|
||||
private static final int GRID = 8;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
|
||||
|
||||
private SurfaceHydrology() {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Sampler { float sample(int x, int y, int z); }
|
||||
|
||||
public enum Kind { POND, LAKE, STREAM }
|
||||
public enum ShoreMaterial { SAND, GRAVEL, CLAY }
|
||||
public record Point(int x, int z) {}
|
||||
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
|
||||
|
||||
/** bedY is an existing solid block. carveTop includes waterY when a natural hollow is filled. */
|
||||
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
|
||||
ShoreMaterial material, long featureId) {
|
||||
public boolean hasWater() { return waterY >= 0; }
|
||||
}
|
||||
|
||||
public record Feature(long id, Kind kind, int waterY, List<Point> path, int waterCells, Bounds bounds) {
|
||||
public Feature { path = List.copyOf(path); }
|
||||
}
|
||||
|
||||
public static final class Plan {
|
||||
private final Map<Long, Cell> cells;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final List<Feature> features;
|
||||
private final int sampledColumns;
|
||||
private final long densitySamples;
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, int columns, long samples) {
|
||||
List<Cell> sorted = source.values().stream()
|
||||
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
|
||||
Map<Long, Cell> ordered = new LinkedHashMap<>();
|
||||
Map<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : sorted) {
|
||||
ordered.put(key(cell.x(), cell.z()), cell);
|
||||
byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell);
|
||||
}
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
cells = Collections.unmodifiableMap(ordered);
|
||||
chunks = Map.copyOf(byChunk);
|
||||
this.features = List.copyOf(features);
|
||||
sampledColumns = columns;
|
||||
densitySamples = samples;
|
||||
}
|
||||
|
||||
public Cell cellAt(int x, int z) { return cells.get(key(x, z)); }
|
||||
public Collection<Cell> cells() { return cells.values(); }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Feature> features() { return features; }
|
||||
public int sampledColumns() { return sampledColumns; }
|
||||
public long densitySamples() { return densitySamples; }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, Sampler sampler) {
|
||||
return new Planner(seed, sampler).build();
|
||||
}
|
||||
|
||||
private record Column(int top, int solidBottom) {}
|
||||
private record Candidate(int x, int z, double score) {}
|
||||
private record Node(int x, int z, double cost) {}
|
||||
private static final Comparator<Node> NODE_ORDER = Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z);
|
||||
|
||||
private static final class Planner {
|
||||
private final long seed;
|
||||
private final Sampler sampler;
|
||||
private final Map<Long, Column> columns = new HashMap<>();
|
||||
private final Map<Long, Integer> coarse = new HashMap<>();
|
||||
private final Map<Long, Cell> cells = new HashMap<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private long samples;
|
||||
|
||||
Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; }
|
||||
|
||||
Plan build() {
|
||||
List<Candidate> candidates = candidates();
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (features.size() >= 5 || attempts++ >= 96) break;
|
||||
Point center = new Point(candidate.x(), candidate.z());
|
||||
if (nearWater(center.x(), center.z(), 38)) continue;
|
||||
Column column = column(center.x(), center.z());
|
||||
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
|
||||
Kind kind = features.size() < 2 ? Kind.LAKE : Kind.POND;
|
||||
int target = kind == Kind.LAKE ? 150 + (int) (random(seed, center.x(), center.z()) * 100)
|
||||
: 40 + (int) (random(seed + 1, center.x(), center.z()) * 45);
|
||||
// Prefer an existing low spot; a shallow cut is the fallback on level terrain.
|
||||
for (int level : new int[]{column.top() + 1, column.top() - 1, column.top() - 2}) {
|
||||
Set<Long> footprint = growBasin(center, level, target);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, kind, level, List.of(center), wet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// One quiet stream connects a basin to a nearby surface refuge at the same water level.
|
||||
// Its route follows low excavation cost through the relief; it has no falling outlets.
|
||||
boolean stream = false;
|
||||
for (Feature source : List.copyOf(features)) if (addStream(source)) { stream = true; break; }
|
||||
if (!stream) addPlateauStream(candidates);
|
||||
addBanks();
|
||||
return new Plan(cells, features, columns.size(), samples);
|
||||
}
|
||||
|
||||
private List<Candidate> candidates() {
|
||||
for (int x = -RADIUS; x <= RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS; z <= RADIUS; z += GRID) {
|
||||
if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z));
|
||||
}
|
||||
}
|
||||
List<Candidate> result = new ArrayList<>();
|
||||
for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) {
|
||||
int height = coarse.getOrDefault(key(x, z), -1);
|
||||
if (height < 64 || !inside(x, z, RADIUS - 24)) continue;
|
||||
double average = 0;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1);
|
||||
if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false;
|
||||
average += neighbor * 0.25;
|
||||
}
|
||||
if (safe) result.add(new Candidate(x, z,
|
||||
(average - height) * 2.0 + random(seed + 2, x, z) * 3.0));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
return result;
|
||||
}
|
||||
|
||||
private int coarseTop(int x, int z) {
|
||||
for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private Column column(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Column old = columns.get(key);
|
||||
if (old != null) return old;
|
||||
int top = -1;
|
||||
// Exact scan, including everything above the planned water: a cave roof cannot be
|
||||
// mistaken for a surface. Only candidates and their nearby shore need this refinement.
|
||||
for (int y = 383; y >= 24; y--) {
|
||||
if (solid(x, y, z)) { top = y; break; }
|
||||
}
|
||||
int bottom = top;
|
||||
if (top >= 0) {
|
||||
while (bottom > Math.max(16, top - 16) && solid(x, bottom - 1, z)) bottom--;
|
||||
}
|
||||
Column created = new Column(top, bottom);
|
||||
columns.put(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private boolean eligible(int x, int z, int level) {
|
||||
if (level < 32 || level >= 383 || !inside(x, z, RADIUS - 2)) return false;
|
||||
Cell old = cells.get(key(x, z));
|
||||
if (old != null && old.hasWater() && old.waterY() != level) return false;
|
||||
Column column = column(x, z);
|
||||
return column.top() >= level - 3 && column.top() < level + MAX_CARVE
|
||||
&& column.solidBottom() <= Math.min(column.top(), level - 1) - 2;
|
||||
}
|
||||
|
||||
private Set<Long> growBasin(Point center, int level, int target) {
|
||||
if (!eligible(center.x(), center.z(), level)) return null;
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
queue.add(new Node(center.x(), center.z(), 0));
|
||||
costs.put(key(center.x(), center.z()), 0.0);
|
||||
while (!queue.isEmpty() && wet.size() < target) {
|
||||
Node current = queue.remove();
|
||||
if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
if (!eligible(current.x(), current.z(), level)) continue;
|
||||
wet.add(key(current.x(), current.z()));
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = current.x() + d[0], z = current.z() + d[1];
|
||||
if (Math.hypot(x - center.x(), z - center.z()) > 24 || wet.contains(key(x, z))) continue;
|
||||
if (!eligible(x, z, level)) continue;
|
||||
double step = Math.hypot(d[0], d[1]);
|
||||
double cost = current.cost() + step * (1 + Math.max(0, column(x, z).top() - level) * 0.8
|
||||
+ terrainNoise(x, z) * 2.0);
|
||||
if (cost >= costs.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
|
||||
costs.put(key(x, z), cost);
|
||||
queue.add(new Node(x, z, cost));
|
||||
}
|
||||
}
|
||||
wet = connectedTo(wet, key(center.x(), center.z()));
|
||||
if (wet.size() < Math.min(target, 30)) return null;
|
||||
// Filling a hollow requires every lower connected outlet to be included. Abort rather
|
||||
// than place a dam if this natural basin runs to a cliff, cave or an oversized catchment.
|
||||
List<Long> work = new ArrayList<>(wet);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long neighbor = key(x, z);
|
||||
if (wet.contains(neighbor) || column(x, z).top() >= level) continue;
|
||||
if (wet.size() >= 420 || Math.hypot(x - center.x(), z - center.z()) > 28
|
||||
|| !eligible(x, z, level)) return null;
|
||||
wet.add(neighbor);
|
||||
work.add(neighbor);
|
||||
}
|
||||
}
|
||||
return wet;
|
||||
}
|
||||
|
||||
private Map<Long, Cell> waterCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Cell> result = new HashMap<>();
|
||||
for (long key : footprint) {
|
||||
Point p = point(key);
|
||||
if (!eligible(p.x(), p.z(), level)) return null;
|
||||
boolean edge = false;
|
||||
for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1]));
|
||||
Column column = column(p.x(), p.z());
|
||||
int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0));
|
||||
depth = Math.min(depth, MAX_CARVE - Math.max(0, column.top() - level));
|
||||
if (depth < 1 || depth > MAX_DEPTH) return null;
|
||||
int bed = level - depth;
|
||||
if (column.top() < bed || column.solidBottom() > bed - 2) return null;
|
||||
ShoreMaterial material = edge ? ShoreMaterial.SAND
|
||||
: terrainNoise(p.x() + 73, p.z() - 19) > 0.65 ? ShoreMaterial.CLAY : ShoreMaterial.GRAVEL;
|
||||
result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()), material, id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean contained(Map<Long, Cell> wet) {
|
||||
for (Cell cell : wet.values()) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
if (!solid(cell.x(), y, cell.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
Cell neighbor = wet.get(key(x, z));
|
||||
if (neighbor == null) neighbor = cells.get(key(x, z));
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
if (neighbor != null && neighbor.hasWater()) {
|
||||
if (neighbor.waterY() != cell.waterY()) return false;
|
||||
if (y > neighbor.bedY()) continue;
|
||||
}
|
||||
if (!solid(x, y, z)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addFeature(long id, Kind kind, int level, List<Point> path, Map<Long, Cell> wet) {
|
||||
int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
int count = 0;
|
||||
for (Cell cell : wet.values()) {
|
||||
Cell old = cells.get(key(cell.x(), cell.z()));
|
||||
if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; }
|
||||
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
|
||||
maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z());
|
||||
}
|
||||
features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ)));
|
||||
}
|
||||
|
||||
private boolean addStream(Feature source) {
|
||||
Point start = source.path().getFirst();
|
||||
int level = source.waterY();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
Map<Long, Long> parent = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
List<Node> refuges = new ArrayList<>();
|
||||
long startKey = key(start.x(), start.z());
|
||||
costs.put(startKey, 0.0);
|
||||
queue.add(new Node(start.x(), start.z(), 0));
|
||||
int visited = 0;
|
||||
while (!queue.isEmpty() && visited++ < 1200) {
|
||||
Node node = queue.remove();
|
||||
long at = key(node.x(), node.z());
|
||||
if (node.cost() > costs.getOrDefault(at, Double.POSITIVE_INFINITY)) continue;
|
||||
double distance = Math.hypot(node.x() - start.x(), node.z() - start.z());
|
||||
if (distance >= 44 && distance <= 76 && column(node.x(), node.z()).top() <= level + 3) refuges.add(node);
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = node.x() + d[0] * 4, z = node.z() + d[1] * 4;
|
||||
if (Math.hypot(x - start.x(), z - start.z()) > 88 || !eligible(x, z, level)) continue;
|
||||
long next = key(x, z);
|
||||
double cost = node.cost() + Math.hypot(d[0], d[1])
|
||||
* (4 + Math.abs(column(x, z).top() - level) * 1.4 + terrainNoise(x, z) * 4);
|
||||
if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue;
|
||||
costs.put(next, cost); parent.put(next, at); queue.add(new Node(x, z, cost));
|
||||
}
|
||||
}
|
||||
refuges.sort(Comparator.comparingDouble((Node n) -> n.cost() / Math.hypot(n.x() - start.x(), n.z() - start.z()))
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z));
|
||||
int attempts = 0;
|
||||
for (Node refuge : refuges) {
|
||||
if (attempts++ >= 24) break;
|
||||
List<Point> path = new ArrayList<>();
|
||||
long cursor = key(refuge.x(), refuge.z());
|
||||
while (cursor != startKey) { path.add(point(cursor)); cursor = parent.get(cursor); }
|
||||
path.add(start); Collections.reverse(path);
|
||||
Set<Long> corridor = new HashSet<>();
|
||||
for (int i = 1; i < path.size(); i++) {
|
||||
Point a = path.get(i - 1), b = path.get(i);
|
||||
int length = Math.max(Math.abs(b.x() - a.x()), Math.abs(b.z() - a.z()));
|
||||
for (int step = 0; step <= length; step++) {
|
||||
int x = a.x() + (b.x() - a.x()) * step / length;
|
||||
int z = a.z() + (b.z() - a.z()) * step / length;
|
||||
int width = terrainNoise(x + 17, z + 29) > 0.65 ? 2 : 1;
|
||||
for (int dx = -width; dx <= width; dx++) for (int dz = -width; dz <= width; dz++) {
|
||||
if (dx * dx + dz * dz <= width * width + 1) corridor.add(key(x + dx, z + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<Long> refugePool = growBasin(new Point(refuge.x(), refuge.z()), level, 36);
|
||||
if (refugePool == null) continue;
|
||||
corridor.addAll(refugePool);
|
||||
if (!closeStreamOutlets(corridor, level)) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(corridor, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, Kind.STREAM, level, path, wet);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Low lake floors may be trapped below every nearby plateau. Try a small natural surface
|
||||
* refuge as a source too, retaining it only if a fully contained stream can actually leave it. */
|
||||
private boolean addPlateauStream(List<Candidate> candidates) {
|
||||
List<Candidate> plateaus = candidates.stream().filter(candidate -> flatness(candidate.x(), candidate.z()) <= 6)
|
||||
.sorted(Comparator.comparingDouble((Candidate c) -> flatness(c.x(), c.z())
|
||||
- random(seed + 91, c.x(), c.z()) * 2).thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)).toList();
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : plateaus) {
|
||||
if (attempts++ >= 20) break;
|
||||
Point center = new Point(candidate.x(), candidate.z());
|
||||
if (nearWater(center.x(), center.z(), 24)) continue;
|
||||
Column column = column(center.x(), center.z());
|
||||
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
|
||||
for (int level : new int[]{column.top() - 1, column.top() - 2}) {
|
||||
Set<Long> footprint = growBasin(center, level, 40);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, Kind.POND, level, List.of(center), wet);
|
||||
if (addStream(features.getLast())) return true;
|
||||
features.removeLast();
|
||||
cells.values().removeIf(cell -> cell.featureId() == id);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private int flatness(int x, int z) {
|
||||
int low = coarse.getOrDefault(key(x, z), -1000), high = low;
|
||||
for (int[] d : CARDINALS) {
|
||||
int value = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1000);
|
||||
low = Math.min(low, value); high = Math.max(high, value);
|
||||
}
|
||||
return high - low;
|
||||
}
|
||||
|
||||
/** Include small connected low spots beside a channel; reject large/deep/open outlets.
|
||||
* This adds water to a verified natural hollow, never rock to close a leak. */
|
||||
private boolean closeStreamOutlets(Set<Long> corridor, int level) {
|
||||
int maximum = Math.min(900, corridor.size() + 180);
|
||||
List<Long> work = new ArrayList<>(corridor);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long next = key(x, z);
|
||||
Cell old = cells.get(next);
|
||||
if (corridor.contains(next) || old != null && old.hasWater() && old.waterY() == level
|
||||
|| column(x, z).top() >= level) continue;
|
||||
if (corridor.size() >= maximum || !eligible(x, z, level)) return false;
|
||||
corridor.add(next); work.add(next);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Set<Long> connectedTo(Set<Long> footprint, long start) {
|
||||
Set<Long> connected = new HashSet<>();
|
||||
List<Long> work = new ArrayList<>();
|
||||
connected.add(start); work.add(start);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
long next = key(point.x() + d[0], point.z() + d[1]);
|
||||
if (footprint.contains(next) && connected.add(next)) work.add(next);
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
private void addBanks() {
|
||||
List<Cell> water = cells.values().stream().filter(Cell::hasWater)
|
||||
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
|
||||
for (Cell cell : water) {
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
if (cells.containsKey(key(x, z))) continue;
|
||||
Column column = column(x, z);
|
||||
if (column.top() < cell.waterY() || column.top() > cell.waterY() + 2
|
||||
|| column.solidBottom() > column.top() - 2) continue;
|
||||
cells.put(key(x, z), new Cell(x, z, -1, column.top(), column.top(),
|
||||
ShoreMaterial.SAND, cell.featureId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean nearWater(int x, int z, int distance) {
|
||||
for (Feature feature : features) {
|
||||
Point point = feature.path().getFirst();
|
||||
if (Math.hypot(x - point.x(), z - point.z()) < distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; }
|
||||
private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); }
|
||||
|
||||
private double terrainNoise(int x, int z) {
|
||||
int gx = Math.floorDiv(x, 8), gz = Math.floorDiv(z, 8);
|
||||
double tx = smooth(Math.floorMod(x, 8) / 8.0), tz = smooth(Math.floorMod(z, 8) / 8.0);
|
||||
double a = random(seed + 31, gx, gz) * (1 - tx) + random(seed + 31, gx + 1, gz) * tx;
|
||||
double b = random(seed + 31, gx, gz + 1) * (1 - tx) + random(seed + 31, gx + 1, gz + 1) * tx;
|
||||
return a * (1 - tz) + b * tz;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static Point point(long key) { return new Point((int) (key >> 32), (int) key); }
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; }
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"generator.sanctuary.sanctuary": "Sanctuary",
|
||||
"biome.sanctuary.starter_forest": "Sanctuary Forest"
|
||||
"biome.sanctuary.starter_forest": "Sanctuary Forest",
|
||||
"biome.sanctuary.starter_forest_hydrology": "Sanctuary Forest and Shores"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"generator.sanctuary.sanctuary": "Sanctuary",
|
||||
"biome.sanctuary.starter_forest": "For\u00eat de Sanctuary"
|
||||
"biome.sanctuary.starter_forest": "Forêt de Sanctuary",
|
||||
"biome.sanctuary.starter_forest_hydrology": "Forêt et rives de Sanctuary"
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"attributes": {
|
||||
"minecraft:audio/background_music": {
|
||||
"default": {
|
||||
"max_delay": 24000,
|
||||
"min_delay": 12000,
|
||||
"sound": "minecraft:music.overworld.forest"
|
||||
}
|
||||
},
|
||||
"minecraft:gameplay/natural_mob_spawns": {
|
||||
"argument": {
|
||||
"spawn_costs": {},
|
||||
"spawns_by_category": {
|
||||
"ambient": [
|
||||
{
|
||||
"type": "minecraft:bat",
|
||||
"count": 8,
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"creature": [
|
||||
{
|
||||
"type": "minecraft:sheep",
|
||||
"count": 4,
|
||||
"weight": 12
|
||||
},
|
||||
{
|
||||
"type": "minecraft:pig",
|
||||
"count": 4,
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"type": "minecraft:chicken",
|
||||
"count": 4,
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"type": "minecraft:cow",
|
||||
"count": 4,
|
||||
"weight": 8
|
||||
},
|
||||
{
|
||||
"type": "minecraft:wolf",
|
||||
"count": 4,
|
||||
"weight": 5
|
||||
}
|
||||
],
|
||||
"monster": [
|
||||
{
|
||||
"type": "minecraft:spider",
|
||||
"count": 4,
|
||||
"weight": 100
|
||||
},
|
||||
{
|
||||
"type": "minecraft:zombie",
|
||||
"count": 4,
|
||||
"weight": 95
|
||||
},
|
||||
{
|
||||
"type": "minecraft:zombie_villager",
|
||||
"count": 1,
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"type": "minecraft:skeleton",
|
||||
"count": 4,
|
||||
"weight": 100
|
||||
},
|
||||
{
|
||||
"type": "minecraft:creeper",
|
||||
"count": 4,
|
||||
"weight": 100
|
||||
},
|
||||
{
|
||||
"type": "minecraft:slime",
|
||||
"count": 4,
|
||||
"weight": 100
|
||||
},
|
||||
{
|
||||
"type": "minecraft:enderman",
|
||||
"count": {
|
||||
"type": "minecraft:uniform",
|
||||
"max_inclusive": 4,
|
||||
"min_inclusive": 1
|
||||
},
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"type": "minecraft:witch",
|
||||
"count": 1,
|
||||
"weight": 5
|
||||
}
|
||||
],
|
||||
"underground_water_creature": [
|
||||
{
|
||||
"type": "minecraft:glow_squid",
|
||||
"count": {
|
||||
"type": "minecraft:uniform",
|
||||
"max_inclusive": 6,
|
||||
"min_inclusive": 4
|
||||
},
|
||||
"weight": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"modifier": "overlay"
|
||||
},
|
||||
"minecraft:visual/sky_color": "#79a6ff"
|
||||
},
|
||||
"carvers": [],
|
||||
"downfall": 0.8,
|
||||
"effects": {
|
||||
"water_color": "#3f76e4"
|
||||
},
|
||||
"features": [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
"minecraft:ore_dirt",
|
||||
"minecraft:ore_granite_upper",
|
||||
"minecraft:ore_granite_lower",
|
||||
"minecraft:ore_diorite_upper",
|
||||
"minecraft:ore_diorite_lower",
|
||||
"minecraft:ore_andesite_upper",
|
||||
"minecraft:ore_andesite_lower",
|
||||
"minecraft:ore_tuff",
|
||||
"minecraft:ore_coal_upper",
|
||||
"minecraft:ore_coal_lower",
|
||||
"minecraft:ore_iron_upper",
|
||||
"minecraft:ore_iron_middle",
|
||||
"minecraft:ore_iron_small",
|
||||
"minecraft:ore_gold",
|
||||
"minecraft:ore_gold_lower",
|
||||
"minecraft:ore_redstone",
|
||||
"minecraft:ore_redstone_lower",
|
||||
"minecraft:ore_diamond",
|
||||
"minecraft:ore_diamond_medium",
|
||||
"minecraft:ore_diamond_large",
|
||||
"minecraft:ore_diamond_buried",
|
||||
"minecraft:ore_lapis",
|
||||
"minecraft:ore_lapis_buried",
|
||||
"minecraft:ore_copper"
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
"minecraft:glow_lichen",
|
||||
"minecraft:forest_flowers",
|
||||
"minecraft:trees_birch_and_oak_leaf_litter",
|
||||
"minecraft:patch_bush",
|
||||
"minecraft:flower_default",
|
||||
"minecraft:patch_grass_forest",
|
||||
"minecraft:brown_mushroom_normal",
|
||||
"minecraft:red_mushroom_normal",
|
||||
"minecraft:patch_pumpkin",
|
||||
"minecraft:patch_sugar_cane",
|
||||
"minecraft:patch_firefly_bush_near_water",
|
||||
"sanctuary:shore_sugar_cane"
|
||||
],
|
||||
[
|
||||
"minecraft:freeze_top_layer"
|
||||
]
|
||||
],
|
||||
"has_precipitation": true,
|
||||
"temperature": 0.7
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"default_block": "minecraft:stone",
|
||||
"default_fluid": "minecraft:air",
|
||||
"disable_mob_generation": false,
|
||||
"legacy_random_source": true,
|
||||
"material_rule": "sanctuary:starter_island",
|
||||
"noise": {
|
||||
"height": 384,
|
||||
"min_y": 0
|
||||
},
|
||||
"noise_router": {
|
||||
"chunk_surface_level": 0.0,
|
||||
"continents": 0.0,
|
||||
"depth": 0.0,
|
||||
"erosion": 0.0,
|
||||
"final_density": "sanctuary:final_density_384",
|
||||
"ridges": 0.0,
|
||||
"temperature": 0.0,
|
||||
"vegetation": 0.0
|
||||
},
|
||||
"sea_level": -64,
|
||||
"spawn_target": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"feature": {
|
||||
"type": "sanctuary:shore_sugar_cane"
|
||||
},
|
||||
"placement": []
|
||||
}
|
||||
+2
-2
@@ -6,9 +6,9 @@
|
||||
"type": "minecraft:noise",
|
||||
"biome_source": {
|
||||
"type": "minecraft:fixed",
|
||||
"biome": "sanctuary:starter_forest"
|
||||
"biome": "sanctuary:starter_forest_hydrology"
|
||||
},
|
||||
"settings": "sanctuary:sanctuary_384"
|
||||
"settings": "sanctuary:sanctuary_hydrology"
|
||||
}
|
||||
},
|
||||
"minecraft:the_end": {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"package": "fr.koka.sanctuary.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"mixins": [
|
||||
"InitialSpawnMixin"
|
||||
"InitialSpawnMixin",
|
||||
"SurfaceHydrologyMixin"
|
||||
],
|
||||
"client": [
|
||||
"client.SanctuarySkyMixin",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Determinism and containment checks independent of Minecraft, using controlled floating terrain. */
|
||||
public final class SurfaceHydrologySmoke {
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
|
||||
public static void main(String[] args) {
|
||||
SurfaceHydrology.Sampler terrain = (x, y, z) -> {
|
||||
int top = 200 + (int) Math.round(2 * Math.sin(x / 24.0) + 2 * Math.cos(z / 31.0));
|
||||
return Math.hypot(x, z) < 220 && y <= top && y >= 145 ? 0.45F : -0.45F;
|
||||
};
|
||||
var first = SurfaceHydrology.create(0, terrain);
|
||||
var replay = SurfaceHydrology.create(0, terrain);
|
||||
var changed = SurfaceHydrology.create(42, terrain);
|
||||
require(!first.features().isEmpty(), "The safe rolling surface should contain surface water");
|
||||
require(List.copyOf(first.cells()).equals(List.copyOf(replay.cells())) && first.features().equals(replay.features()),
|
||||
"The same seed and density must replay exactly");
|
||||
require(!List.copyOf(first.cells()).equals(List.copyOf(changed.cells())), "The world seed must influence water placement");
|
||||
verify(first, terrain);
|
||||
verify(changed, terrain);
|
||||
SurfaceHydrology.Sampler pierced = (x, y, z) ->
|
||||
Math.floorMod(x, 37) < 2 && Math.floorMod(z, 41) < 2 ? -0.45F : terrain.sample(x, y, z);
|
||||
var aroundShafts = SurfaceHydrology.create(0, pierced);
|
||||
require(!aroundShafts.features().isEmpty(), "Safe areas around natural shafts should remain usable");
|
||||
verify(aroundShafts, pierced);
|
||||
require(SurfaceHydrology.create(0, (x, y, z) -> -1).cells().isEmpty(), "An empty world must not acquire a guaranteed basin");
|
||||
var roof = SurfaceHydrology.create(0, (x, y, z) ->
|
||||
Math.hypot(x, z) < 220 && (y == 240 || y >= 145 && y <= 200) ? 0.45F : -0.45F);
|
||||
require(roof.cells().isEmpty(), "Do not create underground lakes below a thin higher roof");
|
||||
var slab = SurfaceHydrology.create(0, (x, y, z) ->
|
||||
Math.hypot(x, z) < 220 && y >= 199 && y <= 200 ? 0.45F : -0.45F);
|
||||
require(slab.cells().isEmpty(), "A thin floating slab cannot safely support water");
|
||||
var flat = SurfaceHydrology.create(0, (x, y, z) ->
|
||||
Math.hypot(x, z) < 220 && y >= 145 && y <= 200 ? 0.45F : -0.45F);
|
||||
require(flat.features().stream().anyMatch(feature -> feature.kind() == SurfaceHydrology.Kind.STREAM),
|
||||
"The controlled safe plateau must exercise the stream planner");
|
||||
System.out.println("SurfaceHydrologySmoke: deterministic lakes/stream, sealed floors/walls, chunk order, no roof/void/thin-slab water passed.");
|
||||
}
|
||||
|
||||
private static void verify(SurfaceHydrology.Plan plan, SurfaceHydrology.Sampler terrain) {
|
||||
Map<String, SurfaceHydrology.Cell> byChunks = new HashMap<>();
|
||||
// Reverse chunk traversal must reconstruct the same cells without generating neighbours.
|
||||
for (int cx = 12; cx >= -12; cx--) for (int cz = 12; cz >= -12; cz--) {
|
||||
var chunk = plan.cellsInChunk(cx, cz);
|
||||
SurfaceHydrology.Cell previous = null;
|
||||
for (var cell : chunk) {
|
||||
require(cell.x() >> 4 == cx && cell.z() >> 4 == cz, "A chunk slice writes outside its chunk");
|
||||
require(previous == null || previous.x() < cell.x() || previous.x() == cell.x() && previous.z() < cell.z(),
|
||||
"Cell order must be stable for seeded shore vegetation");
|
||||
previous = cell;
|
||||
require(byChunks.put(cell.x() + "," + cell.z(), cell) == null, "A cell belongs to multiple chunk slices");
|
||||
}
|
||||
}
|
||||
require(byChunks.size() == plan.cells().size(), "Chunk slices omitted planned cells");
|
||||
for (var cell : plan.cells()) {
|
||||
require(cell.equals(byChunks.get(cell.x() + "," + cell.z())), "Chunk traversal changed the plan");
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
require(terrain.sample(cell.x(), y, cell.z()) > 0, "Bed material requires three existing solid blocks");
|
||||
}
|
||||
if (!cell.hasWater()) continue;
|
||||
require(cell.waterY() - cell.bedY() >= 1 && cell.waterY() - cell.bedY() <= 4, "Invalid shallow-water depth");
|
||||
require(cell.carveTop() - cell.bedY() <= SurfaceHydrology.MAX_CARVE, "Excavation exceeds six blocks");
|
||||
for (int y = cell.carveTop() + 1; y < 384; y++) {
|
||||
require(terrain.sample(cell.x(), y, cell.z()) <= 0, "Water placed below a natural roof");
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
var neighbor = plan.cellAt(x, z);
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
boolean plannedWater = neighbor != null && neighbor.hasWater()
|
||||
&& y > neighbor.bedY() && y <= neighbor.waterY();
|
||||
boolean carvedAir = neighbor != null && y > neighbor.bedY() && y <= neighbor.carveTop()
|
||||
&& !plannedWater;
|
||||
require(plannedWater || !carvedAir && terrain.sample(x, y, z) > 0,
|
||||
"A water cell has an open horizontal outlet, including excavated banks");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -51,7 +51,7 @@ Pour produire un fichier importable par un lanceur compatible Modrinth :
|
||||
|
||||
```sh
|
||||
cd build/packwiz # depuis la racine du dépôt
|
||||
packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.3.mrpack
|
||||
packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.4.mrpack
|
||||
```
|
||||
|
||||
L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "0.1.0-alpha.3"
|
||||
version = "0.1.0-alpha.4"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user