Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e94c1c539 | ||
|
|
40168e2054 | ||
|
|
ebcdb17a11 | ||
|
|
40463329f1 |
@@ -1,5 +1,50 @@
|
||||
# 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
|
||||
64 blocs et un dessous sculpté pour remplacer l'ancien fondu horizontal.
|
||||
- Nuages relevés et suppression du disque sombre de l'horizon et du
|
||||
noircissement d'altitude propres au vide dans ces nouveaux mondes.
|
||||
- Anciens paramètres de monde conservés : aucune translation de blocs ou
|
||||
modification de la hauteur des sauvegardes existantes.
|
||||
- Premier champ de recherche climatique documenté : nord froid, sud chaud,
|
||||
ouest sec, est humide et leurs combinaisons dans les quatre diagonales.
|
||||
- Relevé de développement des blocs et ressources après génération, avec
|
||||
emprise et complétude explicites. Aucun quota n'influence le terrain.
|
||||
|
||||
Les étangs, lacs de surface, rivière, berges à canne à sucre et plages de l'île
|
||||
tempérée restent à implémenter. Le contrat de cette hydrologie et de l'expansion
|
||||
est dans [Expansion et ressources](docs/expansion.md).
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la hauteur et le ciel de l'alpha.3.
|
||||
Le pack se met à jour dans la même instance Prism.
|
||||
|
||||
Validation : build et pack réussis, 8 tests serveur réussis sur chacune des
|
||||
graines 0, 42 et 8675309, injections de rendu vérifiées dans le vrai client.
|
||||
Le relevé complet de la graine 0 et les limites des essais sont documentés dans
|
||||
[Validation](docs/testing.md) et [Expansion et ressources](docs/expansion.md).
|
||||
|
||||
## 0.1.0-alpha.2 — 2026-09-08
|
||||
|
||||
- Suppression du noyau elliptique qui ajoutait une masse artificielle au centre.
|
||||
|
||||
@@ -26,13 +26,23 @@ décrits dans la vision ne sont pas encore implémentés.
|
||||
terrain reprennent l'approche du générateur 26.2, adaptée à l'API 26.3.
|
||||
- Spawn recherché sur le sol de l'île lors de la création. Les mondes ordinaires
|
||||
et les choix ultérieurs de spawn ne sont pas remplacés.
|
||||
- Nouveaux mondes sur 384 blocs (`Y=0..383`), île relevée de 64 blocs,
|
||||
dessous sculpté et nuages au-dessus du terrain. Les anciennes sauvegardes
|
||||
conservent leurs paramètres de génération.
|
||||
- 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,
|
||||
ouest sec et est humide, avec leurs combinaisons diagonales. Un outil de
|
||||
développement relève les ressources après génération ; le système d'expansion
|
||||
en jeu reste à construire. Voir [Expansion et ressources](docs/expansion.md).
|
||||
|
||||
Les détails du terrain et de ses limites sont dans [Génération](docs/worldgen.md),
|
||||
et la provenance dans [Audit 26.2](docs/migration-26.2.md).
|
||||
|
||||
@@ -50,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.2 |
|
||||
| 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
|
||||
@@ -77,7 +87,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.2.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).
|
||||
|
||||
@@ -80,6 +80,12 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
|
||||
**Critères d'acceptation :**
|
||||
|
||||
- Un schéma décrit au minimum l'identifiant, le centre ou la direction et la distance, les dimensions, l'altitude et la seed du continent.
|
||||
- Le premier champ de recherche utilise huit directions climatiques : nord froid,
|
||||
sud chaud, ouest sec, est humide ; les diagonales combinent ces tendances.
|
||||
Chaque candidat expose sa signature et sa distribution de température/humidité.
|
||||
- La sélection climatique ne force ni la forme du terrain ni des quotas de
|
||||
ressources. Un relevé après génération identifie les stocks réellement produits,
|
||||
son emprise, sa version et son niveau de complétude. Voir [le contrat](expansion.md).
|
||||
- Une configuration ou commande de développement documentée crée un continent reproductible.
|
||||
- Les paramètres invalides et les chevauchements interdits sont rejetés avec un message compréhensible.
|
||||
- Des limites de taille et de coût de génération sont définies à partir d'une mesure réelle.
|
||||
@@ -109,10 +115,17 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
|
||||
|
||||
- Un jeu de seeds de référence montre des reliefs, montagnes, ravins et perforations traversantes.
|
||||
- Des bassins accueillent lacs ou océans, et un premier type de rivière flottante est démontré.
|
||||
- Pour l'île initiale tempérée et légèrement humide : privilégier petits étangs,
|
||||
lacs de surface, rivière et berges propices à la canne à sucre, avec quelques
|
||||
plages plus sèches. Pas de grands bassins souterrains remplis d'eau.
|
||||
- L'eau ne se répand pas de manière incontrôlée dans le vide lors du chargement et des mises à jour de blocs du scénario testé.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Climats, expansion et ressources
|
||||
|
||||
Ce document fixe les décisions de conception pour la suite de Sanctuary. Le
|
||||
registre persistant des continents, leur déblocage et leur génération dans les
|
||||
huit directions restent à implémenter. Le relevé de ressources décrit plus bas
|
||||
est un outil de développement séparé, exécuté dans le monde jetable des GameTests.
|
||||
|
||||
## Les huit directions climatiques
|
||||
|
||||
Sanctuary Island constitue le point de départ **tempéré, légèrement humide**.
|
||||
Le nord conduit vers le froid, le sud vers le chaud, l'ouest vers le sec et
|
||||
l'est vers l'humide. Les diagonales combinent ces tendances.
|
||||
|
||||
| Direction | Axe Minecraft depuis Sanctuary | Tendance climatique |
|
||||
| --- | --- | --- |
|
||||
| Nord | `−Z` | Froid |
|
||||
| Nord-est | `+X, −Z` | Froid et humide |
|
||||
| Est | `+X` | Humide |
|
||||
| Sud-est | `+X, +Z` | Chaud et humide |
|
||||
| Sud | `+Z` | Chaud |
|
||||
| Sud-ouest | `−X, +Z` | Chaud et sec |
|
||||
| Ouest | `−X` | Sec |
|
||||
| Nord-ouest | `−X, −Z` | Froid et sec |
|
||||
|
||||
Ces directions définissent des familles de climats, pas huit biomes uniformes.
|
||||
Le relief, l'altitude, les variations locales et la graine doivent conserver
|
||||
des géographies distinctes à l'intérieur de chaque continent. Les amplitudes
|
||||
de température et d'humidité, leur évolution avec la distance et les seuils
|
||||
de sélection des biomes feront partie du ticket de génération des continents.
|
||||
Les ressources et cultures futures, notamment celles d'It's Alive !, pourront
|
||||
ainsi demander l'exploration de plusieurs climats.
|
||||
|
||||
L'île initiale utilise encore un biome de départ spécifique. Une température
|
||||
ou une humidité du biome ne constitue pas à elle seule une carte climatique
|
||||
directionnelle : cette carte et la sélection des biomes des continents restent
|
||||
des travaux distincts.
|
||||
|
||||
## Eau de surface et exploration
|
||||
|
||||
L'île de départ doit pouvoir accueillir naturellement des étangs et lacs de
|
||||
surface, des berges propices à la canne à sucre et, lorsque le relief s'y prête,
|
||||
des rivières. L'eau s'inscrit dans les creux et les chemins du terrain existant.
|
||||
Les rivières flottantes et les cascades doivent tenir compte de la continuité
|
||||
de leur lit et du vide sous les îles.
|
||||
|
||||
Le contrat ne demande ni un bassin souterrain imposé pour obtenir un stock
|
||||
d'eau, ni une cuvette ajoutée systématiquement à une position fixe. Une surface
|
||||
d'eau doit reposer sur un fond naturel et disposer de berges cohérentes ; un
|
||||
volume rocheux fermé sous terre ne devient pas un lac de surface. Les plantations
|
||||
de canne à sucre doivent respecter leurs conditions de placement, sans modifier
|
||||
la carte pour atteindre une quantité prédéterminée.
|
||||
|
||||
Un étang, un lac, une rivière et un bassin souterrain sont donc des résultats
|
||||
différents à vérifier. Un prototype d'étangs ne valide pas automatiquement les
|
||||
rivières ou l'ensemble de l'hydrologie. Le détail effectivement livré à chaque
|
||||
version reste décrit dans [Génération](worldgen.md), avec ses limites.
|
||||
|
||||
## Observer les ressources après génération
|
||||
|
||||
La génération produit le monde ; l'économie apprend ensuite ce que le monde
|
||||
contient. On ne force pas un nombre de blocs de pierre, de bûches ou de minerais
|
||||
pour faire correspondre la géographie à un budget. Un relevé peut constater
|
||||
qu'une ressource est absente, rare ou abondante pour une graine donnée. Ce constat
|
||||
ne déclenche pas de remplissage ni de remplacement de blocs.
|
||||
|
||||
Le relevé initial servira de référence pour préparer l'économie et la progression
|
||||
collective. Il distingue les blocs présents, les ressources réellement accessibles
|
||||
aux joueurs, les ressources renouvelables et les objets obtenus en les récoltant.
|
||||
Un bloc de minerai n'est pas un nombre garanti d'objets : outils, enchantements,
|
||||
butins et transformations relèvent d'autres règles. Les blocs de bois ne comptent
|
||||
pas des arbres, et un comptage de blocs d'eau ne mesure pas un débit de rivière.
|
||||
|
||||
Une observation dans une petite zone reste une observation locale. On ne peut
|
||||
pas multiplier son résultat pour annoncer la quantité totale de l'île. Les
|
||||
rapports de continents devront conserver leur propre graine, version, emprise
|
||||
et état de génération, afin d'additionner seulement des régions connues et
|
||||
disjointes lorsque le besoin économique sera défini.
|
||||
|
||||
## Relevé de développement disponible
|
||||
|
||||
`ResourceSurveyDiagnostics`, dans `src/gametest`, lit les chunks au statut
|
||||
`FULL`, après le terrain, les matériaux et les décorations. Il ne figure pas
|
||||
dans le JAR Sanctuary distribué et n'ajoute aucune commande joueur ou admin.
|
||||
La tâche repart d'un monde GameTest neuf, jamais d'une sauvegarde personnelle.
|
||||
|
||||
Le relevé standard inspecte **neuf chunks**, soit un carré de 48×48 blocs autour
|
||||
du chunk du spawn naturel, sur toute la hauteur de la dimension. Il prépare
|
||||
d'abord ces chunks et un halo voisin, puis compte exactement chaque cellule des
|
||||
palettes de leurs sections. La grille éloignée du framework est exclue et sa
|
||||
distance est vérifiée. Aucun objet posé, inventaire, contenu de coffre ou mob
|
||||
n'est inclus dans ces comptes de blocs.
|
||||
|
||||
```sh
|
||||
# Contrôle standard : neuf chunks inspectés, total local uniquement.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=sample --console=plain
|
||||
|
||||
# Relevé de toute l'enveloppe déclarée de Sanctuary, sur un monde jetable neuf.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
|
||||
|
||||
# Désactiver seulement le relevé pour un autre diagnostic.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=off --console=plain
|
||||
```
|
||||
|
||||
Les modes sont transmis à la propriété JVM de test
|
||||
`sanctuary.test.resourceSurvey`. Le mode `island` couvre **1 444 chunks** :
|
||||
`X` et `Z` de **−304 inclus à 304 exclu**, correspondant à l'enveloppe de terrain
|
||||
de rayon 288 et une marge de décoration de 16 blocs. Un halo d'un chunk porte
|
||||
la préparation à **1 600 chunks FULL** ; seuls les 1 444 chunks demandés sont
|
||||
comptés. Ce relevé complet est plus coûteux et ne s'exécute pas par défaut.
|
||||
La marge devra être revue si une future génération peut produire des structures
|
||||
ou décorations au-delà de cette enveloppe.
|
||||
|
||||
Le JSON est écrit dans
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/resource-survey-seed-<graine>-<mode>.json`.
|
||||
Il contient :
|
||||
|
||||
- La graine, la version Minecraft, le preset et les réglages de génération,
|
||||
ainsi que `generator.version = sanctuary@<version du mod>`.
|
||||
- Le spawn naturel, les bornes exactes en blocs et chunks, les hauteurs de la
|
||||
dimension et du bruit, les coordonnées de chaque chunk inspecté au statut
|
||||
`FULL`, les dates UTC et les durées de préparation et de comptage.
|
||||
- Le total de toutes les cellules, les cellules d'air, les blocs non vides,
|
||||
puis les comptes par identifiant de registre et par état complet, propriétés
|
||||
comprises, par exemple `minecraft:oak_log[axis=y]`.
|
||||
- Des agrégats explicites pour la pierre, les bûches, les feuilles, les minerais
|
||||
de charbon, fer, cuivre, or, redstone, lapis, diamant et émeraude, l'eau, la
|
||||
lave, le sable, le gravier, l'argile, la canne à sucre et les sols.
|
||||
- La complétude dans les bornes demandées, la couverture ou non de l'enveloppe
|
||||
déclarée de l'île et l'absence d'extrapolation. Aucun mode ne prétend inventorier
|
||||
tout le monde Minecraft, ses autres dimensions ou ses futures expansions.
|
||||
|
||||
Les variantes ordinaires et deepslate des minerais sont détaillées. Les agrégats
|
||||
fondés sur des tags indiquent leur définition et les membres observés. Certains
|
||||
groupes se recouvrent, comme `stone` et `base_stone_overworld`, ou les blocs
|
||||
d'eau et les blocs contenant un état d'eau : ils ne doivent pas être additionnés.
|
||||
Les sommes des identifiants et des états doivent chacune être exactement égales
|
||||
au volume inspecté, y compris l'air ; une incohérence fait échouer le GameTest.
|
||||
|
||||
Ce rapport est un instantané après génération, avec l'heure du jeu observée.
|
||||
Il ne prédit pas les pousses, les récoltes, les pertes ni la production future.
|
||||
Le format JSON est versionné pour l'outil de test ; ce n'est pas encore un
|
||||
format de sauvegarde serveur ou un registre persistant de l'économie.
|
||||
|
||||
## Référence mesurée — graine 0, alpha.3
|
||||
|
||||
Le **8 septembre 2026**, un relevé complet de l'enveloppe déclarée a été effectué
|
||||
sur un monde neuf de graine **0**, avec `sanctuary@0.1.0-alpha.3` et les réglages
|
||||
`sanctuary:sanctuary_384`. Il couvre **1 444 chunks FULL**, de **−304 inclus à
|
||||
304 exclu en X et Z**, et de **0 inclus à 384 exclu en Y**. Le volume inspecté
|
||||
est donc exactement **608 × 608 × 384 = 141 950 976 cellules**.
|
||||
|
||||
| Ressource observée | Nombre de blocs |
|
||||
| --- | ---: |
|
||||
| Pierre (`minecraft:stone`) | 7 355 184 |
|
||||
| Bûches, tag `minecraft:logs` | 10 946 |
|
||||
| Minerai de charbon | 101 399 |
|
||||
| Minerai de fer | 67 198 |
|
||||
| Minerai de cuivre | 2 352 |
|
||||
| Minerai de diamant | 6 |
|
||||
| Minerais d'or, redstone, lapis et émeraude | 0 chacun |
|
||||
| Gravier | 121 044 |
|
||||
| Terre et blocs d'herbe | 728 630 |
|
||||
| Feuilles | 117 735 |
|
||||
| Eau, y compris les blocs contenant de l'eau | 0 |
|
||||
| Canne à sucre | 0 |
|
||||
| Sable, argile et lave | 0 chacun |
|
||||
|
||||
Les bûches comprennent **8 765 bûches de chêne** et **2 181 de bouleau**.
|
||||
Les minerais présents sont ici leurs variantes ordinaires ; aucune variante
|
||||
deepslate n'a été observée. Toutes catégories confondues, le relevé compte
|
||||
**8 584 514 blocs non vides** et **133 366 462 cellules d'air**. Ce sont les
|
||||
quantités observées dans cette graine et cette version, sans quota ni correction
|
||||
du terrain. Les zéros d'eau et de canne à sucre confirment que l'hydrologie
|
||||
demandée reste à réaliser ; ils ne constituent pas une cible à conserver.
|
||||
|
||||
La source détaillée est le fichier local non versionné
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/resource-survey-seed-0-island.json`,
|
||||
terminé à **11:34:08 UTC**. Une lecture indépendante du JSON a confirmé les
|
||||
1 444 coordonnées de chunks distinctes, les sommes par chunk, les agrégats et
|
||||
l'égalité exacte entre le volume, le compte par identifiant et le compte par
|
||||
état complet. Les huit tests requis du serveur ont réussi pendant cette exécution.
|
||||
|
||||
La préparation des chunks a pris **782 659 ms**, soit **13 min 2,659 s** ; le
|
||||
comptage et la préparation des données à sérialiser ont pris **135 ms**. Cette
|
||||
mesure locale a été effectuée sur un **Apple M1 avec 8 Go de mémoire**, avec un
|
||||
essai client concurrent pendant environ 23 secondes. Elle décrit une machine
|
||||
chargée et cette exécution, pas une performance garantie sur d'autres machines.
|
||||
Le temps de préparation est distinct du coût du comptage par palettes.
|
||||
|
||||
## Contrat du prochain ticket d'expansion
|
||||
|
||||
Avant toute activation de continent, enregistrer côté serveur son identifiant,
|
||||
sa direction parmi les huit, son centre, son emprise, sa graine dérivée, sa
|
||||
version de génération, son profil climatique et son état de déblocage.
|
||||
Le relevé des ressources intervient après la génération effective de sa région,
|
||||
avec une complétude explicite, indépendamment des contributions qui l'ont ouverte.
|
||||
|
||||
Les zones déjà explorées dans le vide peuvent contenir des chunks sauvegardés
|
||||
ou des constructions. L'activation exige donc un contrat de réservation ou de
|
||||
matérialisation qui les protège ; elle ne peut pas régénérer silencieusement
|
||||
les chunks existants. Les règles de contribution, distances, tailles,
|
||||
réservations, migrations et prix ne sont pas décidées par ce diagnostic.
|
||||
+11
-5
@@ -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.2.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.2/sanctuary-0.1.0-alpha.2.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
|
||||
@@ -98,7 +98,10 @@ Commande à coller dans l'interface Prism :
|
||||
```
|
||||
|
||||
Une modification directe de `instance.cfg` doit échapper les guillemets au format
|
||||
Qt INI (`\"`) et activer `OverrideCommands=true`. Le ZIP préparé le fait déjà.
|
||||
Qt INI (`\"`) et activer `OverrideCommands=true`. Ces clés et
|
||||
`PreLaunchCommand` doivent être dans la section **`[General]`**, jamais ajoutées
|
||||
après la section `[UI]`. Le ZIP préparé le fait déjà. Rouvrir les commandes
|
||||
personnalisées dans Prism pour vérifier que la commande est effectivement lue.
|
||||
Une instance importée d'un `.mrpack` peut être marquée `ManagedPack=true` :
|
||||
dès que packwiz en assure les mises à jour, utiliser `ManagedPack=false` pour
|
||||
éviter deux mécanismes de gestion sur le même pack.
|
||||
@@ -110,8 +113,11 @@ déclare ni `saves/`, ni `options.txt`, `servers.dat`, `screenshots/`, ni les pa
|
||||
graphiques personnels. L'installation de packwiz et une mise à jour des mods
|
||||
n'autorisent pas à supprimer ou régénérer un monde.
|
||||
|
||||
La nouvelle sculpture de terrain de l'alpha.2 se teste dans un **nouveau monde**
|
||||
avec le preset **Sanctuary**. Les détails des versions de génération sont dans
|
||||
La hauteur de 384 blocs, le dessous sculpté et le ciel de l'alpha.3 se testent
|
||||
dans un **nouveau monde** avec le preset **Sanctuary**. Les anciens mondes gardent
|
||||
leurs identifiants de génération et leurs hauteurs. L’hydrologie de l’alpha.4
|
||||
nécessite elle aussi un nouveau monde Sanctuary ; la mise à jour de l’instance
|
||||
n’ajoute pas d’eau aux sauvegardes existantes. Les 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.
|
||||
|
||||
|
||||
+161
-7
@@ -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 quatre 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
|
||||
@@ -63,8 +69,21 @@ Les quatre tests Sanctuary vérifient :
|
||||
3. La densité du datapack réellement chargé, compilée par `RandomState`, est
|
||||
reproductible pour la seed `0` et change pour la seed `8675309`.
|
||||
4. La densité compilée des graines `0`, `42` et `8675309` ne crée aucune matière
|
||||
là où le terrain source est vide. Les trous dans l'ancien noyau restent vides,
|
||||
et chaque graine conserve du terrain dans l'échantillon de l'île.
|
||||
là où le terrain source est vide. Le relief supérieur reste celui de l'alpha.2
|
||||
translaté de 64 blocs et le dessous varie avec le terrain et les bruits.
|
||||
5. Le nouveau monde et son bruit utilisent bien 384 blocs, à partir de Y=0,
|
||||
tandis que les anciens paramètres restent à 256 blocs. Les nuages sont
|
||||
au-dessus de l'île, à Y=352,33.
|
||||
6. Les chunks complets inspectés gardent une marge vide sous l'île, à Y=0..15.
|
||||
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
|
||||
@@ -79,9 +98,8 @@ des PNG et CSV dans `mods/sanctuary/build/run/gameTest/diagnostics/` :
|
||||
|
||||
- `island-density-seed-<graine>.png` : vue de dessus et coupes centrales X/Z,
|
||||
avant/après, calculées avec les fonctions de densité réellement compilées.
|
||||
La colonne « alpha.1 » reconstitue l'ancienne formule du noyau et du bord en
|
||||
utilisant les bruits actuels ; elle ne reproduit pas exactement l'ancien bruit
|
||||
de bord. La colonne de droite est la densité actuelle.
|
||||
La colonne « alpha.2 » utilise ses anciennes ressources conservées ; la colonne
|
||||
de droite utilise la densité alpha.3, sur toute la hauteur Y=0..383.
|
||||
- `island-heightmap-seed-<graine>.csv` : coordonnées et hauteurs échantillonnées
|
||||
des deux versions (`-1` indique une colonne vide dans cet échantillon).
|
||||
|
||||
@@ -103,7 +121,143 @@ 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`.
|
||||
|
||||
## Validation alpha.2 — 8 septembre 2026
|
||||
## 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
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true --console=plain
|
||||
```
|
||||
|
||||
Ce test charge les vrais `SkyRenderer` et `FogRenderer` et vérifie l'application
|
||||
des deux mixins, puis ferme automatiquement le client sans ouvrir de sauvegarde.
|
||||
Il nécessite un environnement graphique ; `check` ne le lance pas. Ses sources
|
||||
dans `src/clientGametest/java` ne sont ajoutées au mod de test que lorsque la
|
||||
propriété est activée. Le test de transformation ne remplace pas l'essai visuel
|
||||
de l'horizon dans un nouveau monde Sanctuary.
|
||||
|
||||
Pour cet essai visuel en monde de développement créatif, comparer la même vue
|
||||
à Y=70, 62, 32, 8 puis −8. Il ne doit apparaître ni disque noir au passage de
|
||||
Y=63 ni assombrissement d'altitude au passage de Y=32. Les brouillards de l'eau,
|
||||
de la météo et des effets d'aveuglement ou d'obscurité doivent rester présents.
|
||||
|
||||
## Validation alpha.4 — 8 septembre 2026
|
||||
|
||||
Les tests serveur utilisent le preset de production et des mondes neufs. Sur
|
||||
la graine `0`, le scénario exige un véritable site `STREAM` ; les autres graines
|
||||
vérifient la continuité de chaque cours d’eau qu’elles produisent, sans exiger
|
||||
qu’une graine arbitraire soit artificiellement complétée.
|
||||
|
||||
| Graine | Spawn naturel | Eau après 200 ticks | Canne à sucre | Chunks FULL inspectés | Raccords d’eau aux frontières |
|
||||
| --- | --- | ---: | ---: | ---: | ---: |
|
||||
| `0` | `(0, 251, -4)` | 1 938 | 15 | 94 | 253 |
|
||||
| `42` | `(-4, 249, -4)` | 1 501 | 24 | 94 | 213 |
|
||||
| `8675309` | `(0, 247, 0)` | 1 551 | 12 | 90 | 190 |
|
||||
|
||||
Pour la graine `0`, le cours d’eau est à **Y=250**, de **X=−48, Z=48** à
|
||||
**X=−20, Z=−20**. Son chemin suit 18 points et relie deux petites poches d’eau.
|
||||
Deux petits lacs et quatre étangs sont également présents, en comptant la poche
|
||||
de départ du cours d’eau. La graine `42` produit elle aussi un cours d’eau, à
|
||||
Y=221, de `(−72, 24)` à `(−60, −40)` en X/Z. Ce sont des observations de ces
|
||||
graines, pas un minimum promis pour toutes les îles.
|
||||
|
||||
**10/10 tests requis réussissent sur chacune des trois graines**, avec un cours
|
||||
d’eau observé sur chacune. Les contrôles comparent chaque bloc d’eau avant et après une simulation réelle
|
||||
confirmée par un témoin d’écoulement et l’activité de tous les chunks concernés.
|
||||
Les quantités d’eau restent identiques. Les cartes des vrais blocs ont été
|
||||
inspectées : les lacs sont arrondis et les chenaux restent continus. Le tracé
|
||||
des cours d’eau comporte encore des segments géométriques ; l’appréciation du
|
||||
relief, des berges et de leur aspect dans le client reste à faire par le joueur.
|
||||
Les cartes masquent la canopée et ne remplacent pas une vue en jeu.
|
||||
|
||||
Le plan initial prend **12 060 ms** pour la graine `0` et **8 743 ms** pour `42`
|
||||
et **9 755 ms** pour `8675309`, sur le poste Apple M1 à 8 Go utilisé pendant ces
|
||||
essais. Il est calculé une seule
|
||||
fois par état aléatoire de monde, sans charger les chunks voisins. La préparation
|
||||
des 94 chunks FULL de chaque diagnostic est distincte du calcul du plan et
|
||||
ne représente pas le travail requis à chaque lancement de partie.
|
||||
|
||||
Les trois exécutions serveur réussies ont été suivies de
|
||||
`./gradlew check build assemblePack -x :sanctuary:runGameTest` : le serveur déjà
|
||||
validé séparément n’est pas relancé à l’identique. Les tests de forme et
|
||||
d’hydrologie pure, la compilation et l’assemblage du pack réussissent en 2 s.
|
||||
Le JAR ne contient aucune classe de test, et le `.mrpack` contient exactement
|
||||
ce même JAR, avec les versions Minecraft et Fabric attendues.
|
||||
|
||||
Journaux : `build/alpha4-hydrology-v2-seed0.log` et
|
||||
`build/alpha4-validation-seed42.log`, `build/alpha4-validation-seed8675309.log`
|
||||
et `build/alpha4-build.log`. Les exports correspondants sont dans
|
||||
le dossier `diagnostics` décrit plus haut. Les cartes de densité, inchangées
|
||||
par l’hydrologie, ont été exportées lors des premières exécutions de ce ticket ;
|
||||
les assertions de densité restent actives pendant chaque validation.
|
||||
|
||||
## Validation alpha.3 — 8 septembre 2026
|
||||
|
||||
Sur Java 25 / Minecraft 26.3-pre-2 / Fabric Loader 0.19.5 :
|
||||
|
||||
| Vérification | Résultat |
|
||||
| --- | --- |
|
||||
| `check build assemblePack` avec relevé complet, graine `0` | 8/8 tests requis réussis ; 14 min 49 s, dont le relevé complet |
|
||||
| Serveur neuf, graine `42` | 8/8 réussis ; 1 min 3 s |
|
||||
| Serveur neuf, graine `8675309` | 8/8 réussis ; 1 min 4 s |
|
||||
| Test client facultatif | Réussi en 23 s ; les deux injections de rendu sont appliquées |
|
||||
|
||||
La graine `0` place le spawn naturel en `(0, 251, -4)` ; la graine `42` en
|
||||
`(0, 249, 0)` et la graine `8675309` en `(0, 247, 0)`. Les tests confirment les 384 blocs du nouveau monde, les nuages
|
||||
relevés, le vide inférieur et extérieur et la conservation des anciens réglages.
|
||||
Les coupes des trois graines ont été inspectées : l'ancienne silhouette est
|
||||
relevée et le dessous devient irrégulier. Une comparaison supplémentaire de
|
||||
1 039 155 échantillons de l'ancienne géométrie refactorée donne des densités
|
||||
identiques bit à bit à l'alpha.2.
|
||||
|
||||
Le relevé complet constate **8 584 514 blocs non vides** dans les 1 444 chunks
|
||||
de l'enveloppe définie. Sa préparation prend 13 min 2,659 s, son comptage et sa
|
||||
préparation JSON 135 ms, sur un Apple M1 avec 8 Go de mémoire. Ce poste exécute
|
||||
d'autres applications et a aussi exécuté le test client pendant le relevé :
|
||||
c'est une mesure locale, pas une promesse de performance. Les quantités exactes
|
||||
et les exclusions figurent dans [la référence de ressources](expansion.md).
|
||||
|
||||
Les journaux sont `build/alpha3-validation-seed0.log`,
|
||||
`build/alpha3-validation-seed42.log`, `build/alpha3-validation-seed8675309.log`
|
||||
et `build/alpha3-client-test.log`. Le JAR alpha.3 a été inspecté : aucune classe
|
||||
de test n'est distribuée. Le `.mrpack` contient exactement le JAR vérifié.
|
||||
|
||||
Le test client valide le chargement et l'application des mixins. Il n'ouvre
|
||||
aucun monde et ne remplace donc pas l'essai visuel en vol décrit plus haut.
|
||||
|
||||
## Historique : validation alpha.2 — 8 septembre 2026
|
||||
|
||||
Commande `./gradlew check build assemblePack --console=plain` réussie en **1 min 10 s**
|
||||
sur Java **25**, Minecraft **26.3-pre-2**, Fabric Loader **0.19.5**, Fabric API
|
||||
|
||||
@@ -50,6 +50,27 @@ Les intégrations communautaires envisagées comprennent Fabric API, Sodium, Iri
|
||||
|
||||
L'île de Sanctuary est le point de départ commun. Elle flotte dans un vide qui ne doit pas être rempli par la génération habituelle du monde. Les continents suivants sont de vastes terres suspendues, créées progressivement selon une taille et une direction maîtrisées.
|
||||
|
||||
L'île initiale doit être **tempérée et légèrement humide**, avec quelques zones
|
||||
plus sèches, notamment des plages. Une rivière et de petits étangs ou lacs de
|
||||
surface peuvent accueillir de la canne à sucre. Les grands bassins souterrains
|
||||
remplis d'eau ne sont pas retenus pour cette île. Le terrain doit laisser voir un
|
||||
dessous sculpté et dégagé, dans un monde de **384 blocs de hauteur**, avec des
|
||||
nuages relevés et sans disque noir artificiel apparaissant sous un seuil d'altitude.
|
||||
|
||||
Le premier champ de recherche des expansions associe les **huit directions**
|
||||
autour de Sanctuary à des signatures de température et d'humidité. En regardant
|
||||
la carte avec le nord en haut : **nord froid, sud chaud, ouest sec et est humide**.
|
||||
Les diagonales combinent ces tendances. La recherche porte sur des climats et
|
||||
leurs distributions, sans imposer la même forme de continent à chaque direction.
|
||||
|
||||
L'économie naturelle d'une île doit être connue **après sa génération** : nombre
|
||||
de blocs de pierre, bûches, minerais de charbon, de fer et autres ressources,
|
||||
avec une emprise et un état de référence précis. Ce relevé constate ce que la
|
||||
génération a produit ; il ne force pas des quotas de ressources et ne choisit pas
|
||||
une seed pour atteindre un stock prédéterminé. Un relevé partiel doit rester
|
||||
identifié comme tel. Le contrat de recherche et de relevé est détaillé dans
|
||||
[Expansion et ressources](expansion.md).
|
||||
|
||||
Leur géographie doit rester variée : perforations et passages de vide, océans et lacs retenus dans le terrain, rivières flottantes, montagnes, ravins et reliefs lisibles depuis les airs. Le système doit pouvoir conserver les constructions et les chunks déjà explorés lorsqu'une nouvelle expansion est ouverte.
|
||||
|
||||
L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue TerraMix d'Another World, avec des biomes originaux comme le **Black Desert**. Cette diversité sera introduite après la validation du terrain de base et de son adaptation technique.
|
||||
|
||||
+105
-19
@@ -27,22 +27,27 @@ de test et les sauvegardes personnelles doivent rester distincts.
|
||||
- Le générateur reste celui de Minecraft, `minecraft:noise`.
|
||||
- La forme reprend le champ de bruit flottant actif en 26.2 : `old_blended_noise`
|
||||
avec `xz_scale=0.25`, `y_scale=0.25`, `xz_factor=80`, `y_factor=160`, `smear=4`,
|
||||
ainsi que ses deux gradients verticaux. Reliefs, surplombs, trous et fragments
|
||||
dépendent donc de la seed Minecraft.
|
||||
ainsi que son gradient supérieur. Il est échantillonné **64 blocs plus bas**
|
||||
pour relever l'île entière de 64 blocs. Reliefs, surplombs, trous et fragments
|
||||
dépendent de la seed Minecraft. L'ancien gradient inférieur uniforme est
|
||||
remplacé par la sculpture irrégulière décrite ci-dessous.
|
||||
- Le terrain naturel reste libre de laisser un trou à l'origine, de former des
|
||||
surplombs ou de se séparer en fragments. **Aucun noyau ellipsoïdal, cylindre ou
|
||||
plateforme de départ n'est ajouté.** Le centre conserve exactement le champ
|
||||
historique dans un rayon de 128 blocs, entre `Y=32` et `Y=224`.
|
||||
plateforme de départ n'est ajouté.** Au-dessus de `Y=144`, le relief de la
|
||||
génération précédente est conservé dans la densité, simplement relevé de 64
|
||||
blocs. L’hydrologie 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.
|
||||
La côte réelle apparaît plus tôt, selon la densité du terrain et la seed.
|
||||
Il reste du vide permanent au-delà de **288 blocs**, sans répétition d'îles.
|
||||
Les arbres peuvent dépasser la côte rocheuse de quelques blocs.
|
||||
- La roche disparaît progressivement avant `Y=0` et `Y=256` ; il n'y a ni plancher de bedrock,
|
||||
- Les nouveaux mondes utilisent **384 blocs de hauteur**, de `Y=0` à `Y=383`,
|
||||
pour la dimension et le générateur. La roche disparaît avant leurs limites.
|
||||
Il n'y a ni plancher de bedrock,
|
||||
ni mer globale, ni reprise du terrain à distance. Le Nether et l'End restent
|
||||
ceux de Minecraft.
|
||||
- `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.
|
||||
@@ -50,12 +55,12 @@ 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 — génération v2
|
||||
## Sculpture du pourtour — conservée depuis la génération v2
|
||||
|
||||
La version **0.1.0-alpha.2** retire deux contraintes de la génération v1
|
||||
(`0.1.0-alpha.1`) : le noyau central qui remplissait les creux du bruit naturel,
|
||||
@@ -92,10 +97,86 @@ d'entrée maximalement positif, elle est négative dès le rayon 280, avant la
|
||||
limite de sécurité 288. Deux transitions verticales douces protègent aussi les
|
||||
bornes de hauteur, sans découper de dalle à `Y=0` ou `Y=256`.
|
||||
|
||||
**Tester cette version dans un nouveau monde.** Mettre à jour le pack ne remodèle
|
||||
pas les chunks enregistrés ; aucun chunk n'est supprimé ou régénéré par le mod.
|
||||
Continuer à explorer une ancienne sauvegarde avec ce générateur peut juxtaposer
|
||||
les deux versions du terrain. Aucun contrat de migration n'est livré.
|
||||
Ces formules sont toujours utilisées par les sauvegardes de la génération v2.
|
||||
La génération v3 conserve la sculpture latérale dans le repère de l'île relevée.
|
||||
|
||||
## Île relevée et dessous irrégulier — génération v3
|
||||
|
||||
La version **0.1.0-alpha.3** crée les nouveaux mondes avec les paramètres et le
|
||||
type de dimension `sanctuary:sanctuary_384` : `min_y=0`, `height=384` et
|
||||
`logical_height=384`. Le nom du preset sélectionné reste `sanctuary:sanctuary`.
|
||||
Le gain de hauteur n'agrandit pas le rayon horizontal et n'active aucun continent.
|
||||
|
||||
Le bruit naturel et les bruits de sculpture sont échantillonnés à `Y−64`.
|
||||
Le profil supérieur et les bords approuvés en alpha.2 sont ainsi relevés sans
|
||||
changer de seed ou étirer l'île. Sur les graines de régression, les sommets
|
||||
proches de `Y=184` se retrouvent vers `Y=248`, avec de l'espace libre au-dessus.
|
||||
|
||||
Sous l'île, déplacer l'ancien gradient inférieur aurait seulement déplacé son
|
||||
plan de coupe. `floating_archipelago_384` conserve donc le bruit naturel sans
|
||||
ce gradient inférieur. Une nouvelle érosion travaille sur une transition de
|
||||
**72 blocs**, dont l'altitude varie avec le bruit 3D `island_underside` et les
|
||||
deux bruits de sculpture existants. Elle laisse les lobes naturels se poursuivre
|
||||
vers le bas, puis les amincit à différentes hauteurs ; elle n'ajoute ni socle ni
|
||||
surface garantie. La transition n'affecte plus le terrain à partir de `Y=144`.
|
||||
Une marge vide protège le bas de la dimension ; le contrôle des chunks terminés
|
||||
vérifie que la tranche `Y=0..15` ne contient aucun bloc.
|
||||
|
||||
Les champs optionnels du codec `vertical_offset`, `height` et `underside` valent
|
||||
respectivement `0`, `256` et un bruit nul par défaut. Seule la nouvelle fonction
|
||||
`sanctuary:final_density_384` active l'offset `64`, la hauteur `384` et le bruit du
|
||||
dessous. Les anciennes fonctions `final_density`, `floating_archipelago` et les
|
||||
paramètres `sanctuary:sanctuary` restent inchangés : les sauvegardes alpha.2 qui
|
||||
les référencent gardent leur hauteur de génération et leur ancien terrain.
|
||||
|
||||
**Créer un nouveau monde pour tester l'île relevée.** L'actualisation du pack ne
|
||||
convertit pas les sauvegardes existantes ; aucun chunk n'est supprimé, déplacé
|
||||
ou régénéré. Il n'existe pas de migration automatique des anciens mondes vers la
|
||||
dimension de 384 blocs. Changer manuellement leurs références de générateur ou
|
||||
de dimension sort de ce contrat de compatibilité.
|
||||
|
||||
## Hydrologie de surface — génération v4
|
||||
|
||||
La version **0.1.0-alpha.4** conserve la densité, la hauteur et le ciel de
|
||||
l’alpha.3. Les nouveaux mondes sélectionnent les paramètres
|
||||
`sanctuary:sanctuary_hydrology` et le biome `sanctuary:starter_forest_hydrology`.
|
||||
Le type de dimension reste `sanctuary:sanctuary_384`. Les anciens paramètres et
|
||||
le biome `sanctuary:starter_forest` restent disponibles et inchangés : ils
|
||||
n’activent pas cette hydrologie dans les sauvegardes antérieures.
|
||||
|
||||
Un plan calculé depuis le relief et la graine recherche des emplacements de
|
||||
surface pour des étangs, des petits lacs et un premier cours d’eau calme.
|
||||
Le lit peut être légèrement creusé dans la roche existante. Avant d’accepter
|
||||
un emplacement, le plan vérifie le fond et les parois en trois dimensions ainsi
|
||||
que l’ouverture vers le ciel. Un trou profond dans la carte de hauteur ne suffit
|
||||
pas à constituer un lac. Aucune digue ni masse de roche n’est ajoutée pour
|
||||
boucher une fuite, et aucun bassin souterrain n’est rempli.
|
||||
|
||||
Les fonds alternent sable, gravier et argile ; les berges basses peuvent recevoir
|
||||
du sable. Dans ce nouveau biome, les disques de sédiments vanilla et les veines
|
||||
de gravier sont remplacés par ces dépôts de rive et de fond : une feature
|
||||
ultérieure ne doit pas transformer leur support en gravier instable. Les
|
||||
sources de magma sous-marines sont également absentes. Après les arbres et la
|
||||
végétation, de la canne à sucre peut pousser sur
|
||||
les rives qui respectent ses règles de placement vanilla. Le nombre de sites,
|
||||
leur eau et leurs ressources dépendent du terrain : le générateur ne complète
|
||||
pas une graine pour atteindre un quota économique.
|
||||
|
||||
Le plan est indépendant des chunks chargés. Chaque chunk applique uniquement
|
||||
ses propres colonnes après le terrain et avant la décoration, ce qui permet de
|
||||
raccorder un même plan d’eau aux frontières des chunks. Les anciens chunks ne
|
||||
sont jamais retouchés, et aucun travail hydrologique ne se déclenche au simple
|
||||
chargement d’une sauvegarde.
|
||||
|
||||
Cette première version vise de petites eaux retenues et des cours d’eau calmes
|
||||
sur les plateaux. Le cours d’eau est un chenal à niveau constant reliant des
|
||||
plans d’eau ; il ne simule pas encore une pente vers l’aval ou un débit. Les
|
||||
tracés peuvent passer par les diagonales, mais leur eau reste connectée de
|
||||
bloc en bloc. L’excavation est limitée à six blocs, l’eau à quatre blocs de
|
||||
profondeur, avec un fond plein sur trois blocs d’épaisseur, dont deux blocs naturels sous
|
||||
le sédiment. 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
|
||||
|
||||
@@ -105,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
|
||||
@@ -128,8 +210,8 @@ surfaces passent par le registre `worldgen/material_rule`. Les données de cette
|
||||
branche suivent ces formats réels ; copier directement les JSON 26.2 ne suffit
|
||||
pas. Les bruits utilisent également `base_octave`, `octave_count` et
|
||||
`amplitude_modifiers`. Le seul opérateur Java ajouté, `sanctuary:main_island`,
|
||||
compose le terrain et les trois champs de sculpture et impose la limite
|
||||
extérieure après interpolation.
|
||||
compose le terrain et les bruits de sculpture, applique le repère vertical
|
||||
choisi et impose la limite extérieure après interpolation.
|
||||
|
||||
## Vérification
|
||||
|
||||
@@ -155,10 +237,14 @@ JSON invalides et les blocs que des features pourraient ajouter après le calcul
|
||||
de densité. Les injections du serveur de test sont isolées dans
|
||||
`src/gametest` et ne sont pas distribuées dans le mod.
|
||||
|
||||
Les graines de régression de la génération v2 sont **0**, **42** et **8675309**.
|
||||
Les graines de régression des générations v2 et v3 sont **0**, **42** et **8675309**.
|
||||
Le serveur de test exporte leurs cartes de hauteur et coupes de densité dans
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/`. Ces images permettent de
|
||||
contrôler le profil des bords et les anciens volumes forcés ; elles ne montrent
|
||||
comparer la vraie densité alpha.2, toujours chargée, avec la nouvelle île relevée
|
||||
dans toute la hauteur `0..383`. Les assertions vérifient la translation du relief
|
||||
supérieur, la variation des hauteurs du dessous, l'absence de plancher, les
|
||||
384 blocs de génération et la conservation des anciens paramètres 256.
|
||||
Ces images ne montrent
|
||||
pas les arbres ni les matériaux et ne remplacent pas un essai visuel en jeu.
|
||||
|
||||
## Contrat du prochain ticket : expansion
|
||||
|
||||
+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.2
|
||||
pack_version=0.1.0-alpha.2
|
||||
mod_version=0.1.0-alpha.4
|
||||
pack_version=0.1.0-alpha.4
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
@@ -6,16 +6,26 @@ group = rootProject.maven_group
|
||||
version = rootProject.mod_version
|
||||
base { archivesName = 'sanctuary' }
|
||||
|
||||
def clientRenderTests = providers.gradleProperty('sanctuaryClientTests')
|
||||
.map { it.toBoolean() }.getOrElse(false)
|
||||
|
||||
fabricApi {
|
||||
configureTests {
|
||||
createSourceSet = true
|
||||
modId = 'sanctuary-gametest'
|
||||
enableGameTests = true
|
||||
enableClientGameTests = false
|
||||
// Explicit opt-in: the rendering check needs a graphics-capable client.
|
||||
enableClientGameTests = clientRenderTests
|
||||
eula = false
|
||||
}
|
||||
}
|
||||
|
||||
if (clientRenderTests) {
|
||||
sourceSets.named('gametest') {
|
||||
java.srcDir('src/clientGametest/java')
|
||||
}
|
||||
}
|
||||
|
||||
// Every integration run must exercise fresh generation, never stale saved chunks.
|
||||
tasks.register('cleanGameTestWorld', Delete) {
|
||||
delete(layout.buildDirectory.dir('run/gameTest/world'))
|
||||
@@ -23,6 +33,8 @@ tasks.register('cleanGameTestWorld', Delete) {
|
||||
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 {
|
||||
@@ -65,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 }
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Arrays;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
|
||||
/** Opt-in check of production render mixins in the actual client, without opening a save. */
|
||||
public final class SanctuaryClientRenderTests implements FabricClientGameTest {
|
||||
@Override
|
||||
public void runTest(ClientGameTestContext context) {
|
||||
context.runOnClient(client -> {
|
||||
assertApplied(client.getClass().getClassLoader(),
|
||||
"net.minecraft.client.renderer.SkyRenderer", "sanctuary$openLowerSky");
|
||||
assertApplied(client.getClass().getClassLoader(),
|
||||
"net.minecraft.client.renderer.fog.FogRenderer", "sanctuary$openVoidFog");
|
||||
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
|
||||
});
|
||||
}
|
||||
|
||||
private static void assertApplied(ClassLoader loader, String target, String hook) {
|
||||
try {
|
||||
// Force transformation even when this renderer is lazily loaded on world entry.
|
||||
Class<?> transformed = Class.forName(target, true, loader);
|
||||
boolean applied = Arrays.stream(transformed.getDeclaredMethods())
|
||||
.anyMatch(method -> method.getName().contains(hook));
|
||||
if (!applied) {
|
||||
throw new AssertionError("Missing Sanctuary render injection: " + target + " / " + hook);
|
||||
}
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new AssertionError("Minecraft render target is missing: " + target, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-38
@@ -33,60 +33,86 @@ final class DensityDiagnostics {
|
||||
var level = helper.getLevel();
|
||||
var registries = level.registryAccess();
|
||||
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(SanctuarySpawn.SETTINGS).value();
|
||||
.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value();
|
||||
DensityFunction finalDensity = registries.lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density"))).value();
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density_384"))).value();
|
||||
helper.assertTrue(finalDensity instanceof MainIslandDensity,
|
||||
"The diagnostic must inspect the actual production island density wrapper");
|
||||
MainIslandDensity island = (MainIslandDensity) finalDensity;
|
||||
DensityFunction legacy = registries.lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density"))).value();
|
||||
var noises = registries.lookupOrThrow(Registries.NOISE);
|
||||
// The default run exports all three seeds once. Additional complete-world runs only
|
||||
// repeat the sparse assertions, avoiding redundant image generation.
|
||||
boolean export = level.getSeed() == 0L;
|
||||
boolean export = level.getSeed() == 0L && Boolean.parseBoolean(System.getProperty("sanctuary.test.densityMaps", "true"));
|
||||
boolean foundUnforcedCentralVoid = false;
|
||||
for (long seed : new long[]{0L, 42L, 8675309L}) {
|
||||
RandomState state = RandomState.create(noises, seed, settings);
|
||||
boolean foundSolid = false;
|
||||
var bottomHeights = new java.util.TreeSet<Integer>();
|
||||
for (int x = -288; x <= 288; x += 16) {
|
||||
for (int z = -288; z <= 288; z += 16) {
|
||||
for (int y = 4; y < 256; y += 8) {
|
||||
int firstSolid = -1;
|
||||
for (int y = 4; y < island.height(); y += 8) {
|
||||
float shaped = state.sampleBlockValueUncached(finalDensity, x, y, z);
|
||||
float natural = state.sampleBlockValueUncached(island.terrain(), x, y, z);
|
||||
float natural = state.sampleBlockValueUncached(island.terrain(), x, y - island.verticalOffset(), z);
|
||||
helper.assertTrue(Float.isFinite(shaped), "Sculpted density must be finite");
|
||||
helper.assertTrue(shaped <= 0.0F || natural > 0.0F,
|
||||
"Sculpt must not invent solid terrain; seed " + seed + " at " + x + "," + y + "," + z);
|
||||
foundSolid |= shaped > 0.0F;
|
||||
if (Math.abs(x) <= 48 && Math.abs(z) <= 48 && y >= 44 && y <= 108) {
|
||||
if (shaped > 0.0F && firstSolid < 0) firstSolid = y;
|
||||
if (Math.abs(x) <= 48 && Math.abs(z) <= 48 && y >= 44 + island.verticalOffset() && y <= 108 + island.verticalOffset()) {
|
||||
foundUnforcedCentralVoid |= natural < 0.0F && shaped < 0.0F;
|
||||
}
|
||||
}
|
||||
if (firstSolid >= 0 && Math.abs(x) <= 96 && Math.abs(z) <= 96) bottomHeights.add(firstSolid);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(foundSolid, "The sampled natural island must contain land for seed " + seed);
|
||||
helper.assertTrue(bottomHeights.size() >= 4 && bottomHeights.first() >= 16,
|
||||
"The central underside must vary in height and leave an empty margin below; seed " + seed);
|
||||
SanctuaryMod.LOGGER.info("Raised island seed {}: sampled central underside Y{}..{} ({} distinct heights)",
|
||||
seed, bottomHeights.first(), bottomHeights.last(), bottomHeights.size());
|
||||
verifyUpperTranslation(helper, state, island, legacy, seed);
|
||||
if (export) {
|
||||
export(state, island, seed, seed == level.getSeed() ? spawn : null);
|
||||
export(state, island, legacy, seed, seed == level.getSeed() ? spawn : null);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(foundUnforcedCentralVoid,
|
||||
"The regression seeds must exercise natural holes inside the former forced core");
|
||||
}
|
||||
|
||||
private static void export(RandomState state, MainIslandDensity island, long seed,
|
||||
private static void verifyUpperTranslation(GameTestHelper helper, RandomState state,
|
||||
MainIslandDensity island, DensityFunction legacy, long seed) {
|
||||
for (int x = -256; x <= 256; x += 32) {
|
||||
for (int z = -256; z <= 256; z += 32) {
|
||||
for (int oldY = 80; oldY < 256; oldY += 8) {
|
||||
float before = state.sampleBlockValueUncached(legacy, x, oldY, z);
|
||||
float after = state.sampleBlockValueUncached(island, x, oldY + island.verticalOffset(), z);
|
||||
helper.assertTrue(Math.abs(before - after) < 0.000001F,
|
||||
"The upper silhouette must keep its seed's alpha.2 profile after translation; seed "
|
||||
+ seed + " at " + x + "," + oldY + "," + z + ": " + before + " -> " + after);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void export(RandomState state, MainIslandDensity island, DensityFunction legacy, long seed,
|
||||
LevelData.RespawnData spawn) throws IOException {
|
||||
Path directory = Path.of("diagnostics");
|
||||
Files.createDirectories(directory);
|
||||
BufferedImage picture = new BufferedImage(MAP_SIZE * 2 + 60, 1260, BufferedImage.TYPE_INT_RGB);
|
||||
BufferedImage picture = new BufferedImage(MAP_SIZE * 2 + 60, 715 + 2 * (island.height() + 28), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = picture.createGraphics();
|
||||
graphics.setColor(new Color(0xF0F0EC));
|
||||
graphics.fillRect(0, 0, picture.getWidth(), picture.getHeight());
|
||||
graphics.setColor(new Color(0x14202A));
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 18));
|
||||
graphics.drawString("Seed " + seed + " | alpha.1 (reconstructed density)", 20, 27);
|
||||
graphics.drawString("Sculpted production density", MAP_SIZE + 40, 27);
|
||||
graphics.drawString("Seed " + seed + " | alpha.2 (actual preserved density)", 20, 27);
|
||||
graphics.drawString("Raised production density (384 blocks)", MAP_SIZE + 40, 27);
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
|
||||
graphics.drawString("Top view: x/z -304..304, sample every 4 blocks; height sampled every 4 blocks", 20, 48);
|
||||
graphics.drawString("Dark = void; low green to high cream. Terrain only; no trees/materials.", MAP_SIZE + 40, 48);
|
||||
StringBuilder heights = new StringBuilder("x,z,alpha1_top_y,sculpted_top_y\n");
|
||||
StringBuilder heights = new StringBuilder("x,z,alpha2_top_y,raised_top_y\n");
|
||||
int beforeColumns = 0;
|
||||
int afterColumns = 0;
|
||||
for (int gx = 0; gx < GRID; gx++) {
|
||||
@@ -95,12 +121,10 @@ final class DensityDiagnostics {
|
||||
int z = -EXTENT + gz * STEP;
|
||||
int beforeTop = -1;
|
||||
int afterTop = -1;
|
||||
float distortion = state.sampleBlockValueUncached(island.distortion(), x, 0, z);
|
||||
// Stop as soon as both topmost samples have been found.
|
||||
for (int y = 252; y >= 4 && (beforeTop < 0 || afterTop < 0); y -= 4) {
|
||||
for (int y = island.height() - 4; y >= 4 && (beforeTop < 0 || afterTop < 0); y -= 4) {
|
||||
if (beforeTop < 0) {
|
||||
float natural = state.sampleBlockValueUncached(island.terrain(), x, y, z);
|
||||
if (alphaOne(x, y, z, natural, distortion) > 0.0F) beforeTop = y;
|
||||
if (state.sampleBlockValueUncached(legacy, x, y, z) > 0.0F) beforeTop = y;
|
||||
}
|
||||
if (afterTop < 0 && state.sampleBlockValueUncached(island, x, y, z) > 0.0F) afterTop = y;
|
||||
}
|
||||
@@ -120,8 +144,8 @@ final class DensityDiagnostics {
|
||||
graphics.drawOval(px - 4, pz - 4, 8, 8);
|
||||
graphics.drawString("Initial spawn " + spawn.pos().toShortString(), MAP_SIZE + 40, 689);
|
||||
}
|
||||
drawSections(graphics, state, island, false, 715);
|
||||
drawSections(graphics, state, island, true, 998);
|
||||
drawSections(graphics, state, island, legacy, false, 715);
|
||||
drawSections(graphics, state, island, legacy, true, 715 + island.height() + 28);
|
||||
graphics.dispose();
|
||||
Path output = directory.resolve("island-density-seed-" + seed + ".png");
|
||||
ImageIO.write(picture, "png", output.toFile());
|
||||
@@ -130,40 +154,28 @@ final class DensityDiagnostics {
|
||||
seed, beforeColumns, afterColumns, output.toAbsolutePath());
|
||||
}
|
||||
|
||||
private static void drawSections(Graphics2D graphics, RandomState state, MainIslandDensity island,
|
||||
private static void drawSections(Graphics2D graphics, RandomState state, MainIslandDensity island, DensityFunction legacy,
|
||||
boolean alongZ, int top) {
|
||||
graphics.setColor(new Color(0x14202A));
|
||||
graphics.drawString(alongZ ? "Section x=0: z -304..304; y 255 (top)..0 (bottom)"
|
||||
: "Section z=0: x -304..304; y 255 (top)..0 (bottom)", 20, top - 8);
|
||||
graphics.drawString(alongZ ? "Section x=0: z -304..304; y 383 (top)..0 (bottom)"
|
||||
: "Section z=0: x -304..304; y 383 (top)..0 (bottom)", 20, top - 8);
|
||||
for (int horizontal = -EXTENT; horizontal < EXTENT; horizontal += 2) {
|
||||
int x = alongZ ? 0 : horizontal;
|
||||
int z = alongZ ? horizontal : 0;
|
||||
float distortion = state.sampleBlockValueUncached(island.distortion(), x, 0, z);
|
||||
for (int y = 0; y < 256; y++) {
|
||||
float natural = state.sampleBlockValueUncached(island.terrain(), x, y, z);
|
||||
float before = alphaOne(x, y, z, natural, distortion);
|
||||
for (int y = 0; y < island.height(); y++) {
|
||||
float before = state.sampleBlockValueUncached(legacy, x, y, z);
|
||||
float after = state.sampleBlockValueUncached(island, x, y, z);
|
||||
graphics.setColor(new Color(before > 0.0F ? heightColor(y) : VOID_COLOR));
|
||||
graphics.fillRect(20 + horizontal + EXTENT, top + 255 - y, 2, 1);
|
||||
graphics.fillRect(20 + horizontal + EXTENT, top + island.height() - 1 - y, 2, 1);
|
||||
graphics.setColor(new Color(after > 0.0F ? heightColor(y) : VOID_COLOR));
|
||||
graphics.fillRect(MAP_SIZE + 40 + horizontal + EXTENT, top + 255 - y, 2, 1);
|
||||
graphics.fillRect(MAP_SIZE + 40 + horizontal + EXTENT, top + island.height() - 1 - y, 2, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact former envelope/core formula, applied to the same seed's unchanged natural field. */
|
||||
private static float alphaOne(int x, int y, int z, float natural, float distortion) {
|
||||
double distance = Math.hypot((double) x, (double) z);
|
||||
if (distance >= 288 || y <= 0 || y >= 256) return -1.0F;
|
||||
double edge = (256.0 + Math.clamp(distortion, -1.0F, 1.0F) * 32.0 - distance) / 48.0;
|
||||
double core = 1.0 - distance * distance / (96.0 * 96.0)
|
||||
- (y - 76) * (double) (y - 76) / (42.0 * 42.0);
|
||||
return (float) Math.clamp(Math.min(Math.max(natural, core), edge), -1.0, 1.0);
|
||||
}
|
||||
|
||||
private static int heightColor(int y) {
|
||||
if (y < 0) return VOID_COLOR;
|
||||
float height = Math.clamp(y / 200.0F, 0.0F, 1.0F);
|
||||
float height = Math.clamp(y / 280.0F, 0.0F, 1.0F);
|
||||
int red = (int) (43 + 192 * height);
|
||||
int green = (int) (91 + 139 * height);
|
||||
int blue = (int) (70 + 117 * height);
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.IslandShape;
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.Property;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.storage.LevelData;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Exact post-generation observations in the disposable GameTest world; never edits terrain. */
|
||||
public final class ResourceSurveyDiagnostics {
|
||||
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
|
||||
private static final int DECORATION_MARGIN = 16;
|
||||
private static final int PREPARATION_HALO_CHUNKS = 1;
|
||||
|
||||
private ResourceSurveyDiagnostics() {}
|
||||
|
||||
public static void verifyAndExport(GameTestHelper helper, LevelData.RespawnData initialSpawn) throws IOException {
|
||||
String mode = System.getProperty("sanctuary.test.resourceSurvey", "sample");
|
||||
if (mode.equals("off")) return;
|
||||
if (!mode.equals("sample") && !mode.equals("island")) {
|
||||
throw new IllegalArgumentException("sanctuary.test.resourceSurvey must be sample, island or off");
|
||||
}
|
||||
ServerLevel level = helper.getLevel();
|
||||
helper.assertTrue(SanctuarySpawn.usesSanctuaryGenerator(level),
|
||||
"A resource survey must inspect the production Sanctuary generator");
|
||||
helper.assertTrue(initialSpawn != null, "Use the actual initial spawn, not the remote GameTest fixture");
|
||||
Instant started = Instant.now();
|
||||
long startedNanos = System.nanoTime();
|
||||
long initialGameTime = level.getGameTime();
|
||||
int centreX = initialSpawn.pos().getX() >> 4;
|
||||
int centreZ = initialSpawn.pos().getZ() >> 4;
|
||||
int envelopeRadius = IslandShape.TERRAIN_LIMIT + DECORATION_MARGIN;
|
||||
int minimumIslandChunk = Math.floorDiv(-envelopeRadius, 16);
|
||||
int maximumIslandChunk = Math.floorDiv(envelopeRadius - 1, 16);
|
||||
int minimumX = mode.equals("island") ? minimumIslandChunk : centreX - 1;
|
||||
int maximumX = mode.equals("island") ? maximumIslandChunk : centreX + 1;
|
||||
int minimumZ = mode.equals("island") ? minimumIslandChunk : centreZ - 1;
|
||||
int maximumZ = mode.equals("island") ? maximumIslandChunk : centreZ + 1;
|
||||
int expectedChunks = (maximumX - minimumX + 1) * (maximumZ - minimumZ + 1);
|
||||
BlockPos fixture = helper.absolutePos(BlockPos.ZERO);
|
||||
helper.assertTrue(fixture.getX() < minimumX * 16 - 1024 || fixture.getX() > (maximumX + 1) * 16 + 1024
|
||||
|| fixture.getZ() < minimumZ * 16 - 1024 || fixture.getZ() > (maximumZ + 1) * 16 + 1024,
|
||||
"The remote GameTest fixture must remain well outside the resource survey envelope");
|
||||
|
||||
// Complete the entire requested region, plus its immediate decoration neighbours, before
|
||||
// taking counts. Counting while still generating the next chunk would risk stale borders.
|
||||
SanctuaryMod.LOGGER.info("Resource survey {}: preparing {} inspected chunks plus a {}-chunk halo (seed {})",
|
||||
mode, expectedChunks, PREPARATION_HALO_CHUNKS, level.getSeed());
|
||||
int preparedChunks = 0;
|
||||
for (int x = minimumX - PREPARATION_HALO_CHUNKS; x <= maximumX + PREPARATION_HALO_CHUNKS; x++) {
|
||||
for (int z = minimumZ - PREPARATION_HALO_CHUNKS; z <= maximumZ + PREPARATION_HALO_CHUNKS; z++) {
|
||||
requireFullChunk(helper, level, x, z);
|
||||
preparedChunks++;
|
||||
}
|
||||
if (mode.equals("island") && (x - minimumX + PREPARATION_HALO_CHUNKS + 1) % 4 == 0) {
|
||||
SanctuaryMod.LOGGER.info("Resource survey preparation: {} FULL chunks ready", preparedChunks);
|
||||
}
|
||||
}
|
||||
long preparedNanos = System.nanoTime();
|
||||
Map<BlockState, Long> observedStates = new IdentityHashMap<>();
|
||||
List<Map<String, Object>> chunks = new ArrayList<>();
|
||||
long totalCells = 0;
|
||||
for (int x = minimumX; x <= maximumX; x++) {
|
||||
for (int z = minimumZ; z <= maximumZ; z++) {
|
||||
LevelChunk chunk = requireFullChunk(helper, level, x, z);
|
||||
Map<BlockState, Long> chunkStates = new IdentityHashMap<>();
|
||||
for (LevelChunkSection section : chunk.getSections()) {
|
||||
// This counts every cell in each palette, including all forms of air. Registry
|
||||
// lookups and property serialization happen once per distinct state later.
|
||||
section.getStates().count((state, count) -> chunkStates.merge(state, (long) count, Long::sum));
|
||||
}
|
||||
long chunkCells = sum(chunkStates);
|
||||
helper.assertTrue(chunkCells == 256L * level.getHeight(),
|
||||
"Every cell in the full dimension height must be counted at chunk " + x + "," + z);
|
||||
long nonAir = 0;
|
||||
for (var entry : chunkStates.entrySet()) {
|
||||
observedStates.merge(entry.getKey(), entry.getValue(), Long::sum);
|
||||
if (!entry.getKey().isAir()) nonAir += entry.getValue();
|
||||
}
|
||||
chunks.add(Map.of("x", x, "z", z, "status", "minecraft:full", "counted_cells", chunkCells,
|
||||
"non_air_blocks", nonAir, "distinct_block_states", chunkStates.size()));
|
||||
totalCells += chunkCells;
|
||||
}
|
||||
}
|
||||
Map<String, Long> blocks = new TreeMap<>();
|
||||
Map<String, Long> states = new TreeMap<>();
|
||||
long air = 0;
|
||||
for (var entry : observedStates.entrySet()) {
|
||||
String block = blockName(entry.getKey());
|
||||
blocks.merge(block, entry.getValue(), Long::sum);
|
||||
states.put(stateName(entry.getKey(), block), entry.getValue());
|
||||
if (entry.getKey().isAir()) air += entry.getValue();
|
||||
}
|
||||
long expectedCells = expectedChunks * 256L * level.getHeight();
|
||||
helper.assertTrue(chunks.size() == expectedChunks && totalCells == expectedCells,
|
||||
"Survey completeness must match its explicit requested chunk rectangle and height");
|
||||
helper.assertTrue(sum(blocks) == totalCells && sum(states) == totalCells,
|
||||
"Registry block and full-state totals must account for every inspected cell");
|
||||
var settingsHolder = ((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator()).generatorSettings();
|
||||
var settings = settingsHolder.value();
|
||||
String modVersion = FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow()
|
||||
.getMetadata().getVersion().getFriendlyString();
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("schema_version", 1);
|
||||
report.put("purpose", "development_post_generation_resource_observation");
|
||||
report.put("mode", mode);
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("minecraft_version", SharedConstants.getCurrentVersion().name());
|
||||
report.put("generator", Map.of("preset", "sanctuary:sanctuary", "noise_settings", settingsHolder.getRegisteredName(),
|
||||
"mod_version", modVersion, "version", "sanctuary@" + modVersion,
|
||||
"noise_min_y", settings.noiseSettings().minY(),
|
||||
"noise_max_y_exclusive", settings.noiseSettings().minY() + settings.noiseSettings().height()));
|
||||
report.put("dimension", "minecraft:overworld");
|
||||
report.put("initial_spawn", Map.of("x", initialSpawn.pos().getX(), "y", initialSpawn.pos().getY(),
|
||||
"z", initialSpawn.pos().getZ()));
|
||||
report.put("remote_test_fixture", Map.of("x", fixture.getX(), "y", fixture.getY(), "z", fixture.getZ(),
|
||||
"verified_outside_bounds_with_margin_blocks", 1024));
|
||||
report.put("bounds", Map.of("min_x_inclusive", minimumX * 16, "max_x_exclusive", (maximumX + 1) * 16,
|
||||
"min_z_inclusive", minimumZ * 16, "max_z_exclusive", (maximumZ + 1) * 16,
|
||||
"min_y_inclusive", level.getMinY(), "max_y_exclusive", level.getMinY() + level.getHeight(),
|
||||
"min_chunk_x_inclusive", minimumX, "max_chunk_x_inclusive", maximumX,
|
||||
"min_chunk_z_inclusive", minimumZ, "max_chunk_z_inclusive", maximumZ));
|
||||
report.put("declared_island_envelope", Map.of("terrain_radius_blocks", IslandShape.TERRAIN_LIMIT,
|
||||
"decoration_margin_blocks", DECORATION_MARGIN, "bounding_rectangle_radius_blocks", envelopeRadius));
|
||||
report.put("completeness", Map.of("requested_chunks", expectedChunks, "inspected_full_chunks", chunks.size(),
|
||||
"complete_within_requested_bounds", true, "covers_declared_island_envelope", mode.equals("island"),
|
||||
"covers_entire_world", false, "extrapolated", false, "height_coverage", "entire_dimension",
|
||||
"basis", "all palette cells in every inspected FULL chunk, not density samples"));
|
||||
report.put("preparation", Map.of("halo_chunks", PREPARATION_HALO_CHUNKS, "full_chunks_prepared", preparedChunks,
|
||||
"halo_included_in_counts", false, "world", "fresh_disposable_gametest_world",
|
||||
"personal_saves_accessed", false, "player_inventory_included", false, "remote_test_fixture_included", false));
|
||||
report.put("counts", Map.of("all_cells", totalCells, "air_cells", air, "non_air_blocks", totalCells - air,
|
||||
"by_block_registry_id", blocks, "by_complete_block_state", states));
|
||||
report.put("aggregates", aggregates(observedStates, blocks));
|
||||
report.put("aggregate_semantics", "Counts are blocks, not drops or economic value; groups overlap and must not be added together.");
|
||||
report.put("inspected_chunks", chunks);
|
||||
report.put("exclusions", List.of("entities", "item_drops", "container_contents", "player_inventories",
|
||||
"other_dimensions", "future_expansions", "blocks_outside_requested_bounds", "future_growth_and_loot"));
|
||||
report.put("observation", Map.of("started_at_utc", started.toString(), "completed_at_utc", Instant.now().toString(),
|
||||
"game_time_at_start", initialGameTime, "game_time_at_end", level.getGameTime(),
|
||||
"generation_preparation_millis", (preparedNanos - startedNanos) / 1_000_000L,
|
||||
"counting_and_serialization_preparation_millis", (System.nanoTime() - preparedNanos) / 1_000_000L,
|
||||
"scope", "snapshot after FULL generation; no resource quotas or terrain correction applied"));
|
||||
Path directory = Path.of("diagnostics");
|
||||
Files.createDirectories(directory);
|
||||
Path output = directory.resolve("resource-survey-seed-" + level.getSeed() + "-" + mode + ".json");
|
||||
Files.writeString(output, JSON.toJson(report) + "\n");
|
||||
SanctuaryMod.LOGGER.info("Resource survey {} complete: {} FULL chunks, {} non-air blocks; {}",
|
||||
mode, chunks.size(), totalCells - air, output.toAbsolutePath());
|
||||
}
|
||||
|
||||
private static LevelChunk requireFullChunk(GameTestHelper helper, ServerLevel level, int x, int z) {
|
||||
ChunkAccess chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Survey requires a FULL chunk at " + x + "," + z);
|
||||
return (LevelChunk) chunk;
|
||||
}
|
||||
|
||||
private static Map<String, Object> aggregates(Map<BlockState, Long> states, Map<String, Long> blocks) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("stone", explicitBlocks(blocks, "minecraft:stone"));
|
||||
result.put("base_stone_overworld", taggedStates(states, state -> state.is(BlockTags.BASE_STONE_OVERWORLD),
|
||||
"#minecraft:base_stone_overworld"));
|
||||
result.put("logs", taggedStates(states, state -> state.is(BlockTags.LOGS), "#minecraft:logs"));
|
||||
result.put("leaves", taggedStates(states, state -> state.is(BlockTags.LEAVES), "#minecraft:leaves"));
|
||||
for (String ore : List.of("coal", "iron", "copper", "gold", "redstone", "lapis", "diamond", "emerald")) {
|
||||
result.put(ore + "_ore", explicitBlocks(blocks, "minecraft:" + ore + "_ore", "minecraft:deepslate_" + ore + "_ore"));
|
||||
}
|
||||
result.put("water_blocks", explicitBlocks(blocks, "minecraft:water"));
|
||||
result.put("water_containing_blocks", taggedStates(states, state -> state.getFluidState().is(FluidTags.WATER),
|
||||
"block states containing #minecraft:water, including waterlogged blocks"));
|
||||
result.put("lava_blocks", explicitBlocks(blocks, "minecraft:lava"));
|
||||
result.put("sand", explicitBlocks(blocks, "minecraft:sand", "minecraft:red_sand"));
|
||||
result.put("gravel", explicitBlocks(blocks, "minecraft:gravel"));
|
||||
result.put("clay", explicitBlocks(blocks, "minecraft:clay"));
|
||||
result.put("sugar_cane", explicitBlocks(blocks, "minecraft:sugar_cane"));
|
||||
result.put("soil", explicitBlocks(blocks, "minecraft:grass_block", "minecraft:dirt", "minecraft:coarse_dirt",
|
||||
"minecraft:rooted_dirt", "minecraft:podzol", "minecraft:mud"));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Object> explicitBlocks(Map<String, Long> blocks, String... names) {
|
||||
Map<String, Long> members = new TreeMap<>();
|
||||
for (String name : names) members.put(name, blocks.getOrDefault(name, 0L));
|
||||
return Map.of("count", sum(members), "definition", "sum of the listed block registry IDs", "members", members);
|
||||
}
|
||||
|
||||
private static Map<String, Object> taggedStates(Map<BlockState, Long> states, Predicate<BlockState> predicate,
|
||||
String definition) {
|
||||
Map<String, Long> members = new TreeMap<>();
|
||||
states.forEach((state, count) -> {
|
||||
if (predicate.test(state)) members.merge(blockName(state), count, Long::sum);
|
||||
});
|
||||
return Map.of("count", sum(members), "definition", definition, "observed_members", members);
|
||||
}
|
||||
|
||||
private static String blockName(BlockState state) {
|
||||
return BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString();
|
||||
}
|
||||
|
||||
private static String stateName(BlockState state, String block) {
|
||||
Map<String, String> properties = new TreeMap<>();
|
||||
for (Property<?> property : state.getProperties()) properties.put(property.getName(), propertyValue(state, property));
|
||||
if (properties.isEmpty()) return block;
|
||||
StringJoiner suffix = new StringJoiner(",", "[", "]");
|
||||
properties.forEach((name, value) -> suffix.add(name + "=" + value));
|
||||
return block + suffix;
|
||||
}
|
||||
|
||||
private static <T extends Comparable<T>> String propertyValue(BlockState state, Property<T> property) {
|
||||
return property.getName(state.getValue(property));
|
||||
}
|
||||
|
||||
private static long sum(Map<?, Long> counts) {
|
||||
return counts.values().stream().mapToLong(Long::longValue).sum();
|
||||
}
|
||||
}
|
||||
+56
-3
@@ -2,9 +2,12 @@ package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import fr.koka.sanctuary.worldgen.IslandShape;
|
||||
import fr.koka.sanctuary.worldgen.MainIslandDensity;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
@@ -20,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();
|
||||
@@ -33,7 +47,7 @@ public final class SanctuaryWorldGameTests {
|
||||
helper.assertTrue(spawn.getY() > 1 && spawn.getY() < level.getMaxY() - 1,
|
||||
"Shared spawn must be inside the dimension height");
|
||||
var settings = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(SanctuarySpawn.SETTINGS).value();
|
||||
.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value();
|
||||
float spawnFloorDensity = level.getChunkSource().randomState().sampleBlockValueUncached(
|
||||
settings.noiseRouter().finalDensity(), spawn.getX(), spawn.getY() - 1, spawn.getZ());
|
||||
helper.assertTrue(spawnFloorDensity > 0.0F,
|
||||
@@ -63,6 +77,45 @@ public final class SanctuaryWorldGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void generationHeightAndLegacySettingsStaySeparate(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
var registries = level.registryAccess();
|
||||
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384,
|
||||
"The new Sanctuary dimension must have exactly 384 buildable blocks, Y0..383");
|
||||
float cloudHeight = level.environmentAttributes()
|
||||
.getDimensionValue(net.minecraft.world.attribute.EnvironmentAttributes.CLOUD_HEIGHT);
|
||||
helper.assertTrue(Math.abs(cloudHeight - 352.33F) < 0.001F,
|
||||
"Sanctuary clouds must be raised to Y352.33");
|
||||
helper.assertTrue(settings.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value().noiseSettings().height() == 384,
|
||||
"Noise generation must use all 384 blocks, not just a taller dimension");
|
||||
helper.assertTrue(settings.getOrThrow(SanctuarySpawn.SETTINGS).value().noiseSettings().height() == 256,
|
||||
"The noise settings referenced by saved alpha.2 worlds must retain their old height");
|
||||
var legacy = registries.lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
|
||||
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("final_density"))).value();
|
||||
helper.assertTrue(legacy instanceof MainIslandDensity old && old.verticalOffset() == 0 && old.height() == 256,
|
||||
"The legacy density codec must remain in its unshifted alpha.2 mode");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void completedLowerMarginIsVoid(GameTestHelper helper) {
|
||||
for (int[] coordinate : new int[][]{{0, 0}, {-8, 0}, {8, 0}, {0, -8}, {0, 8}}) {
|
||||
ChunkAccess chunk = helper.getLevel().getChunkSource().getChunk(
|
||||
coordinate[0], coordinate[1], ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk != null && chunk.getSection(0).hasOnlyAir(),
|
||||
"The fully generated island must leave Y0..15 empty, without a floor cut at the dimension bound");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void generatedResourceSurvey(GameTestHelper helper) throws java.io.IOException {
|
||||
ResourceSurveyDiagnostics.verifyAndExport(helper, initialSpawn);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void completedExteriorChunksAreVoid(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
@@ -89,7 +142,7 @@ public final class SanctuaryWorldGameTests {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var registries = level.registryAccess();
|
||||
NoiseGeneratorSettings settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(SanctuarySpawn.SETTINGS).value();
|
||||
.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value();
|
||||
var noises = registries.lookupOrThrow(Registries.NOISE);
|
||||
RandomState first = RandomState.create(noises, 0L, settings);
|
||||
RandomState replay = RandomState.create(noises, 0L, settings);
|
||||
@@ -98,7 +151,7 @@ public final class SanctuaryWorldGameTests {
|
||||
boolean observedSeedVariation = false;
|
||||
for (int x : new int[]{112, 160, 208}) {
|
||||
for (int z : new int[]{-96, -48, 0, 48, 96}) {
|
||||
for (int y : new int[]{64, 96, 128, 160}) {
|
||||
for (int y : new int[]{96, 128, 160, 192, 224}) {
|
||||
float value = first.sampleBlockValueUncached(density, x, y, z);
|
||||
float repeated = replay.sampleBlockValueUncached(density, x, y, z);
|
||||
float changed = otherSeed.sampleBlockValueUncached(density, x, y, z);
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"environment": "*",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"entrypoints": {
|
||||
"fabric-gametest": ["fr.koka.sanctuary.gametest.SanctuaryWorldGameTests"]
|
||||
"fabric-gametest": ["fr.koka.sanctuary.gametest.SanctuaryWorldGameTests"],
|
||||
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
|
||||
},
|
||||
"mixins": ["sanctuary-gametest.mixins.json"],
|
||||
"depends": {
|
||||
|
||||
@@ -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,17 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Client visuals belonging only to the new floating-world dimension type. */
|
||||
public final class SanctuarySky {
|
||||
private static final Identifier FLOATING_WORLD = SanctuaryMod.id("sanctuary_384");
|
||||
|
||||
private SanctuarySky() {
|
||||
}
|
||||
|
||||
public static boolean isFloatingWorld(ClientLevel level) {
|
||||
return level.dimensionTypeRegistration().is(FLOATING_WORLD);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,24 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.SanctuarySky;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.fog.FogRenderer;
|
||||
import net.minecraft.util.Mth;
|
||||
import org.joml.Vector4f;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
|
||||
@Mixin(FogRenderer.class)
|
||||
public abstract class SanctuaryFogMixin {
|
||||
// Only the altitude-derived darkness is removed. Vanilla still applies
|
||||
// blindness/darkness effects, weather, fluids and the normal distance fog.
|
||||
@Redirect(method = "computeFogColor", at = @At(value = "INVOKE",
|
||||
target = "Lnet/minecraft/util/Mth;clamp(FFF)F", ordinal = 0))
|
||||
private float sanctuary$openVoidFog(float value, float minimum, float maximum,
|
||||
Camera camera, float partialTicks, ClientLevel level,
|
||||
int renderDistance, float darkenWorldAmount, Vector4f destination) {
|
||||
return SanctuarySky.isFloatingWorld(level) ? 0.0F : Mth.clamp(value, minimum, maximum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.SanctuarySky;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.SkyRenderer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(SkyRenderer.class)
|
||||
public abstract class SanctuarySkyMixin {
|
||||
// Vanilla draws a black lower sky below Y=63 even when there is no terrain.
|
||||
@Inject(method = "shouldRenderDarkDisc", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$openLowerSky(float partialTicks, ClientLevel level,
|
||||
CallbackInfoReturnable<Boolean> callback) {
|
||||
if (SanctuarySky.isFloatingWorld(level)) {
|
||||
callback.setReturnValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ public final class IslandShape {
|
||||
public static final int EDGE_WARP = 32;
|
||||
public static final int TERRAIN_LIMIT = RADIUS + EDGE_WARP;
|
||||
public static final int INNER_RADIUS = 128;
|
||||
public static final int RAISED_HEIGHT = 384;
|
||||
public static final int VERTICAL_OFFSET = 64;
|
||||
|
||||
private IslandShape() {}
|
||||
|
||||
@@ -20,6 +22,36 @@ public final class IslandShape {
|
||||
return -1.0F;
|
||||
}
|
||||
|
||||
double erosion = rimErosion(distance, y, distortion, sculpt, detail);
|
||||
erosion += 2.0 * smoothstep((32.0 - y) / 32.0);
|
||||
erosion += 2.0 * smoothstep((y - 224.0) / 32.0);
|
||||
return bounded(terrain, erosion);
|
||||
}
|
||||
|
||||
/** Generation v3. The natural field and lateral sculpture are sampled in their original frame. */
|
||||
public static float raisedDensity(int x, int y, int z, float terrain, float distortion,
|
||||
float sculpt, float detail, float underside, int offset, int height) {
|
||||
double distance = Math.hypot((double) x, (double) z);
|
||||
if (distance >= TERRAIN_LIMIT || y <= 0 || y >= height) return -1.0F;
|
||||
double localY = (double) y - offset;
|
||||
double erosion = rimErosion(distance, localY, distortion, sculpt, detail);
|
||||
|
||||
// The former uniform lower gradient is absent from the new natural field. Let its seeded
|
||||
// lobes continue downwards, then sculpt them over a broad, spatially varying transition.
|
||||
// The extra margin below the translated island must not merely reveal the same flat cut.
|
||||
double bottom = -24.0 + Math.clamp(underside, -1.0F, 1.0F) * 16.0
|
||||
+ Math.clamp(sculpt, -1.0F, 1.0F) * 8.0 + Math.clamp(detail, -1.0F, 1.0F) * 4.0;
|
||||
double lower = Math.clamp((bottom + 72.0 - localY) / 72.0, 0.0, 1.0);
|
||||
erosion += 2.0 * lower * lower * lower * lower;
|
||||
// Upper silhouette is exactly the old one translated by offset. Absolute-world fades are
|
||||
// safety margins, outside the normal terrain, rather than the island's visible underside.
|
||||
erosion += 2.0 * smoothstep((localY - 224.0) / 32.0);
|
||||
erosion += 2.0 * smoothstep((24.0 - y) / 24.0);
|
||||
erosion += 2.0 * smoothstep((y - (height - 24.0)) / 24.0);
|
||||
return bounded(terrain, erosion);
|
||||
}
|
||||
|
||||
private static double rimErosion(double distance, double y, float distortion, float sculpt, float detail) {
|
||||
double coast = Math.clamp(distortion, -1.0F, 1.0F);
|
||||
double coarse = Math.clamp(sculpt, -1.0F, 1.0F);
|
||||
double fine = Math.clamp(detail, -1.0F, 1.0F);
|
||||
@@ -44,12 +76,11 @@ public final class IslandShape {
|
||||
double sculptWeight = smoothstep(rim);
|
||||
erosion += sculptWeight * (0.10 * (coarse + 1.0) * 0.5 + 0.04 * (fine + 1.0) * 0.5);
|
||||
|
||||
// Fade out before the vertical safety bounds too. The original vertical gradients remain
|
||||
// responsible for the natural profile; these guards must not cut a horizontal rock slab.
|
||||
erosion += 2.0 * smoothstep((32.0 - y) / 32.0);
|
||||
erosion += 2.0 * smoothstep((y - 224.0) / 32.0);
|
||||
double value = Math.clamp(terrain, -1.0F, 1.0F) - erosion;
|
||||
return (float) Math.clamp(value, -1.0, 1.0);
|
||||
return erosion;
|
||||
}
|
||||
|
||||
private static float bounded(float terrain, double erosion) {
|
||||
return (float) Math.clamp(Math.clamp(terrain, -1.0F, 1.0F) - erosion, -1.0, 1.0);
|
||||
}
|
||||
|
||||
private static double smoothstep(double value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import net.minecraft.util.Interval;
|
||||
@@ -11,37 +12,52 @@ import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
|
||||
/** 26.3's compiled density API; all four child functions receive Minecraft's world seed. */
|
||||
/** Compiled density API. Legacy defaults preserve the generator saved by alpha.2 worlds. */
|
||||
public record MainIslandDensity(DensityFunction terrain, DensityFunction distortion,
|
||||
DensityFunction sculpt, DensityFunction detail) implements DensityFunction {
|
||||
DensityFunction sculpt, DensityFunction detail,
|
||||
int verticalOffset, int height, DensityFunction underside) implements DensityFunction {
|
||||
public static final MapCodec<MainIslandDensity> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
DensityFunction.CODEC.fieldOf("terrain").forGetter(MainIslandDensity::terrain),
|
||||
DensityFunction.CODEC.fieldOf("distortion").forGetter(MainIslandDensity::distortion),
|
||||
DensityFunction.CODEC.optionalFieldOf("sculpt", DensityFunctions.zero()).forGetter(MainIslandDensity::sculpt),
|
||||
DensityFunction.CODEC.optionalFieldOf("detail", DensityFunctions.zero()).forGetter(MainIslandDensity::detail)
|
||||
DensityFunction.CODEC.optionalFieldOf("detail", DensityFunctions.zero()).forGetter(MainIslandDensity::detail),
|
||||
Codec.intRange(0, 128).optionalFieldOf("vertical_offset", 0).forGetter(MainIslandDensity::verticalOffset),
|
||||
Codec.intRange(256, 384).optionalFieldOf("height", 256).forGetter(MainIslandDensity::height),
|
||||
DensityFunction.CODEC.optionalFieldOf("underside", DensityFunctions.zero()).forGetter(MainIslandDensity::underside)
|
||||
).apply(instance, MainIslandDensity::new));
|
||||
|
||||
public MainIslandDensity(DensityFunction terrain, DensityFunction distortion) {
|
||||
this(terrain, distortion, DensityFunctions.zero(), DensityFunctions.zero());
|
||||
}
|
||||
|
||||
public MainIslandDensity(DensityFunction terrain, DensityFunction distortion,
|
||||
DensityFunction sculpt, DensityFunction detail) {
|
||||
this(terrain, distortion, sculpt, detail, 0, 256, DensityFunctions.zero());
|
||||
}
|
||||
|
||||
@Override
|
||||
public DensitySampler compileSampler(CompileContext context) {
|
||||
DensitySampler terrainSampler = terrain.compileSampler(context);
|
||||
DensitySampler distortionSampler = distortion.compileSampler(context);
|
||||
DensitySampler sculptSampler = sculpt.compileSampler(context);
|
||||
DensitySampler detailSampler = detail.compileSampler(context);
|
||||
DensitySampler undersideSampler = underside.compileSampler(context);
|
||||
return new DensitySampler() {
|
||||
@Override
|
||||
public float sampleValue(SamplerContext samplerContext, int x, int y, int z) {
|
||||
if (Math.hypot((double) x, (double) z) >= IslandShape.TERRAIN_LIMIT || y <= 0 || y >= 256) {
|
||||
if (Math.hypot((double) x, (double) z) >= IslandShape.TERRAIN_LIMIT || y <= 0 || y >= height) {
|
||||
return -1.0F;
|
||||
}
|
||||
return IslandShape.density(x, y, z,
|
||||
terrainSampler.sampleValue(samplerContext, x, y, z),
|
||||
distortionSampler.sampleValue(samplerContext, x, 0, z),
|
||||
sculptSampler.sampleValue(samplerContext, x, y, z),
|
||||
detailSampler.sampleValue(samplerContext, x, y, z));
|
||||
int sampleY = y - verticalOffset;
|
||||
float natural = terrainSampler.sampleValue(samplerContext, x, sampleY, z);
|
||||
float coast = distortionSampler.sampleValue(samplerContext, x, 0, z);
|
||||
float coarse = sculptSampler.sampleValue(samplerContext, x, sampleY, z);
|
||||
float fine = detailSampler.sampleValue(samplerContext, x, sampleY, z);
|
||||
if (verticalOffset == 0 && height == 256) {
|
||||
return IslandShape.density(x, y, z, natural, coast, coarse, fine);
|
||||
}
|
||||
return IslandShape.raisedDensity(x, y, z, natural, coast, coarse, fine,
|
||||
undersideSampler.sampleValue(samplerContext, x, sampleY, z), verticalOffset, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -54,7 +70,7 @@ public record MainIslandDensity(DensityFunction terrain, DensityFunction distort
|
||||
@Override
|
||||
public DensityFunction rewriteChildren(DfRewriteRule rule) {
|
||||
return new MainIslandDensity(rule.rewrite(terrain), rule.rewrite(distortion),
|
||||
rule.rewrite(sculpt), rule.rewrite(detail));
|
||||
rule.rewrite(sculpt), rule.rewrite(detail), verticalOffset, height, rule.rewrite(underside));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -17,13 +17,18 @@ import net.minecraft.world.level.storage.LevelData;
|
||||
public final class SanctuarySpawn {
|
||||
public static final ResourceKey<NoiseGeneratorSettings> SETTINGS =
|
||||
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(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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"ambient_light": 0.0,
|
||||
"attributes": {
|
||||
"minecraft:audio/ambient_sounds": {
|
||||
"mood": {
|
||||
"block_search_extent": 8,
|
||||
"offset": 2.0,
|
||||
"sound": "minecraft:ambient.cave",
|
||||
"tick_delay": 6000
|
||||
}
|
||||
},
|
||||
"minecraft:audio/background_music": {
|
||||
"creative": {
|
||||
"max_delay": 24000,
|
||||
"min_delay": 12000,
|
||||
"sound": "minecraft:music.creative"
|
||||
},
|
||||
"default": {
|
||||
"max_delay": 24000,
|
||||
"min_delay": 12000,
|
||||
"sound": "minecraft:music.game"
|
||||
}
|
||||
},
|
||||
"minecraft:gameplay/bed_rule": {
|
||||
"can_set_spawn": "always",
|
||||
"can_sleep": "when_dark",
|
||||
"error_message": {
|
||||
"translate": "block.minecraft.bed.no_sleep"
|
||||
}
|
||||
},
|
||||
"minecraft:gameplay/nether_portal_spawns_piglin": true,
|
||||
"minecraft:gameplay/respawn_anchor_works": false,
|
||||
"minecraft:gameplay/straw_bed_rule": {
|
||||
"can_set_spawn": "never",
|
||||
"can_sleep": "when_dark",
|
||||
"destroy_on_leave": true,
|
||||
"error_message": {
|
||||
"translate": "block.minecraft.bed.no_sleep"
|
||||
}
|
||||
},
|
||||
"minecraft:visual/ambient_light_color": "#0a0a0a",
|
||||
"minecraft:visual/cloud_color": "#ccffffff",
|
||||
"minecraft:visual/cloud_height": 352.33,
|
||||
"minecraft:visual/fog_color": "#c0d8ff",
|
||||
"minecraft:visual/sky_color": "#78a7ff"
|
||||
},
|
||||
"coordinate_scale": 1.0,
|
||||
"default_clock": "minecraft:overworld",
|
||||
"has_ceiling": false,
|
||||
"has_ender_dragon_fight": false,
|
||||
"has_skylight": true,
|
||||
"height": 384,
|
||||
"infiniburn": "#minecraft:infiniburn_overworld",
|
||||
"logical_height": 384,
|
||||
"min_y": 0,
|
||||
"monster_spawn_block_light_limit": 0,
|
||||
"monster_spawn_light_level": {
|
||||
"type": "minecraft:uniform",
|
||||
"max_inclusive": 7,
|
||||
"min_inclusive": 0
|
||||
},
|
||||
"timelines": "#minecraft:in_overworld"
|
||||
}
|
||||
+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
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"type": "sanctuary:main_island",
|
||||
"terrain": {
|
||||
"type": "minecraft:squeeze",
|
||||
"input": {
|
||||
"type": "minecraft:interpolated",
|
||||
"cell_size_xz": 8,
|
||||
"cell_size_y": 4,
|
||||
"input": {
|
||||
"type": "minecraft:mul",
|
||||
"left": 0.64,
|
||||
"right": "sanctuary:floating_archipelago_384"
|
||||
}
|
||||
}
|
||||
},
|
||||
"distortion": {
|
||||
"type": "minecraft:noise",
|
||||
"noise": "sanctuary:island_edge",
|
||||
"xz_scale": 1.0,
|
||||
"y_scale": 0.0
|
||||
},
|
||||
"sculpt": {
|
||||
"type": "minecraft:noise",
|
||||
"noise": "sanctuary:island_sculpt",
|
||||
"xz_scale": 1.0,
|
||||
"y_scale": 1.0
|
||||
},
|
||||
"detail": {
|
||||
"type": "minecraft:noise",
|
||||
"noise": "sanctuary:island_detail",
|
||||
"xz_scale": 1.0,
|
||||
"y_scale": 1.0
|
||||
},
|
||||
"vertical_offset": 64,
|
||||
"height": 384,
|
||||
"underside": {
|
||||
"type": "minecraft:noise",
|
||||
"noise": "sanctuary:island_underside",
|
||||
"xz_scale": 1.0,
|
||||
"y_scale": 1.0
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "minecraft:lerp",
|
||||
"alpha": {
|
||||
"type": "minecraft:gradient",
|
||||
"axis": "y",
|
||||
"from_coordinate": 184,
|
||||
"to_coordinate": 440,
|
||||
"from_value": 1.0,
|
||||
"to_value": 0.0
|
||||
},
|
||||
"first": -23.4375,
|
||||
"second": "sanctuary:base_3d_noise"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"base_octave": -6,
|
||||
"octave_count": 3,
|
||||
"amplitude_modifiers": [
|
||||
1.0,
|
||||
0.5,
|
||||
0.25
|
||||
]
|
||||
}
|
||||
+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": []
|
||||
}
|
||||
+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": []
|
||||
}
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"dimensions": {
|
||||
"minecraft:overworld": {
|
||||
"type": "minecraft:overworld",
|
||||
"type": "sanctuary:sanctuary_384",
|
||||
"generator": {
|
||||
"type": "minecraft:noise",
|
||||
"biome_source": {
|
||||
"type": "minecraft:fixed",
|
||||
"biome": "sanctuary:starter_forest"
|
||||
"biome": "sanctuary:starter_forest_hydrology"
|
||||
},
|
||||
"settings": "sanctuary:sanctuary"
|
||||
"settings": "sanctuary:sanctuary_hydrology"
|
||||
}
|
||||
},
|
||||
"minecraft:the_end": {
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
"package": "fr.koka.sanctuary.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"mixins": [
|
||||
"InitialSpawnMixin"
|
||||
"InitialSpawnMixin",
|
||||
"SurfaceHydrologyMixin"
|
||||
],
|
||||
"client": [
|
||||
"client.SanctuarySkyMixin",
|
||||
"client.SanctuaryFogMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,55 @@ public final class WorldgenSmoke {
|
||||
gradualErosion();
|
||||
finiteVoid();
|
||||
densityBounds();
|
||||
System.out.println("WorldgenSmoke: no artificial core, untouched interior, gradual 3D erosion, finite void and density bounds passed.");
|
||||
raisedIsland();
|
||||
System.out.println("WorldgenSmoke: legacy shape, raised silhouette, irregular underside, no artificial material and finite void passed.");
|
||||
}
|
||||
|
||||
private static void raisedIsland() {
|
||||
for (int x = -288; x <= 288; x += 16) {
|
||||
for (int z = -288; z <= 288; z += 16) {
|
||||
for (int y = 0; y < IslandShape.RAISED_HEIGHT; y += 8) {
|
||||
for (float noise : NOISE_EXTREMES) {
|
||||
for (float natural : new float[]{-0.75F, 0, 0.45F, 1.0F}) {
|
||||
float value = raised(x, y, z, natural, noise, -noise, noise, noise);
|
||||
require(Float.isFinite(value) && value <= natural, "raised sculpt added material");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preserve the approved silhouette above the underside's working band, shifted by 64.
|
||||
for (int x = -272; x <= 272; x += 16) {
|
||||
for (int y = 80; y < 256; y += 4) {
|
||||
for (float noise : NOISE_EXTREMES) {
|
||||
float old = IslandShape.density(x, y, 32, 0.45F, noise, -noise, noise);
|
||||
float raised = raised(x, y + IslandShape.VERTICAL_OFFSET, 32, 0.45F, noise, -noise, noise, noise);
|
||||
require(Float.floatToIntBits(old) == Float.floatToIntBits(raised),
|
||||
"raising the island changed its approved upper silhouette");
|
||||
}
|
||||
}
|
||||
}
|
||||
int deep = firstRaisedSolid(-1.0F);
|
||||
int high = firstRaisedSolid(1.0F);
|
||||
require(deep > 24 && high - deep >= 24, "underside ends on a uniform horizontal plane");
|
||||
require(deep < IslandShape.VERTICAL_OFFSET, "underside only moved the former lower cutoff upwards");
|
||||
for (float noise : NOISE_EXTREMES) {
|
||||
require(raised(0, 8, 0, Float.MAX_VALUE, noise, noise, noise, noise) < 0, "raised terrain reaches world bottom");
|
||||
require(raised(0, 376, 0, Float.MAX_VALUE, noise, noise, noise, noise) < 0, "raised terrain reaches world top");
|
||||
require(raised(280, 168, 0, Float.MAX_VALUE, noise, noise, noise, noise) < 0, "raised terrain reaches radial guard");
|
||||
}
|
||||
}
|
||||
|
||||
private static int firstRaisedSolid(float underside) {
|
||||
for (int y = 1; y < IslandShape.RAISED_HEIGHT; y++) {
|
||||
if (raised(0, y, 0, 0.45F, 0, 0, 0, underside) > 0) return y;
|
||||
}
|
||||
throw new AssertionError("the controlled raised lobe has no terrain");
|
||||
}
|
||||
|
||||
private static float raised(int x, int y, int z, float terrain, float coast, float coarse, float fine, float underside) {
|
||||
return IslandShape.raisedDensity(x, y, z, terrain, coast, coarse, fine, underside,
|
||||
IslandShape.VERTICAL_OFFSET, IslandShape.RAISED_HEIGHT);
|
||||
}
|
||||
|
||||
private static void noAddedTerrain() {
|
||||
|
||||
+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.2.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.2"
|
||||
version = "0.1.0-alpha.4"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
@@ -15,7 +15,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
|
||||
import uuid
|
||||
import zipfile
|
||||
@@ -95,7 +95,12 @@ class Gitea:
|
||||
'POST', payload,
|
||||
content_type='multipart/form-data; boundary=' + boundary)
|
||||
release['assets'].append(asset)
|
||||
asset_url = pack.https_url(asset['browser_download_url'])
|
||||
# Some reverse-proxied Gitea installations advertise HTTP despite their
|
||||
# public HTTPS origin. Upgrade only this same-origin URL, then verify it.
|
||||
advertised = urlsplit(asset['browser_download_url'])
|
||||
if advertised.scheme == 'http' and advertised.netloc == urlsplit(self.repo_url).netloc:
|
||||
advertised = advertised._replace(scheme='https')
|
||||
asset_url = pack.https_url(urlunsplit(advertised))
|
||||
# Verify anonymous access: players must not need publishing credentials.
|
||||
with urlopen(asset_url, timeout=60) as response:
|
||||
digest = hashlib.sha256(response.read()).hexdigest()
|
||||
|
||||
Reference in New Issue
Block a user