Raise Sanctuary to 384 blocks and survey generated resources
Build Sanctuary / build (push) Canceled after 0s

This commit is contained in:
koka
2026-09-08 13:39:45 +02:00
parent ebcdb17a11
commit 40168e2054
31 changed files with 1116 additions and 93 deletions
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## 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.
+10 -2
View File
@@ -26,6 +26,9 @@ 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.
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
@@ -33,6 +36,11 @@ le terrain : le spawn cherche un emplacement naturel. Le catalogue
TerraMix, les continents déverrouillables, les lacs et rivières, 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 +58,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.3 |
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 +85,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.3.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).
+9
View File
@@ -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,6 +115,9 @@ 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é.
+202
View File
@@ -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.
+5 -4
View File
@@ -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.3.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.3/sanctuary-0.1.0-alpha.3.jar
```
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
@@ -113,8 +113,9 @@ 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. 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.
+65 -7
View File
@@ -52,7 +52,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 sept 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 +63,15 @@ 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.
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 +86,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 +109,59 @@ 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
## 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.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
+21
View File
@@ -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.
+55 -14
View File
@@ -27,19 +27,23 @@ 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é, simplement relevé de 64 blocs.
- 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
@@ -55,7 +59,7 @@ complète des ressources de survie et variété de biomes constituent le prochai
ticket de terrain. 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 +96,43 @@ 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 à `Y64`.
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é.
## Apparition commune
@@ -128,8 +165,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 +192,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
View File
@@ -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.3
pack_version=0.1.0-alpha.3
maven_group=fr.koka.sanctuary
+12 -1
View File
@@ -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,7 @@ 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'))
}
dependencies {
@@ -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);
}
}
}
@@ -33,12 +33,14 @@ 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.
@@ -47,46 +49,70 @@ final class DensityDiagnostics {
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);
@@ -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();
}
}
@@ -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;
@@ -33,7 +36,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 +66,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 +131,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 +140,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": {
@@ -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,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);
}
}
}
@@ -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,15 @@ 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"));
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));
}
/** Called only during first creation; saved spawns and /setworldspawn survive subsequent loads. */
@@ -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"
}
@@ -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
}
}
@@ -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
]
}
@@ -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": []
}
@@ -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"
},
"settings": "sanctuary:sanctuary"
"settings": "sanctuary:sanctuary_384"
}
},
"minecraft:the_end": {
@@ -5,6 +5,10 @@
"mixins": [
"InitialSpawnMixin"
],
"client": [
"client.SanctuarySkyMixin",
"client.SanctuaryFogMixin"
],
"injectors": {
"defaultRequire": 1
}
@@ -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
View File
@@ -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.3.mrpack
```
L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API.
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "0.1.0-alpha.2"
version = "0.1.0-alpha.3"
pack-format = "packwiz:1.1.0"
[index]