Compare commits

...
Author SHA1 Message Date
koka b19e93dd21 Fix Windows expansion journal persistence in alpha.13.1
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 12:37:36 +02:00
koka 3569cf1f5f docs(release): record alpha.13 publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 11:15:51 +02:00
koka 78b63be8e1 feat(worldgen): unify Sanctuary and native size options for alpha.13
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 11:13:24 +02:00
koka 818c6753f9 docs: record alpha.12.1 release and preserved Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 08:57:40 +02:00
koka 332d60be96 fix: search farther for expansion space and accept a minimum distance
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 08:55:29 +02:00
koka 40a86588b5 docs: record alpha.12 packwiz publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 08:33:43 +02:00
koka cdfbba0089 feat: add alpha.12 branching expansion laboratory
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 07:07:04 +02:00
koka f816ffc640 Record alpha.11 publication and verified Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 06:19:35 +02:00
koka f30a350240 Add Small and Large islands and accelerate exact world generation
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 06:17:52 +02:00
86 changed files with 10610 additions and 215 deletions
+115
View File
@@ -1,5 +1,120 @@
# Changelog
## 0.1.0-alpha.13.1 — 2026-09-09
- Corrige larrêt des expansions sous Windows : le journal ne tente plus
douvrir son dossier comme un fichier pour le synchroniser. Cette opération
reste exigée sur les systèmes de fichiers utilisant le séparateur Unix.
- Écriture du fichier synchronisée, remplacement atomique et relecture stricte
conservés. Les véritables erreurs de stockage arrêtent toujours les expansions.
- Après mise à jour et réouverture du même monde, une réservation valide déjà
enregistrée avant lerreur est reprise automatiquement. Aucun changement de
schéma du journal, de génération, de paramètres ou de chunks existants.
Le smoke du journal passe sur macOS, avec politique Windows simulée, erreurs
après remplacement et récupération au rechargement. Aucun essai Windows natif
na encore été réalisé. Voir le [dépannage](docs/alpha13-worldgen.md#correctif-alpha131--expansions-sous-windows)
et les [contrôles ciblés](docs/testing.md#correctif-alpha131--journal-dexpansion-sous-windows).
## 0.1.0-alpha.13 — 2026-09-09
- Un seul choix public **Sanctuary**, avec le bouton natif **Personnaliser** :
Petit (512 blocs), Moyen (724, par défaut), Grand (1 024). Le diamètre est
enregistré dans le générateur ; confirmer ou annuler agit sur le monde à créer.
- Île initiale issue de la densité et de lhydrologie complète de Population,
avec sa forêt de surface et les poches de biomes souterrains natives du
laboratoire. Les eaux et les ressources restent dépendantes du terrain.
- Reprise des outils dexpansion alpha.12.1 dans ce même monde : parent,
direction, climat, taille, relief, distance facultative et recherche de sites
libres, sans remplacer les chunks existants. Le déblocage collectif reste futur.
- Nouveau codec `sanctuary:island_v13`, racine de génération 13 et journal
`data/sanctuary-world-v13/expansions.json`. Les extensions restent de génération
12. Anciens codecs et presets conservés mais cachés de la création ; aucun
monde existant converti, agrandi ou régénéré.
- La suite GameTest unifiée devient le défaut de `check` et `runGameTest` ; les
essais historiques demandent `-PsanctuaryUnifiedTests=false`.
`check build assemblePack`, interface client, trois tailles et rechargement des
expansions vérifiés. Moyen : 63,66 s pour créer puis charger 289 chunks FULL
autour du spawn sur la graine 0, 7,82 s pour les 80 suivants ; mesure serveur,
sans garantie de FPS ni charge multijoueur. Release, MRpack et canal packwiz
publiés ; même instance Prism synchronisée deux fois, avec 601 fichiers
personnels et réglages suivis conservés.
Voir le [guide alpha.13](docs/alpha13-worldgen.md) et les [preuves](docs/testing.md).
## 0.1.0-alpha.12.1 — 2026-09-09 (historique)
- Corrige le blocage de toutes les directions lorsque du vide a déjà été
généré : la recherche continue plus loin sur le même rayon, sans remplacer
les chunks existants.
- Paramètre final `distance` facultatif pour `quick`, `create`, `preview` :
distance minimale entre centres en blocs, de 0 à 100 000 ; 0 ou omis = auto.
- Recherche limitée à 32 positions et un budget global de lecture de 30 secondes.
Les erreurs disque et timeouts interrompent la demande ; seul un emplacement
occupé permet de poursuivre. La graine et le profil sont conservés.
- Compatibilité des commandes précédentes et des mondes laboratoire alpha.12 :
journal v1 et génération 12 inchangés, sans migration ni régénération.
Les contrôles moteur de création et de rechargement passent sur la graine 42,
avec huit premiers sites occupés et distance explicite. `check build assemblePack`
passe, avec les douze tests historiques. Release, MRpack et canal publiés ;
la même instance Prism est synchronisée deux fois et ses 466 fichiers personnels
et réglages suivis sont conservés. Voir [WG-14](docs/backlog.md).
## 0.1.0-alpha.12 — 2026-09-09 (historique)
- Nouveau preset Sanctuary — Laboratoire, distinct de Petit/Grand. Outils
opérateur `quick`, `create`, `preview`, `list`, `visit`, `resume` pour tester
les expansions sans blocs de commande ni économie préalable.
- Îles de 64, 128, 256 ou 512 blocs de diamètre nominal ; parent et direction
enregistrés, climat libre parmi douze profils. La boussole climatique reste
un raccourci facultatif ; les branches peuvent composer plusieurs couronnes.
- Nouveaux reliefs hauts : naturel, pics, canyon, volcan. Le relief naturel est
aussi testé sur l’île initiale du laboratoire. Palette volcanique et petites
poches facultatives de fluides, sans ajout de fondations artificielles.
- 34 biomes vanilla déclarés dès le chargement, avec surface et poches souterraines.
Sélection de structures vanilla selon biomes, emprise et fondations : villages,
temples de désert/jungle, cabanes de marais et camps abandonnés.
- Journal serveur v1 atomique, paramètres immuables, reprise des réservations,
refus des chunks existants ou en cours de chargement/déchargement, vide compris.
Aucun changement des anciens générateurs ni migration dune sauvegarde.
Validation locale : `check build assemblePack`, douze tests moteur Grand de
graine 0, smokes de journal/relief, expansions et second processus de rechargement
sur la graine 42 avec camp et butin natifs. Les essais restent sans client ni
joueurs connectés. Release, MRpack et canal packwiz publiés ; la même instance
Prism est synchronisée deux fois avec 466 fichiers personnels et réglages
suivis conservés. Recherche collective,
boîtes postales, Void, hydrologie étendue et autres structures restent à construire.
Voir [le contrat du laboratoire](docs/alpha12-laboratory.md).
## 0.1.0-alpha.11 — 2026-09-09
- Deux choix à la création : **Petit** (512 blocs de diamètre nominal)
et **Grand** (environ 724 blocs), Grand par défaut. Leur aire nominale varie
dun facteur deux, sur une hauteur de 384 blocs. Les repères internes 5/10
ne désignent pas un nombre garanti de joueurs.
- Nouveau réglage `sanctuary:population_10` pour Grand. Les anciens formats
20/100 disparaissent des choix publics, mais restent chargeables avec leurs
identifiants et leur relief enregistrés. Aucune île existante nest réduite.
- Cache des coins dinterpolation conservant larithmétique exacte du moteur,
borné à 192 Kio par thread. Les plans régionaux déjà calculés restent lisibles
pendant le calcul dune nouvelle région ; le sampler est compilé une seule
fois par plan.
- À géométrie 20 identique, création et 289 chunks : 407,43 → 62,54 s
(84,65 %). Exploration de 80 chunks : 103,14 → 14,05 s. Le nouveau Grand
atteint 62,67 s sur le protocole de départ. Une seule mesure par version,
graine 0, M1/8 Gio : ce sont des temps serveur jusqu’à FULL, pas des FPS
ni une garantie de délai en jeu.
Validation : `check build assemblePack`, douze tests moteur pour Petit et
Grand, 1 800 ticks de fluides, persistance des quatre références et
3 183 804 comparaisons bit à bit de densités réelles, plus 122 528 synthétiques.
Les sources historiques de forme et les anciens réglages sont conservés.
Voir [Validation](docs/testing.md) et [Distribution](docs/packwiz.md).
Le canal stable est publié et la même instance Prism est synchronisée deux fois,
avec les 364 fichiers personnels et réglages suivis conservés.
## 0.1.0-alpha.10 — 2026-09-09
- Trois tailles de départ : **Sanctuary (20 joueurs)** par défaut, plus les
+153 -9
View File
@@ -14,8 +14,60 @@ infrastructures qui permettront progressivement d'ouvrir de nouveaux continents.
Ce dépôt démarre la nouvelle base Fabric. La [vision complète](docs/vision.md)
conserve les intentions ; le [backlog](docs/backlog.md) prépare les premiers
tickets. Les systèmes d'expansion, d'économie, de progression et les dimensions
décrits dans la vision ne sont pas encore implémentés.
tickets. Les systèmes déconomie, de progression collective et les dimensions
décrits dans la vision ne sont pas encore implémentés. Lalpha.13 réunit l’île
initiale et les outils dexpansion dans un seul choix de monde Sanctuary.
Les trois tailles et linterface native sont vérifiées. Lalpha.13 est publiée
et la même instance Prism est synchronisée ; les versions précédentes restent historiques.
## Alpha.13.1 — correctif des expansions sous Windows
Le journal dexpansion nessaie plus douvrir son dossier comme un fichier sous
Windows, ce qui provoquait `AccessDeniedException` et bloquait les expansions.
La synchronisation du fichier, son remplacement atomique et sa relecture restent
obligatoires. Le format du journal et la génération alpha.13 restent identiques.
Après installation du correctif, **rouvrir le même monde** : une réservation déjà
enregistrée est reprise après vérification du journal. Il ne faut pas supprimer
`sanctuary-world-v13` ni recréer le monde pour ce correctif. Voir le
[dépannage Windows](docs/alpha13-worldgen.md) et la [validation](docs/testing.md).
## Alpha.13 — un seul monde Sanctuary
À la création dun **nouveau monde**, sélectionner **Sanctuary**, puis utiliser
le bouton natif **Personnaliser** pour choisir la taille de l’île :
| Taille | Diamètre nominal |
| --- | --- |
| Petit | 512 blocs |
| Moyen, par défaut | 724 blocs |
| Grand | 1 024 blocs |
**Terminé** conserve le choix ; **Annuler** revient sans lappliquer. La hauteur
reste de 384 blocs. Ces tailles décrivent une emprise, sans promettre un nombre
de joueurs, une aire constructible ou un stock de minerais.
L’île reprend les forêts, le relief flottant et les plans deau complets du
générateur précédent : bassins, rivière, terrasses et cascades restent liés
aux possibilités naturelles du terrain. Les poches de cavités natives de Minecraft
du laboratoire rejoignent cette île. Les commandes opérateur
dexpansion, avec leur distance facultative et leur recherche demplacements
libres, sont accessibles dans ce même nouveau monde. Les déblocages par la
production et lXP restent à construire.
Les anciens choix Petit/Grand et Laboratoire sont cachés à la création, tout
en restant chargeables avec leurs paramètres enregistrés. **Aucun monde
existant nest converti ni régénéré.** Le [guide alpha.13](docs/alpha13-worldgen.md)
détaille la création, les expansions et la séparation des sauvegardes.
`check build assemblePack`, les tests client, les trois tailles et les scénarios
dexpansion/rechargement passent. Moyen atteint 63,66 secondes pour la création
puis 289 chunks FULL dans le test serveur de référence. Les preuves et leurs
limites sont dans [Validation](docs/testing.md).
Le canal packwiz, le MRpack et la même instance Prism sont à jour. Les deux
synchronisations conservent les 601 fichiers personnels et réglages suivis.
Voir [Distribution](docs/packwiz.md).
## Socle livré jusqu’à lalpha.8
@@ -62,7 +114,7 @@ sur les trois graines de référence, avec 1 800 ticks de fluides. Ces résultat
ne valident pas par avance lincrément suivant. Les observations et leurs
limites figurent dans [Validation](docs/testing.md).
## Alpha.9 : cavités vivantes et terrasses deau
## Alpha.9, historique : cavités vivantes et terrasses deau
Lincrément ajoute des cavités luxuriantes et lumineuses sous l’île, des
secteurs à spéléothèmes, des géodes daméthyste et des poches de soufre moins
@@ -86,9 +138,9 @@ anciennes sauvegardes alpha.5. Aucun lac, groupe de terrasses, geyser ou stock
de ressources nest garanti sur chaque graine. Voir le ticket local
[WG-10](docs/backlog.md).
## Alpha.10 : îles pour 5, 20 ou 100 joueurs
## Alpha.10, historique : îles pour 5, 20 ou 100 joueurs
Trois tailles de départ sont disponibles, avec **20 joueurs par défaut**.
Lalpha.10 proposait trois tailles, avec **20 joueurs par défaut**.
Leur aire nominale est proportionnelle à la capacité choisie, sur la base
de l’île de 512 blocs pour cinq joueurs. La hauteur reste de 384 blocs.
@@ -128,6 +180,95 @@ 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).
## Alpha.11, historique : Petit, Grand et génération plus rapide
Les anciens formats sont trop grands et éloignent les constructions. Le
nouveau choix de départ se limite à **Petit** et **Grand**, avec **Grand par
défaut**. Petit garde un diamètre nominal de 512 blocs ; Grand mesure environ
724 blocs, soit deux fois laire nominale de Petit. La hauteur reste de
384 blocs. Les repères internes 5 et 10 servent au calcul de surface : ils
ne sont ni les noms des choix ni une promesse de capacité multijoueur.
Grand utilise le nouveau réglage `sanctuary:population_10`. Les anciennes
parties `population_5`, `population_20` et `population_100` gardent leurs
paramètres ; les formats 20/100 sont retirés des choix publics de création,
sans réduire les mondes déjà enregistrés. Aucun monde personnel nest ouvert
pour les essais.
Le ticket [WG-12](docs/backlog.md) réduit les calculs répétés de terrain et
permet de lire un plan régional déjà prêt pendant le calcul du suivant.
À géométrie 20 identique, la création puis 289 chunks autour du spawn passent
de 407,43 à 62,54 secondes (84,65 %) ; 80 chunks dexploration passent de
103,14 à 14,05 secondes. Le nouveau Grand atteint 62,67 secondes sur le même
protocole de départ. Ces essais serveur sur une graine et une machine ne
mesurent pas le rendu du client ni une charge multijoueur.
`check build assemblePack` passe, ainsi que douze tests moteur pour chacun
des deux choix, avec 1 800 ticks de fluides. Plus de trois millions de
comparaisons de densité confirment l’équivalence de calcul. Les mesures et
limites sont dans [Validation](docs/testing.md) ; publication et synchronisation
de la même instance sont consignées dans [Distribution](docs/packwiz.md).
Lalpha.11 est publiée ; la même instance Prism a été synchronisée deux fois,
avec les 364 fichiers personnels et réglages suivis conservés.
## Alpha.12, historique — laboratoire dexpansion
Un troisième preset **Sanctuary — Laboratoire** permet de tester les expansions
dans un **nouveau monde**, sur la même version Minecraft. Les choix Petit et
Grand gardent leur génération alpha.11. Le laboratoire possède sa propre île
initiale de 512 blocs, avec un relief supérieur légèrement déformé et des biomes
vanilla. Les nouvelles îles choisissent séparément leur parent, direction,
climat, diamètre nominal (64/128/256/512) et relief (naturel, pics, canyon, volcan).
Avec les commandes autorisées ou le statut opérateur :
```text
/sanctuary expansion quick north autumn 128 peaks
/sanctuary expansion list
/sanctuary expansion visit island_1
/sanctuary expansion create oasis island_1 east arid 64 natural
```
Le premier exemple ouvre une forêt automnale au nord ; le second continent
part vers lest depuis cette île. `quick north` seul emploie la convention froide,
avec 128 blocs et un relief naturel. `create` permet de choisir explicitement un
identifiant et un parent. `preview` propose lemplacement sans lactiver ;
`resume <id>` reprend une réservation interrompue. Lautocomplétion liste les
directions, douze profils climatiques et quatre reliefs.
Les anciens chunks, même vides, sont refusés dans lemprise dune nouvelle île.
Le registre persiste côté serveur ; la préparation est progressive et la visite
attend un terrain prêt. Villages, temples, cabanes de marais et camps abandonnés
vanilla peuvent être retenus selon leur biome et leurs fondations. Les autres
structures, la grande hydrologie des continents, les recherches, les boîtes de
dépôt et le refuge Void restent des étapes suivantes. Un biome humide ne promet
pas encore un lagon ou un océan. Les règles, limites et essais figurent dans le
[guide du laboratoire](docs/alpha12-laboratory.md).
`check build assemblePack` et les essais dexpansion/rechargement de graine 42
passent. Lalpha.12, son MRpack et le canal packwiz sont publiés ; la même
instance Prism a été synchronisée deux fois, avec les 466 fichiers personnels
et réglages suivis conservés. Voir [Distribution](docs/packwiz.md).
## Alpha.12.1, historique — distance et emplacements libres
Le correctif ajoute une distance minimale facultative à la fin des commandes :
```text
/sanctuary expansion quick north autumn 128 peaks 2048
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
```
La distance se mesure entre les centres depuis le parent choisi. Sans distance,
le placement reste automatique. Les emplacements déjà générés sont maintenant
écartés en cherchant plus loin dans la même direction, sans changer le climat
ni réécrire les chunks. Les mondes laboratoire alpha.12 peuvent être conservés :
le journal et la génération restent compatibles. Voir le [guide](docs/alpha12-laboratory.md).
`check build assemblePack`, les scénarios moteur et les tests de distance passent.
Lalpha.12.1 et son MRpack sont publiés ; la même instance Prism est synchronisée
deux fois, avec 466 fichiers personnels et réglages suivis conservés.
## Versions et prérequis
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
@@ -142,7 +283,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.10 |
| Sanctuary / pack | 0.1.0-alpha.13, publiée et synchronisée |
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
@@ -163,13 +304,15 @@ cd sanctuary-beta
```
`check` inclut les tests de forme et le serveur de test automatisé de Fabric,
qui charge le vrai preset Sanctuary dans un monde de développement neuf.
qui charge par défaut le nouveau générateur unifié Sanctuary dans un monde
de développement neuf. Les suites historiques utilisent explicitement
`-PsanctuaryUnifiedTests=false`.
Les scénarios couverts et les essais multijoueurs restant à effectuer sont
décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.10.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.13.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).
@@ -187,7 +330,8 @@ Lancer le client de développement :
./gradlew :sanctuary:runClient
```
Créer un **nouveau monde**, puis sélectionner le type de monde **Sanctuary**.
Créer un **nouveau monde**, sélectionner le type **Sanctuary**, puis
**Personnaliser** pour changer la taille Moyen retenue par défaut.
L'installation du mod seule ne transforme pas un monde Minecraft ordinaire.
Pour un serveur de développement, lancer `./gradlew :sanctuary:runServer`, puis
+190
View File
@@ -0,0 +1,190 @@
# Alpha.12 — laboratoire dexpansion
Ticket local **WG-13**, branche `codex/expansion-lab`. Contrat du laboratoire et guide
dessai. Cette livraison reste une alpha ; le canal distribué est suivi séparément
dans [Distribution](packwiz.md).
## Décisions de conception
La direction géographique et le climat deviennent deux choix distincts. La
boussole nord froid / sud chaud / ouest sec / est humide reste un raccourci
facultatif. Une île peut prolonger le climat de son parent ou ouvrir une branche
vers un autre climat. Les parents et les coordonnées forment un graphe persistant,
permettant plusieurs couronnes autour de Sanctuary. Les recherches, leurs coûts,
les boîtes aux lettres et les boîtes de dépôt sont planifiés pour plus tard.
Cette alpha fournit des commandes opérateur : créer, suivre, lister et visiter
les expansions. Les tailles nominales des nouvelles îles sont 64, 128, 256 et
512 blocs. Les reliefs de laboratoire sappliquent aussi à l’île initiale, dans
un nouveau preset expérimental. Les structures vanilla compatibles sont admises
après contrôle de leur emprise et de leur support. Une absence reste possible.
Lalpha sert à éprouver les outils et les paysages. Le passage en bêta se fera
après définition du périmètre de la vision et de la progression jouable ; ce
numéro ne désigne pas une progression collective déjà livrée.
## Contrat de sauvegarde et douverture v1
- Activation exclusivement dans un **nouveau monde du laboratoire alpha.12**.
Les anciens presets, leurs paramètres, leurs chunks et leurs sauvegardes ne
sont pas convertis. Il nexiste pas de commande de migration ou de régénération.
- Un journal serveur propre au monde enregistre graine, version, origine,
identifiant, parent, direction, climat, relief, diamètre, centre et statut.
Les paramètres dune île sont immuables après réservation.
- Toute la réservation inclut terrain, décorations et halo moteur. Les régions
déjà présentes sur disque, chargées ou en cours de déchargement sont refusées,
même si leurs blocs sont tous vides. Une erreur de lecture interdit louverture.
- Le journal est écrit atomiquement et relu avant de publier le nouveau plan au
générateur. Un journal existant absent ou corrompu nest jamais remis à zéro.
- La génération travaille uniquement dans la région nouvelle. Les chunks voisins
nécessaires au moteur restent dans son halo. Aucun bloc dune ancienne région
nest remplacé. Les références et le placement des structures utilisent le moteur.
- Une réservation interrompue conserve son identité et sa graine. La préparation
peut reprendre, sans double île ni nouveau tirage, jusquau statut prêt.
- Les commandes exigent la permission opérateur. Être en créatif seul ne confère
aucun droit sur le serveur. Une opération ne téléporte personne implicitement.
- Le refuge Void et le déplacement collectif des joueurs restent un système
distinct à concevoir. Ils ne servent pas de mécanisme de protection des chunks.
Le halo impose une distance supplémentaire dans cette première version. Le placer
au plus proche ne permet pas de réutiliser silencieusement du vide déjà généré.
Les points dorigine enregistrés ne suivent pas les changements de lit ou de spawn.
## Commandes et palette
Créer un **nouveau monde**, type **Sanctuary — Laboratoire**, avec les commandes
activées. Pour un serveur neuf, utiliser `level-type=sanctuary:expansion_lab`
et un nouveau `level-name`. La graine `42` fournit un cas reproductible observé
avec un camp abandonné ; aucune structure nest garantie sur chaque graine.
```text
/sanctuary expansion quick north autumn 128 peaks
/sanctuary expansion list
/sanctuary expansion visit island_1
/sanctuary expansion create oasis island_1 east arid 64 natural
/sanctuary expansion preview glacier sanctuary south glacial 256 canyon
```
`quick` part toujours de Sanctuary et attribue un identifiant `island_N`.
`create <id> <parent> <direction> <climate> <diameter> <relief> [distance]` ouvre une branche
nommée depuis nimporte quelle île prête. `preview` emploie les mêmes paramètres ;
il propose des coordonnées mais ne valide ni loccupation des chunks ni le sol.
Répéter un `create` identique rend la même réservation ; un autre profil sous
le même identifiant est refusé. `resume <id>` reprend une préparation interrompue.
Après une erreur du journal, il faut rétablir le stockage puis recharger le monde.
Depuis l**alpha.12.1**, `quick`, `create` et `preview` acceptent une distance
minimale facultative à la fin, de 0 à 100 000 blocs. Elle mesure la distance
entre les centres du parent et de lenfant, y compris en diagonale. Lalignement
des chunks et les marges du terrain peuvent éloigner légèrement le résultat.
Une valeur de 0, ou son omission, laisse le placement automatique.
```text
/sanctuary expansion quick north autumn 128 peaks 2048
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
/sanctuary expansion preview glacier sanctuary northeast glacial 256 canyon 2048
```
Si une zone est déjà générée, `create` et `quick` essaient plus loin dans la
même direction, sur 32 positions maximum avec un budget total de lecture de
30 secondes. Le climat, le relief et la graine restent ceux de la demande.
`preview` propose seulement le premier emplacement géométrique ; le résultat
réel peut être plus loin. Les erreurs de lecture interrompent la recherche.
Les anciens appels restent valides. Un identifiant déjà créé garde sa position ;
demander sous le même nom une distance supérieure à sa distance réelle est refusé.
Cette correction utilise le même journal v1 et les mêmes descripteurs de
génération 12 : le monde laboratoire alpha.12 actuel peut être conservé. Aucun
ancien chunk nest régénéré ; la recherche réserve uniquement une nouvelle zone.
| Profil de commande | Surface recherchée |
| --- | --- |
| `temperate` | Chênes, bouleaux, clairières et fleurs |
| `boreal` | Taïga, bosquets et neige en hauteur |
| `glacial` | Plaines enneigées, pics gelés et pointes de glace |
| `cold_humid` | Vieilles taïgas et bosquets froids |
| `humid` | Forêt sombre, marécages et Pale Garden ponctuel |
| `tropical` | Jungle, bambous et mangrove basse |
| `warm` | Savane et plateaux |
| `arid` | Désert, badlands et cheminées érodées |
| `dry` | Prairies exposées et reliefs pierreux |
| `cold_dry` | Neige, gravier et sommets gelés |
| `autumn` | Dappled Forest, prairies et bouleaux ponctuels |
| `cherry` | Cerisiers, prairies et bosquets en hauteur |
La sélection dépend de la graine, de laltitude et de variations locales ; une
petite île naffiche pas nécessairement tous les biomes de son profil. Des poches
luxuriantes, à spéléothèmes ou soufrées diversifient les étages inférieurs.
Les reliefs `natural`, `peaks`, `canyon` et `volcano` sont indépendants du climat.
Le volcan déforme et creuse la matière existante ; il ne garantit pas un cône
plein ni un lac de lave lorsque la géométrie centrale est ajourée.
Les valeurs par défaut de `quick` suivent la boussole décrite dans
[Expansion](expansion.md), avec diamètre 128 et relief naturel. Les directions
acceptées sont `north`, `northeast`, `east`, `southeast`, `south`, `southwest`,
`west`, `northwest`. Lautocomplétion fournit les paramètres disponibles.
## Portée technique et limites
Le codec `sanctuary:expansion_lab` enregistre le générateur ; le journal conserve
les descripteurs de génération **12**. Chaque île emploie sa propre graine dérivée
de la graine du monde et de son identifiant. Changer didentifiant change donc
le tirage. Les instances de bruit sont propres aux îles, y compris après reload.
Les 34 biomes vanilla sont déclarés avant la première expansion.
La densité flottante existante sert de base. Le laboratoire ajoute les
transformations des reliefs hauts et utilise les matériaux et décorations natifs.
Ses carvers vanilla sont désactivés pour conserver les supports prospectés.
Les grandes rivières et terrasses hydrologiques alpha.11 ne sont pas transférées
dans ce prototype : les anciens presets les conservent. Le laboratoire essaie
seulement de petites sources facultatives sur support rocheux fermé.
Les structures autorisées sont les villages (diamètre au moins 256), pyramides
du désert et de jungle, cabanes de marais et camps abandonnés. Les starts natifs
sont acceptés après vérification de leur emprise et de leurs fondations ; le
moteur place les pièces, références et butins. Leur grille de sélection diffère
de celle de `/locate` vanilla : cette commande nest pas un outil fiable pour
les retrouver dans le laboratoire. La preuve actuelle porte sur un camp ; elle
ne valide pas visuellement chaque type de village ou de temple.
La réservation ajoute 32 blocs de décoration et 176 blocs de halo moteur autour
de lenveloppe de terrain. Les petites îles sont donc encore assez éloignées :
le test de diamètre 64 place une première île à 800 blocs du centre de Sanctuary,
puis une branche à 544 blocs de son parent. Un emplacement déjà exploré est
écarté ; depuis lalpha.12.1, la recherche continue plus loin sans réécrire ce vide.
Il existe une limite de 64 régions, origine comprise, et de ±100 000 blocs pour
leurs centres. La vérification initiale doccupation est synchrone et peut
suspendre brièvement le serveur pendant les lectures disque ; la préparation
suivante avance chunk par chunk.
Les bateaux volants, montgolfières, panthéons, îles uniques en béton coloré,
océans coralliens, recherches, coûts et boîtes de dépôt restent dans la vision.
Aucun command block, transfert collectif au Void ou reset global nest nécessaire
pour utiliser ces commandes.
## Observations reproductibles
Sur la graine **42**, le scénario moteur a ouvert une île tropicale de 64 blocs
au nord, puis une île aride de 64 blocs à lest de celle-ci. Chaque préparation
a atteint 100 chunks FULL. Les biomes sauvegardés, les points darrivée sur sol
réel, les permissions du dispatcher, le refus des doublons incohérents, la
conservation dun bloc témoin sur Sanctuary et le refus du vide déjà chargé
ont été vérifiés. Un camp abandonné possède un tonneau placé avec sa table de
butin vanilla. Sur la graine **0**, ces expansions fonctionnent mais aucun des
11 sites de structure candidats na passé les conditions de terrain ; le test
exigeant une preuve de butin a donc échoué sur cette graine.
Un second processus Java a rouvert le monde de développement de graine 42 et
retrouvé les trois régions prêtes, le bloc dor écrit avant larrêt, les colonnes
de blocs, biomes, densités et le tonneau. Le codec sauvegardé a aussi été décodé.
Le framework GameTest reconstruit ses réglages depuis le preset : ce contrôle
ne remplace pas un essai du parcours normal de chargement dans le client.
La reprise dun journal réservé est contrôlée séparément par le smoke ; un
crash forcé en pleine génération, les téléportations de vrais joueurs et la
charge multijoueur ne sont pas simulés.
Les smokes parcourent cinq graines, les quatre tailles et les quatre reliefs :
transformation verticale monotone, dessous de lorigine conservé, canyon retirant
de la matière et couronne volcanique. Ce sont des contrôles géométriques ; les
profils 128/256/512 et les quatre reliefs demandent encore une appréciation
visuelle en jeu. Voir [Validation](testing.md) pour les commandes et preuves.
+212
View File
@@ -0,0 +1,212 @@
# Alpha.13 — un monde Sanctuary unifié
**Publiée et synchronisée dans la même instance Prism.** Cette version réunit l’île de départ complète et les outils
dexpansion du laboratoire.
[Validation](testing.md) décrit les preuves disponibles et
[Distribution](packwiz.md) indique la version effectivement publiée.
La cible reste **Minecraft 26.3-pre-2 / Fabric**. Ce guide concerne un nouveau
monde alpha.13. Les anciens mondes conservent leur génération.
## Créer son monde
1. Créer un **nouveau monde**, puis sélectionner le type **Sanctuary**.
2. Ouvrir le bouton natif **Personnaliser**.
3. Choisir la taille, puis cliquer sur **Terminé** avant de créer le monde.
| Taille | Diamètre nominal de l’île initiale |
| --- | --- |
| Petit | 512 blocs |
| Moyen, choix par défaut | 724 blocs |
| Grand | 1 024 blocs |
**Annuler** revient sans appliquer le changement. La hauteur reste de 384 blocs,
de `Y=0` à `Y=383`. Le diamètre est une référence pour la forme : trous, surplombs,
fragments et contour irrégulier dépendent de la graine. Il ne mesure pas une
surface entièrement constructible et ne fixe pas un nombre de joueurs.
La liste des types de monde contient un seul choix Sanctuary ; les types
Minecraft habituels restent disponibles. Petit, Moyen et Grand sont des
réglages de ce même choix. Lancien Grand alpha.11, de 724 blocs, correspond
au diamètre nominal du nouveau Moyen.
Sur un serveur neuf, le preset suivant crée un monde de taille Moyen :
```properties
level-type=sanctuary:sanctuary
level-name=sanctuary-dev
level-seed=42
```
Choisir un dossier de monde neuf pour un essai. Le bouton **Personnaliser**
configure la création en solo ; il ne modifie pas les fichiers dun serveur
existant. Installer le mod ou changer `level-type` ne transforme pas une partie.
## L’île de départ
Le nouveau monde reprend le générateur complet de l’île initiale : forêts de
chênes et de bouleaux, clairières, affleurements, strates rocheuses et corniches
intérieures. Son relief flottant conserve les trous et les surplombs, sans
ajouter de noyau cylindrique pour garantir un terrain à lorigine.
Les plans hydrologiques de Population sont repris : étangs et lacs, recherche
de rivière, bassins étagés et leurs débordements, sources et cascades, poche et
coulées de lave. Le placement dépend des supports réellement présents dans la
roche. Un plan qui ne trouve pas de site admissible reste absent ; aucune
rivière, terrasse ou ressource nest garantie sur chaque graine. Les eaux en
terrasses permettent la nage verticale sans ajouter de culture de riz.
Les poches souterraines natives du laboratoire rejoignent cette île : cavités
luxuriantes, à spéléothèmes ou soufrées, avec leurs décorations Minecraft.
Il sagit toujours des profondeurs de lOverworld, pas de la dimension des
cavernes prévue dans la vision. L’île garde sa palette tempérée en surface ;
Dappled Forest reste disponible dans les expansions au climat automnal.
Lintégration protège les volumes déclarés par les plans deau et de lave
contre les écritures de décoration suivantes. Cette protection concerne les
plans de génération, sans mettre les blocs à labri des actions des joueurs.
Les preuves moteur et leurs limites sont suivies dans [Validation](testing.md).
## Ajouter une île
Les commandes demandent les commandes autorisées en solo ou la permission
opérateur sur le serveur. Elles reprennent les outils alpha.12.1 dans le nouveau
monde Sanctuary. L’économie, les recherches et les contributions collectives
ne conditionnent pas encore leur utilisation.
```text
/sanctuary expansion quick north autumn 128 peaks
/sanctuary expansion list
/sanctuary expansion visit island_1
/sanctuary expansion create oasis island_1 east arid 64 natural 2048
```
`quick` ouvre une île depuis Sanctuary et lui attribue un identifiant `island_N`.
Lexemple de visite suppose quil sagit de la première réservation ; `list`
indique lidentifiant et l’état réels. La visite attend que l’île soit prête.
`create` choisit un nom et un parent déjà prêt pour prolonger une branche.
La création ne téléporte personne implicitement.
Les nouveaux continents choisissent leur propre direction, climat, diamètre
**64/128/256/512** et relief **natural/peaks/canyon/volcano**. La direction ne
contraint pas le climat. Lautocomplétion fournit les huit directions et les
douze profils : `temperate`, `boreal`, `glacial`, `cold_humid`, `humid`, `tropical`,
`warm`, `arid`, `dry`, `cold_dry`, `autumn`, `cherry`.
Le dernier nombre facultatif est une **distance minimale entre les centres**
du parent et de lenfant, entre 0 et 100 000 blocs. Une valeur de 0 ou son
omission laisse le placement automatique. Les marges et lalignement sur les
chunks peuvent éloigner le centre retenu.
Si le premier site est occupé, la recherche continue plus loin dans la même
direction : au plus 32 positions et 30 secondes de lecture au total. Même du
vide déjà généré compte comme occupé. Une erreur disque arrête la demande ;
elle nautorise pas à remplacer les chunks concernés.
```text
/sanctuary expansion preview glacier sanctuary northeast glacial 256 canyon 2048
/sanctuary expansion resume island_1
```
`preview` calcule une proposition géométrique sans activer l’île et sans vérifier
loccupation ou les fondations. `resume` reprend une réservation interrompue,
avec sa graine et ses paramètres conservés. Le système ne fournit aucune commande
de régénération des régions déjà créées.
Les îles ajoutées gardent la génération dexpansion alpha.12 : leurs climats,
reliefs, décorations et structures compatibles. Elles ne reçoivent pas
systématiquement la grande rivière ou les terrasses de l’île initiale. Villages,
temples, cabanes de marais et camps abandonnés dépendent du biome, du diamètre
et des fondations ; aucune présence nest garantie. Les règles de progression
futures restent décrites dans [Expansion](expansion.md).
## Correctif alpha.13.1 — expansions sous Windows
Lerreur `AccessDeniedException` visant le **dossier**
`data/sanctuary-world-v13`, depuis `ExpansionJournal.persist`, vient de la
synchronisation du répertoire après l’écriture du journal. Java sous Windows
ne permet pas cette ouverture avec `FileChannel` ; ce message ne signifie pas
à lui seul que le joueur manque de droits sur sa sauvegarde.
1. Fermer Minecraft pour arrêter la session dont les expansions sont bloquées.
2. Mettre linstance Prism existante à jour vers **0.1.0-alpha.13.1** via son
canal packwiz, puis relancer le jeu.
3. Rouvrir **le même monde** et consulter `/sanctuary expansion list`.
Si linstance Windows a été importée depuis le MRpack sans auto-update, fermer
Minecraft puis remplacer seulement `sanctuary-0.1.0-alpha.13.jar` par le JAR
`sanctuary-0.1.0-alpha.13.1.jar` de la release dans son dossier `mods`.
Conserver un seul JAR Sanctuary actif ; les autres mods et les sauvegardes
restent dans la même instance. Le raccordement au canal stable est décrit dans
[Distribution](packwiz.md#raccorder-linstance-prism-existante).
Le remplacement du journal ayant précédé cette erreur, lexpansion demandée
peut déjà être enregistrée comme `reserved`. Au chargement, les réservations
valides reprennent automatiquement avec leur identifiant, leur graine et leur
position. Il nest pas nécessaire de recréer le monde ou de supprimer le dossier
du journal. La commande `resume` seule ne débloque pas une session dont le
journal a été déclaré indisponible : sa relecture exige de rouvrir le monde.
Le correctif conserve le format et le contenu du journal existant ; il ne
migre ni ne régénère les chunks. La [validation ciblée](testing.md#correctif-alpha131--journal-dexpansion-sous-windows)
distingue les contrôles locaux de lessai Windows natif restant à effectuer.
## Paramètres et sauvegardes
Le preset public `sanctuary:sanctuary` utilise le nouveau codec de générateur
`sanctuary:island_v13`. Sa source de biomes est également enregistrée sous
`sanctuary:island_v13`. Le générateur sérialise `initial_diameter`, limité à
512, 724 ou 1 024 ; une valeur absente prend 724. Ce champ appartient à la
sauvegarde créée, et ne suit pas les changements ultérieurs de défaut du pack.
Pour l’île initiale, les réglages internes `sanctuary:unified_5`,
`sanctuary:unified_10` et `sanctuary:unified_20` correspondent respectivement
aux diamètres 512, 724 et 1 024. Les suffixes sont des références de calcul,
pas des capacités affichées. Ils reprennent la densité Population et son
hydrologie avec le générateur et le `RandomState` propres à lorigine.
La source combine la palette Population de départ et le sélecteur de cavités
natives du laboratoire, avec les mêmes conditions de sélection de ces poches.
Les îles enfants conservent le sélecteur climatique du laboratoire. Les 34
biomes natifs de cette palette et les dix biomes Population sont déclarés
avant la première expansion ; TerraMix nest pas une dépendance téléchargée.
Le nouveau journal est enregistré dans le dossier du monde :
```text
data/sanctuary-world-v13/expansions.json
```
Son schéma reste **1**, avec une racine de **génération 13** et des extensions
de **génération 12**. Les identifiants, parents, graines, paramètres, positions
et états douverture sont conservés côté serveur. La racine et le diamètre
initial ne sont pas modifiables par une commande dexpansion.
Les mondes du laboratoire alpha.12/12.1 gardent leur codec et leur journal
`data/sanctuary-expansion-v1/expansions.json`. Les générations Population et
les autres générations antérieures gardent leurs paramètres enregistrés.
Les presets historiques sont conservés mais cachés dans la création publique.
**Aucun monde nest converti, agrandi, réduit ou régénéré par lalpha.13.**
Le [guide alpha.12](alpha12-laboratory.md) est une référence historique, pas une
instruction de sélectionner à nouveau le laboratoire pour cette version.
## Contrôles de développement
`./gradlew check build assemblePack` et `:sanctuary:runGameTest` sélectionnent
par défaut la suite unifiée. Son diamètre de test se choisit avec
`-PsanctuaryTestDiameter=512`, `724` ou `1024` ; il ne change pas le défaut public.
Les suites Population historiques demandent `-PsanctuaryUnifiedTests=false`,
avec leur profil `-PsanctuaryTestPlayers=5`, `10`, `20` ou `100`. Les essais du
laboratoire historique utilisent aussi ce drapeau, avec
`-PsanctuaryExpansionTests=true`. Ces processus travaillent dans les dossiers
de développement ignorés, jamais dans les sauvegardes personnelles.
Les validations et mesures des alpha.11, .12 et .12.1 restent historiques.
Elles ne prouvent ni les temps de chargement ni le rendu de cette intégration.
Les trois tailles, linterface native et les expansions/rechargements passent
les contrôles alpha.13. Moyen atteint 63,66 s pour créer le monde puis charger
289 chunks FULL sur la graine 0 ; voir [Validation](testing.md) pour le protocole
et ses limites. La release, le MRpack et le canal packwiz sont publiés ; deux
synchronisations dans la même instance Prism conservent les 601 fichiers
personnels et réglages suivis. Voir [Distribution](packwiz.md).
+175
View File
@@ -16,6 +16,8 @@ WG-09 est livré en alpha.8, avec mesures moteur et mise à jour Prism vérifié
WG-10 est livré en alpha.9, avec mesures moteur et synchronisation Prism vérifiées.
WG-11 est livré en alpha.10 : tailles 5/20/100 validées sur la graine 0,
release publiée et même instance Prism synchronisée.
WG-12 est livré en alpha.11 : Petit/Grand, génération accélérée et même instance
Prism synchronisée deux fois ; voir [Distribution](packwiz.md).
Résultat visé : un client et un serveur Fabric compatibles peuvent ouvrir un monde Sanctuary, plusieurs joueurs y arrivent sur une même île sûre et les chunks extérieurs restent vides. La génération est reproductible et ses limites sont documentées.
@@ -359,6 +361,179 @@ Les anciens mondes restent inchangés. Les observations ne couvrent pas toute
l’île de 100 joueurs, et le coût initial dun plan régional reste notable.
Ce ticket local nest pas une issue distante publiée.
### WG-12 — Petit, Grand et génération plus rapide — alpha.11 livrée
**Branche :** `codex/worldgen-performance`.
**Problème :** les grands formats éloignent trop les constructions. Les essais
signalent aussi plusieurs minutes de chargement puis des chunks difficiles à
utiliser. Le ticket rapproche les deux choix de départ et optimise les calculs,
avec des preuves distinctes pour la taille et la vitesse.
**Critères dacceptation :**
- Proposer seulement **Petit** et **Grand** dans la création, avec **Grand par
défaut**, et libellés FR/EN. Petit garde le diamètre nominal de 512 blocs ;
Grand vise environ 724 blocs, soit une aire nominale deux fois supérieure.
La hauteur reste de 384 blocs. Les nombres 5/10 ne servent que de repères
internes de surface, sans capacité de joueurs garantie.
- Associer Petit à `sanctuary:sanctuary_5` / `sanctuary:population_5` et Grand
à `sanctuary:sanctuary` / nouveau réglage `sanctuary:population_10`.
Garder les anciens réglages `population_20` et `population_100` chargeables,
tout en cachant leurs formats dans les choix publics de création.
- Ne pas modifier le relief dune sauvegarde existante, son identifiant ou
son format. Les changements du défaut de création ne convertissent pas
un monde. Aucune sauvegarde personnelle nest ouverte pendant les essais.
- Pour loptimisation, **conserver les résultats à paramètres identiques** :
densités, plans et blocs utiles, ordre déterministe et protections de leau,
des terrasses et de la lave. Le nouveau relief Grand a une clé distincte ;
la réduction de surface ne compte pas comme gain de calcul à géométrie égale.
- Mesurer séparément démarrage initial, planification et génération dun lot
explicite de chunks. Fixer machine, Java, mémoire, graine, paramètres,
coordonnées et état des caches pour comparer alpha.10 et alpha.11.
- Viser **120 secondes au plus de démarrage initial** pour les choix publics
et **au moins 30 % de gain** sur les phases comparées à géométrie identique.
Un temps de planificateur seul ne prouve pas lentrée dans un monde jouable.
Préciser les bornes de chronométrage et ne pas promettre ces seuils pour
tout matériel ou pour une charge de cent joueurs.
- Valider Petit et Grand, plus un témoin de compatibilité et de performance
sur lancien profil 20. Les anciennes mesures 100 restent historiques ;
ce format nest plus un choix public ni le centre du plan de validation.
Examiner le premier accès, le cache réutilisé et les chunks dexploration.
- Conserver les contrôles moteur de fluides et de réentrée sans marqueur,
puis exécuter `check build assemblePack` sur les sources finales. Garder
visibles les limites et toute cible de performance non atteinte.
- Après validation seulement : artefacts immuables, canal packwiz stable,
deux synchronisations isolées puis deux dans la même instance Prism. Les
mondes, réglages et mods personnels, même désactivés, doivent être préservés.
**État :** livré. Artefacts immuables publiés, canal stable avancé, deux
synchronisations isolées et deux dans la même instance Prism, avec 364 fichiers
personnels et réglages suivis conservés. Tailles et code validés. `check build assemblePack` passe, ainsi
que douze tests moteur sur Petit et Grand (graine 0, 1 800 ticks), les contrôles
de persistance et l’équivalence bit à bit. Le benchmark ancien20 comparable
baisse de 84,65 % au départ et de 86,38 % en exploration. Grand atteint
62,67 secondes pour création + 289 chunks FULL. Cette mesure serveur ne
valide pas le rendu ni un temps de chargement universel. Voir
[Validation](testing.md), [résultats](benchmarks/alpha11-generation.json) et
[Distribution](packwiz.md) pour le reçu de publication et de synchronisation.
Ce ticket local nest pas une issue distante publiée.
### WG-13 — Laboratoire dexpansion à branches — alpha.12
**Branche :** `codex/expansion-lab`.
**But :** tester en opérateur de nouvelles îles, des climats alternatifs, les
reliefs hauts et les structures vanilla avant de développer leur progression.
**Critères dacceptation :**
- Nouveau preset laboratoire ; Petit/Grand et anciennes parties conservés.
- Commandes pour préparer, créer, suivre, reprendre et visiter une île ;
opérateurs uniquement, raccourcis et retours FR/EN.
- Parent et direction indépendants du climat, avec tailles nominales bornées
64/128/256/512 et reliefs versionnés. Une branche peut changer de climat.
- Application du relief haut aussi à l’île initiale expérimentale ; dessous
historique et sources anciennes préservés.
- Structures vanilla sélectionnées par biome avec contrôles demprise/support ;
preuve moteur dune structure réellement placée et de son butin natif.
- Journal atomique et repris après interruption, demandes répétées sans doublon,
refus des chunks existants y compris vides, conservation dun bloc témoin.
- Contrat de réservation explicite avant toute activation. Aucun ancien monde
converti ; aucune suppression de chunks ni commande de régénération.
- `check build assemblePack` et tests moteur sur monde jetable, avec limites
documentées. Les recherches, prix, boîtes postales et le Void restent planifiés.
**État :** implémenté et vérifié localement : build/pack, douze tests historiques,
expansions et rechargement moteur de graine 42. Release et MRpack publiés,
canal packwiz avancé et même instance Prism synchronisée deux fois ; 466 fichiers
personnels et réglages suivis conservés. Validation visuelle et multijoueur
encore ouverte. Contrat et portée des preuves
dans [Laboratoire alpha.12](alpha12-laboratory.md).
Ce ticket ne clôture pas par avance les océans, toute la palette de structures
ou la progression collective prévus par WG-04 à WG-07 et la vision.
### WG-14 — Distance et recherche dun emplacement libre — alpha.12.1
**Branche :** `codex/expansion-free-space`.
**Problème :** les huit premiers emplacements peuvent contenir du vide déjà
sauvegardé. Lalpha.12 refuse alors toutes les directions au lieu de chercher
plus loin, et ne propose aucun paramètre de distance.
**Contrat et résultat attendu :**
- Distance minimale facultative en fin de `quick`, `create` et `preview`, en
blocs entre les centres du parent et de lenfant ; 0 ou omission = automatique.
- Rechercher au plus 32 positions sur le même rayon en dépassant les zones
occupées ; conserver le budget global de lecture de 30 secondes.
- Seule une occupation certaine autorise un nouvel essai. Une erreur disque,
un timeout ou un terrain sans sol ne doivent pas devenir un nouveau tirage.
- Préserver les anciennes commandes, les identifiants, la graine de chaque île,
les descripteurs de génération 12 et le journal v1. La correction sapplique
aux mondes laboratoire alpha.12 sans conversion. Les régions existantes
sont inchangées ; seules de nouvelles réservations peuvent être ajoutées.
- Vérifier les huit premières directions occupées, la création plus loin,
la distance demandée depuis une île fille et les chunks sauvegardés après
redémarrage. Fournir les retours FR/EN et la distribution packwiz/MRpack.
**État :** correction implémentée, smokes et scénarios moteur de création/rechargement
réussis. `check build assemblePack` et douze tests historiques réussis.
Release, MRpack et canal packwiz publiés ; même instance Prism synchronisée
deux fois, avec 466 fichiers personnels et réglages suivis conservés.
### WG-15 — Unifier Sanctuary et ses options — alpha.13
**Branche :** `codex/unified-worldgen`.
- Un seul type public Sanctuary, bouton Personnaliser avec Petit (512), Moyen
(724, défaut), Grand (1 024). Choix enregistré dans le générateur ; Annuler
conserve les réglages. Les types vanilla restent disponibles.
- Réunir le relief, les forêts, minerais, rivières, bassins, terrasses et lave
de Sanctuary avec les cavités vanilla luxuriantes, à spéléothèmes et soufrées
du laboratoire, en protégeant les volumes hydrologiques pendant la décoration.
- Les outils dexpansion alpha.12.1 fonctionnent dans les nouveaux mondes unifiés.
Même contrat opérateur, réservation de zones nouvelles et refus du vide exploré.
- Nouveau codec et origine génération 13 ; les anciens codecs et journaux restent
chargeables. Aucun monde personnel nest ouvert ou converti, aucun chunk régénéré.
- Vérifier les trois tailles, cavités réellement décorées et fluides après ticks,
persistance/rechargement, UI client, build et distribution dans la même instance.
**État :** implémenté et vérifié : bouton natif, paramètres sur disque, huit tests
moteur par taille sur la graine 42, eau après 1 800 ticks, trois cavités natives,
compatibilité historique et rechargement des expansions. Moyen : 63,66 s pour
création et 289 chunks FULL dans le protocole serveur de référence.
`check build assemblePack` passe. Release, MRpack et canal packwiz publiés ;
même instance Prism synchronisée deux fois, avec 601 fichiers personnels et
réglages suivis conservés.
Les limites, dont labsence de preuve de camp dans lorigine unifiée des graines
inspectées, sont documentées dans [Validation](testing.md).
### WG-16 — Journal dexpansion sous Windows — alpha.13.1
**Branche :** `codex/windows-expansion-journal`.
**Problème :** après le remplacement atomique du journal, louverture de son
répertoire par `FileChannel` échoue sous Windows avec `AccessDeniedException`.
La session arrête alors toute expansion, même si la réservation a déjà été écrite.
- Écarter uniquement la synchronisation de répertoire non prise en charge par
le système de fichiers Windows ; conserver force du fichier, remplacement
atomique, relecture stricte et arrêt sur les véritables erreurs de stockage.
- Conserver le schéma, les identifiants, la géométrie et les journaux existants.
Après mise à jour et rechargement, reprendre les réservations déjà enregistrées
avec le mécanisme existant, sans suppression ni reconstruction de lhistorique.
- Tester le chemin Windows par injection, une panne après remplacement et la
reprise, puis le build et les scénarios moteur dans le développement local.
- Publier le correctif sur le canal stable. La machine Windows signalée nest
pas accessible depuis cet environnement Mac : ne pas prétendre lavoir testée.
**État :** correctif et tests vérifiés en alpha.13.1. `check build assemblePack`
passe, ainsi que le smoke du journal et les scénarios de création/rechargement
dans deux processus Minecraft distincts. Validation Windows par injection sur
macOS ; aucun essai Windows natif. Publication et synchronisation du canal en
cours ; preuves dans [Validation](testing.md).
## Réserve de thèmes futurs
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
+654
View File
@@ -0,0 +1,654 @@
{
"ticket": "WG-12",
"version": "0.1.0-alpha.11",
"date": "2026-09-09",
"protocol": {
"machine": "Apple M1, 8 GiB RAM, macOS",
"minecraft": "26.3-pre-2",
"fabric_loader": "0.19.5",
"fabric_api": "0.160.0+26.3",
"baseline_source_commit": "a26b72f",
"production_files_changed_in_baseline": 0,
"runs_per_configuration": 1,
"world": "fresh ignored development world per run",
"workload": "289 synchronous FULL chunks in concentric rings around natural spawn, then 80 FULL chunks in an eastward corridor. Minecraft world creation timed separately from startup log.",
"scope": "Server generation up to FULL. Does not establish ticking readiness, postprocessing completion, client rendering, FPS, multiplayer capacity or a guaranteed user loading time.",
"baseline_scope_correction": "The raw alpha.10 report incorrectly says postprocessing is included; FULL alone does not guarantee this. Workload code is identical; alpha.11 corrects only that explanatory string.",
"build_time_excluded": true,
"comparison": "Only legacy20 versus legacy20 measures the algorithm gain. Grand10 changes geometry; its exploration timing cannot be used for that comparison.",
"frozen_runtime_files": 272,
"frozen_runtime_manifest_sha256": "1c2b433417a5d9e6a219e9ee19781574538d2167c774e14ed2bf254d277d4482",
"baseline_runtime_manifest_sha256": "d4860072d1896f0dfd784a5876f1253367b7818a2023f1910ba1612b6f0e7103",
"benchmark_source_sha256": "408711c97d65de63af4e0a1eac90e6edb5bf2df2b70c43772a3fdef7347d75d1"
},
"runs": {
"alpha10_legacy20": {
"version": "0.1.0-alpha.10",
"players": 20,
"seed": 0,
"spawn": [
0,
252,
0
],
"java": "25.0.3",
"processors": 8,
"max_heap_bytes": 2147483648,
"spawn_area_ms": 310664.163542,
"spawn_area_full_chunks": 289,
"spawn_area_nonempty_chunks": 289,
"rings": [
{
"elapsed_ms": 2.609,
"cumulative_chunks": 1,
"radius": 0
},
{
"elapsed_ms": 5468.709333,
"cumulative_chunks": 9,
"radius": 1
},
{
"elapsed_ms": 17601.560917,
"cumulative_chunks": 25,
"radius": 2
},
{
"elapsed_ms": 27397.566458,
"cumulative_chunks": 49,
"radius": 3
},
{
"elapsed_ms": 36562.853333,
"cumulative_chunks": 81,
"radius": 4
},
{
"elapsed_ms": 44462.775334,
"cumulative_chunks": 121,
"radius": 5
},
{
"elapsed_ms": 52423.80275,
"cumulative_chunks": 169,
"radius": 6
},
{
"elapsed_ms": 60416.705916,
"cumulative_chunks": 225,
"radius": 7
},
{
"elapsed_ms": 66303.128125,
"cumulative_chunks": 289,
"radius": 8
}
],
"spawn_area_chunk_latency_ms": {
"median": 993.3335,
"p95": 1392.158958,
"max": 2560.16575
},
"exploration_ms": 103141.834334,
"exploration_full_chunks": 80,
"exploration_steps": [
{
"elapsed_ms": 5146.88675,
"chunk_x": 9
},
{
"elapsed_ms": 5087.495583,
"chunk_x": 10
},
{
"elapsed_ms": 5229.782834,
"chunk_x": 11
},
{
"elapsed_ms": 5046.559,
"chunk_x": 12
},
{
"elapsed_ms": 4810.5415,
"chunk_x": 13
},
{
"elapsed_ms": 28755.95025,
"chunk_x": 14
},
{
"elapsed_ms": 4763.415834,
"chunk_x": 15
},
{
"elapsed_ms": 4772.209166,
"chunk_x": 16
},
{
"elapsed_ms": 4826.460208,
"chunk_x": 17
},
{
"elapsed_ms": 4857.054416,
"chunk_x": 18
},
{
"elapsed_ms": 4947.188167,
"chunk_x": 19
},
{
"elapsed_ms": 5168.714916,
"chunk_x": 20
},
{
"elapsed_ms": 4897.048167,
"chunk_x": 21
},
{
"elapsed_ms": 5286.23025,
"chunk_x": 22
},
{
"elapsed_ms": 4865.694458,
"chunk_x": 23
},
{
"elapsed_ms": 4661.793875,
"chunk_x": 24
}
],
"exploration_chunk_latency_ms": {
"median": 925.461875,
"p95": 1273.829625,
"max": 25131.068417
},
"regions_cached": 2,
"passed": true,
"world_creation_ms": 96766,
"world_creation_plus_spawn_area_ms": 407430.163542,
"raw_report_sha256": "d70c4ac49f37470d4db15b9200e26da8ac9d4d3b1b008e7ec03e67deafcc0f93",
"log_sha256": "5b1df55f7a167003c089e80fe1fb824d087c137e3662bac84af3ae0b325b8392"
},
"alpha11_legacy20": {
"version": "0.1.0-alpha.11",
"players": 20,
"seed": 0,
"spawn": [
0,
252,
0
],
"java": "25.0.3",
"processors": 8,
"max_heap_bytes": 2147483648,
"spawn_area_ms": 43647.054375,
"spawn_area_full_chunks": 289,
"spawn_area_nonempty_chunks": 289,
"rings": [
{
"cumulative_chunks": 1,
"elapsed_ms": 1.965667,
"radius": 0
},
{
"cumulative_chunks": 9,
"elapsed_ms": 870.4725,
"radius": 1
},
{
"cumulative_chunks": 25,
"elapsed_ms": 2972.902334,
"radius": 2
},
{
"cumulative_chunks": 49,
"elapsed_ms": 4480.355667,
"radius": 3
},
{
"cumulative_chunks": 81,
"elapsed_ms": 5120.230375,
"radius": 4
},
{
"cumulative_chunks": 121,
"elapsed_ms": 6290.425208,
"radius": 5
},
{
"cumulative_chunks": 169,
"elapsed_ms": 7381.384417,
"radius": 6
},
{
"cumulative_chunks": 225,
"elapsed_ms": 7839.425333,
"radius": 7
},
{
"cumulative_chunks": 289,
"elapsed_ms": 8677.269209,
"radius": 8
}
],
"spawn_area_chunk_latency_ms": {
"p95": 280.6,
"median": 135.289625,
"max": 699.612708
},
"exploration_ms": 14051.250167,
"exploration_full_chunks": 80,
"exploration_steps": [
{
"chunk_x": 9,
"elapsed_ms": 750.152875
},
{
"chunk_x": 10,
"elapsed_ms": 680.650584
},
{
"chunk_x": 11,
"elapsed_ms": 729.180958
},
{
"chunk_x": 12,
"elapsed_ms": 725.223
},
{
"chunk_x": 13,
"elapsed_ms": 540.200125
},
{
"chunk_x": 14,
"elapsed_ms": 5554.177667
},
{
"chunk_x": 15,
"elapsed_ms": 473.982208
},
{
"chunk_x": 16,
"elapsed_ms": 471.05575
},
{
"chunk_x": 17,
"elapsed_ms": 513.109667
},
{
"chunk_x": 18,
"elapsed_ms": 533.284292
},
{
"chunk_x": 19,
"elapsed_ms": 503.983917
},
{
"chunk_x": 20,
"elapsed_ms": 513.126292
},
{
"chunk_x": 21,
"elapsed_ms": 508.7555
},
{
"chunk_x": 22,
"elapsed_ms": 587.331416
},
{
"chunk_x": 23,
"elapsed_ms": 467.628834
},
{
"chunk_x": 24,
"elapsed_ms": 494.771375
}
],
"exploration_chunk_latency_ms": {
"p95": 186.482333,
"median": 103.648708,
"max": 5153.6295
},
"regions_cached": 2,
"passed": true,
"world_creation_ms": 18888,
"world_creation_plus_spawn_area_ms": 62535.054375,
"raw_report_sha256": "4799180bd61ec4ffc93d6a1a7ccb5440eb880f7dc08a345429b77db10cc17ab0",
"log_sha256": "a9347297608528e719d6780f3dd2f2588bb516e06ab771ca6da6ffdda404f7a4"
},
"alpha11_grand10": {
"version": "0.1.0-alpha.11",
"players": 10,
"seed": 0,
"spawn": [
0,
252,
0
],
"java": "25.0.3",
"processors": 8,
"max_heap_bytes": 2147483648,
"spawn_area_ms": 44291.159875,
"spawn_area_full_chunks": 289,
"spawn_area_nonempty_chunks": 289,
"rings": [
{
"radius": 0,
"cumulative_chunks": 1,
"elapsed_ms": 42.387459
},
{
"radius": 1,
"cumulative_chunks": 9,
"elapsed_ms": 1474.290584
},
{
"radius": 2,
"cumulative_chunks": 25,
"elapsed_ms": 3180.279791
},
{
"radius": 3,
"cumulative_chunks": 49,
"elapsed_ms": 4474.062208
},
{
"radius": 4,
"cumulative_chunks": 81,
"elapsed_ms": 5043.704042
},
{
"radius": 5,
"cumulative_chunks": 121,
"elapsed_ms": 6367.417708
},
{
"radius": 6,
"cumulative_chunks": 169,
"elapsed_ms": 7285.252542
},
{
"radius": 7,
"cumulative_chunks": 225,
"elapsed_ms": 7824.965875
},
{
"radius": 8,
"cumulative_chunks": 289,
"elapsed_ms": 8577.641208
}
],
"spawn_area_chunk_latency_ms": {
"max": 665.612875,
"p95": 274.9215,
"median": 136.067375
},
"exploration_ms": 7684.81575,
"exploration_full_chunks": 80,
"exploration_steps": [
{
"elapsed_ms": 739.863,
"chunk_x": 9
},
{
"elapsed_ms": 724.49625,
"chunk_x": 10
},
{
"elapsed_ms": 720.754584,
"chunk_x": 11
},
{
"elapsed_ms": 695.608958,
"chunk_x": 12
},
{
"elapsed_ms": 522.1695,
"chunk_x": 13
},
{
"elapsed_ms": 1112.798042,
"chunk_x": 14
},
{
"elapsed_ms": 430.820667,
"chunk_x": 15
},
{
"elapsed_ms": 416.082834,
"chunk_x": 16
},
{
"elapsed_ms": 400.90425,
"chunk_x": 17
},
{
"elapsed_ms": 414.387708,
"chunk_x": 18
},
{
"elapsed_ms": 394.878458,
"chunk_x": 19
},
{
"elapsed_ms": 393.643834,
"chunk_x": 20
},
{
"elapsed_ms": 391.553625,
"chunk_x": 21
},
{
"elapsed_ms": 279.306334,
"chunk_x": 22
},
{
"elapsed_ms": 25.634541,
"chunk_x": 23
},
{
"elapsed_ms": 18.210167,
"chunk_x": 24
}
],
"exploration_chunk_latency_ms": {
"max": 760.323917,
"p95": 198.353916,
"median": 85.88425
},
"regions_cached": 2,
"passed": true,
"world_creation_ms": 18376,
"world_creation_plus_spawn_area_ms": 62667.159875,
"raw_report_sha256": "5284e0ef88217f01d51f9040bf13f652de8276dc3f9844dc14569848b227d05a",
"log_sha256": "3a4c84bb6ea1614f8fc9c85f472612750149996e2f7658c83afe398b43afe5f6"
}
},
"same_geometry_reduction_percent": {
"world_creation_plus_spawn_area_ms": 84.65134396742975,
"spawn_area_ms": 85.95040577665497,
"exploration_ms": 86.37676917641545
},
"targets": {
"minimum_reduction_percent": 30,
"grand_creation_plus_spawn_area_max_ms": 120000,
"same_geometry_reduction_passed": true,
"grand_generation_proxy_passed": true
},
"density_equivalence": {
"comparison": "Float.floatToRawIntBits; no tolerance",
"reference_commit": "a26b72f",
"scope": "Real compiled scalar/bulk densities, 4 capacities x 3 seeds (5/20/100 compatibility and new10 using the same parameterized algorithm), signed coordinates, region/coast/height boundaries, context switches and concurrency; no block changes",
"reference_source_sha256": "fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1",
"profiles": [
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 5,
"optimized_path_supported": true,
"reference_density_digest": "28ec59cba36174fd"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 5,
"optimized_path_supported": true,
"reference_density_digest": "efd1e8084800ed5c"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 5,
"optimized_path_supported": true,
"reference_density_digest": "91bea1db755d9aca"
},
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 10,
"optimized_path_supported": true,
"reference_density_digest": "b7bab667baf73361"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 10,
"optimized_path_supported": true,
"reference_density_digest": "42aeaf160360e41c"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 10,
"optimized_path_supported": true,
"reference_density_digest": "cb85f95333e040c0"
},
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 20,
"optimized_path_supported": true,
"reference_density_digest": "175524360755dd88"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 20,
"optimized_path_supported": true,
"reference_density_digest": "4cc8aec467a2d164"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 20,
"optimized_path_supported": true,
"reference_density_digest": "5a3f16e426e5a64e"
},
{
"seed": 0,
"bitwise_comparisons": 255678,
"players": 100,
"optimized_path_supported": true,
"reference_density_digest": "83cda17336772fa7"
},
{
"seed": 42,
"bitwise_comparisons": 255678,
"players": 100,
"optimized_path_supported": true,
"reference_density_digest": "e7aa7f52022cc6aa"
},
{
"seed": 8675309,
"bitwise_comparisons": 255678,
"players": 100,
"optimized_path_supported": true,
"reference_density_digest": "de4b8f52d249a9bd"
}
],
"concurrent_workers": 3,
"concurrent_comparisons": 115668,
"passed": true,
"adversarial": {
"aligned_scalar_path_preserved": true,
"passed": true,
"bitwise_comparisons": 122528,
"concurrent_workers": 3,
"repeated_cell_corner_batches": 1
}
},
"persistence": {
"default_players": 10,
"profiles": [
{
"restored_settings": "sanctuary:population_5",
"players": 5,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary_5"
},
{
"restored_settings": "sanctuary:population_10",
"players": 10,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary"
},
{
"restored_settings": "sanctuary:population_20",
"players": 20,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary_20"
},
{
"restored_settings": "sanctuary:population_100",
"players": 100,
"disk_round_trip": true,
"preset": "sanctuary:sanctuary_100"
}
],
"passed": true,
"visible_presets": [
"sanctuary:sanctuary_5",
"sanctuary:sanctuary"
],
"seed": 0,
"legacy_settings_preserved": "sanctuary:sanctuary_cavern",
"omitted_density_capacity_preserved": 20
},
"limits": [
"Single seed and one timed run per configuration; repeat to estimate variance.",
"A new cold region still has a synchronous planning cost; observed legacy20 maximum exploration chunk latency is about 5.15 s.",
"No personal saved world was opened or regenerated."
],
"engine_checks": {
"5": {
"mod_version": "0.1.0-alpha.11",
"seed": 0,
"settings": "sanctuary:population_5",
"inspected_full_chunks": 110,
"retained_water_blocks": 5656,
"fluid_ticks": 1800,
"tested_springs": 1,
"tested_spills": 1,
"tested_lava_falls": 1,
"postprocessing_reentry_witnesses": 3,
"passed": true,
"scope": "Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test",
"raw_report_sha256": "cfaa0a532b50399ab28808f51a871363b198a67d8c096ac13f7a011bd87ff9d0"
},
"10": {
"mod_version": "0.1.0-alpha.11",
"seed": 0,
"settings": "sanctuary:population_10",
"inspected_full_chunks": 125,
"retained_water_blocks": 5923,
"fluid_ticks": 1800,
"tested_springs": 1,
"tested_spills": 1,
"tested_lava_falls": 1,
"postprocessing_reentry_witnesses": 3,
"passed": true,
"scope": "Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test",
"raw_report_sha256": "aaed5b38821197c479a8acb7f2a201c64f290666c039d0b15047340e403e028c"
}
}
}
+314
View File
@@ -0,0 +1,314 @@
{
"version": "0.1.0-alpha.12",
"date": "2026-09-09",
"minecraft": "26.3-pre-2",
"java": 25,
"generation": 12,
"source_base_commit": "f816ffc6402fe31789f9677c2ee94328b3181329",
"scope": "Local development tests only; no personal worlds, client rendering, live player teleport or multiplayer load.",
"normalization": "world_directory fields replaced with <development_game_test_world>; all other diagnostic fields retained.",
"artifact": {
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.jar",
"sha256": "f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9",
"staged_pack_jar_identical": true,
"contains_gametest_classes": false
},
"build": {
"command": "./gradlew check build assemblePack",
"passed": true,
"legacy_profile": 10,
"legacy_seed": 0,
"required_engine_tests": 12,
"fluid_ticks": 1800,
"log_sha256": "dfedc75558596ce4413bd0466ec03645b2fa6853165510e9b8db0406d0958eb8"
},
"first_process_diagnostic_sha256": "90872c801cb04e3f0c78b47e0d38a0b45dd5d3e6d1e33ccc53c4359e4b407238",
"reload_diagnostic_sha256": "1efe055e1179ce60741a0d8546618807fc136c0de77f9aff6a979cf88b2b1cdd",
"fresh_world": {
"seed": 42,
"process_id": 2892,
"generator_version": 12,
"islands": [
{
"upper_biome": "minecraft:jungle",
"climate": "tropical",
"landing": [
0,
202,
-800
],
"landing_biome": "minecraft:jungle",
"parent": "sanctuary",
"id": "alpha",
"centre": [
0,
-800
],
"diameter": 64
},
{
"upper_biome": "minecraft:desert",
"climate": "arid",
"landing": [
544,
198,
-800
],
"landing_biome": "minecraft:desert",
"parent": "alpha",
"id": "beta",
"centre": [
544,
-800
],
"diameter": 64
}
],
"scope": "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.",
"registered_command_permission_checks": true,
"parent_witness": [
-16,
248,
-16
],
"root_landing": [
-16,
242,
-16
],
"journal_reloaded_regions": 3,
"visited_void_refusal": {
"chunk": [
-50,
50
],
"reason": "Expansion intersects a loaded or unloading chunk: [-61, 39]"
},
"native_structure": {
"full_chunks": 12,
"placed_block": "Block{minecraft:barrel}[facing=south,open=false]",
"structure": "minecraft:abandoned_camp_forest",
"mode": "natural candidate accepted by production ExpansionStructures, native decoration",
"container": [
28,
247,
73
],
"loot_table": "minecraft:barrels/abandoned_camp_barrel"
},
"restart_snapshot": {
"world_directory": "<development_game_test_world>",
"gold_witness": [
544,
204,
-800
],
"journal_sha256": "d7de79838744624e98ce26997ddc1daecb7bd3334bd9bf0e1ad31aa5eb7e27c3",
"islands": [
{
"id": "sanctuary",
"parent": "sanctuary",
"direction": "north",
"climate": "temperate",
"diameter": 512,
"center_x": 0,
"center_z": 0,
"seed": 42,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "alpha",
"parent": "sanctuary",
"direction": "north",
"climate": "tropical",
"diameter": 64,
"center_x": 0,
"center_z": -800,
"seed": 390175152143173586,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "beta",
"parent": "alpha",
"direction": "east",
"climate": "arid",
"diameter": 64,
"center_x": 544,
"center_z": -800,
"seed": 6281303726624369227,
"relief": "natural",
"status": "ready",
"generation": 12
}
],
"generator_codec": {
"biome_source": {
"biomes": [
"minecraft:the_void",
"minecraft:forest",
"minecraft:birch_forest",
"minecraft:flower_forest",
"minecraft:plains",
"minecraft:meadow",
"minecraft:taiga",
"minecraft:snowy_taiga",
"minecraft:snowy_plains",
"minecraft:grove",
"minecraft:ice_spikes",
"minecraft:frozen_peaks",
"minecraft:old_growth_spruce_taiga",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:pale_garden",
"minecraft:jungle",
"minecraft:sparse_jungle",
"minecraft:bamboo_jungle",
"minecraft:mangrove_swamp",
"minecraft:savanna",
"minecraft:savanna_plateau",
"minecraft:desert",
"minecraft:badlands",
"minecraft:eroded_badlands",
"minecraft:wooded_badlands",
"minecraft:windswept_hills",
"minecraft:windswept_gravelly_hills",
"minecraft:stony_peaks",
"minecraft:dappled_forest",
"minecraft:cherry_grove",
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:sulfur_caves"
]
},
"type": "sanctuary:expansion_lab"
},
"samples": [
{
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242
},
{
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"point": [
0,
202,
-800
],
"id": "alpha",
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
"base_height": 202
},
{
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"point": [
544,
198,
-800
],
"id": "beta",
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198
}
],
"world_flushed_before_shutdown": true
},
"native_structure_starts_seen": 1,
"structure_start_chunks_inspected": 106,
"ticks": 1390,
"elapsed_ms": 43123.278916,
"passed": true
},
"reloaded_world": {
"seed": 42,
"process_id": 2975,
"scope": "A second Java process opens the same saved development chunks and expansion journal, and verifies a gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client world-selection test or a multiplayer/teleportation test.",
"previous_process_id": 2892,
"world_directory": "<development_game_test_world>",
"gold_witness": [
544,
204,
-800
],
"samples": [
{
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242,
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485"
},
{
"point": [
0,
202,
-800
],
"id": "alpha",
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
"base_height": 202,
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b"
},
{
"point": [
544,
198,
-800
],
"id": "beta",
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198,
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60"
}
],
"regions": 3,
"native_container": [
28,
247,
73
],
"saved_generator_codec_decoded": true,
"passed": true
},
"earlier_seed_0_attempt": {
"scope": "Earlier development run before final cache and error-message fixes. Expansion phases completed; positive loot witness failed.",
"native_candidate_sites": 11,
"no_surface": 9,
"insufficient_foundation": 2,
"accepted_structures": 0,
"structure_proof_passed": false
},
"unverified": [
"Forced crash during chunk preparation",
"Normal client world selection and rendering",
"Real player teleports and multiplayer",
"Visual quality of every climate/relief and 128/256/512 formats",
"Every village and temple variant"
]
}
+408
View File
@@ -0,0 +1,408 @@
{
"version": "0.1.0-alpha.12.1",
"date": "2026-09-09",
"minecraft": "26.3-pre-2",
"java": 25,
"generation": 12,
"journal_schema": 1,
"base_commit": "40a86588b5f83fbc7bd2728eb9b439baa1fc9ba5",
"scope": "Development-world engine tests; no personal world, live player teleportation or client rendering. Process timings are not performance benchmarks.",
"normalization": "world_directory fields replaced with <development_game_test_world>; other diagnostic fields unchanged.",
"build": {
"command": "./gradlew check build assemblePack",
"passed": true,
"required_historical_engine_tests": 12,
"seed": 0,
"profile": 10,
"log_sha256": "72f2173023e5407391d7e0e528012fcda9a314c981acfea462918c36ffa7bf90"
},
"artifact": {
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.1.jar",
"sha256": "c9df4da956cfa54581b3fde21de006bf8ac3ec025b8dd0daa5d060bcb70aabd4"
},
"first_process_diagnostic_sha256": "3ef35faf09130409135879ad3a4be4ceba4f5644a57ae9cdcec4cd4d6e84c06a",
"reload_diagnostic_sha256": "401f2e6739de9efd3668aa27c5ef45a6e81e1acfde44442bf101e5ac6397074a",
"fresh_world": {
"seed": 42,
"process_id": 5743,
"generator_version": 12,
"islands": [
{
"centre": [
0,
-1600
],
"diameter": 64,
"upper_biome": "minecraft:jungle",
"climate": "tropical",
"landing": [
-4,
171,
-1604
],
"landing_biome": "minecraft:jungle",
"parent": "sanctuary",
"id": "alpha"
},
{
"centre": [
2048,
-1600
],
"diameter": 64,
"upper_biome": "minecraft:desert",
"climate": "arid",
"landing": [
2048,
198,
-1600
],
"landing_biome": "minecraft:desert",
"parent": "alpha",
"id": "beta"
}
],
"scope": "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.",
"registered_command_permission_checks": true,
"parent_witness": [
-16,
248,
-16
],
"root_landing": [
-16,
242,
-16
],
"initial_occupied_directions": 8,
"journal_reloaded_regions": 3,
"native_structure": {
"container": [
28,
247,
73
],
"loot_table": "minecraft:barrels/abandoned_camp_barrel",
"full_chunks": 12,
"placed_block": "Block{minecraft:barrel}[facing=south,open=false]",
"structure": "minecraft:abandoned_camp_forest",
"mode": "natural candidate accepted by production ExpansionStructures, native decoration"
},
"restart_snapshot": {
"world_directory": "<development_game_test_world>",
"gold_witness": [
2048,
204,
-1600
],
"journal_sha256": "21136a5e8f1857b10ef5673281c8a3a4568ae032641995c206ed2031e112aa5b",
"islands": [
{
"id": "sanctuary",
"parent": "sanctuary",
"direction": "north",
"climate": "temperate",
"diameter": 512,
"center_x": 0,
"center_z": 0,
"seed": 42,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "alpha",
"parent": "sanctuary",
"direction": "north",
"climate": "tropical",
"diameter": 64,
"center_x": 0,
"center_z": -1600,
"seed": 390175152143173586,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "beta",
"parent": "alpha",
"direction": "east",
"climate": "arid",
"diameter": 64,
"center_x": 2048,
"center_z": -1600,
"seed": 6281303726624369227,
"relief": "natural",
"status": "ready",
"generation": 12
}
],
"generator_codec": {
"biome_source": {
"biomes": [
"minecraft:the_void",
"minecraft:forest",
"minecraft:birch_forest",
"minecraft:flower_forest",
"minecraft:plains",
"minecraft:meadow",
"minecraft:taiga",
"minecraft:snowy_taiga",
"minecraft:snowy_plains",
"minecraft:grove",
"minecraft:ice_spikes",
"minecraft:frozen_peaks",
"minecraft:old_growth_spruce_taiga",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:pale_garden",
"minecraft:jungle",
"minecraft:sparse_jungle",
"minecraft:bamboo_jungle",
"minecraft:mangrove_swamp",
"minecraft:savanna",
"minecraft:savanna_plateau",
"minecraft:desert",
"minecraft:badlands",
"minecraft:eroded_badlands",
"minecraft:wooded_badlands",
"minecraft:windswept_hills",
"minecraft:windswept_gravelly_hills",
"minecraft:stony_peaks",
"minecraft:dappled_forest",
"minecraft:cherry_grove",
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:sulfur_caves"
]
},
"type": "sanctuary:expansion_lab"
},
"blocked_sites": [
{
"id": "blocked_north",
"parent": "sanctuary",
"direction": "north",
"climate": "boreal",
"diameter": 64,
"center_x": 0,
"center_z": -800,
"seed": 6170446579001265223,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_northeast",
"parent": "sanctuary",
"direction": "northeast",
"climate": "boreal",
"diameter": 64,
"center_x": 800,
"center_z": -800,
"seed": 2312608842580142629,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_east",
"parent": "sanctuary",
"direction": "east",
"climate": "boreal",
"diameter": 64,
"center_x": 800,
"center_z": 0,
"seed": -5785143794938635053,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_southeast",
"parent": "sanctuary",
"direction": "southeast",
"climate": "boreal",
"diameter": 64,
"center_x": 800,
"center_z": 800,
"seed": 6543995692374257629,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_south",
"parent": "sanctuary",
"direction": "south",
"climate": "boreal",
"diameter": 64,
"center_x": 0,
"center_z": 800,
"seed": 9109857537375437182,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_southwest",
"parent": "sanctuary",
"direction": "southwest",
"climate": "boreal",
"diameter": 64,
"center_x": -800,
"center_z": 800,
"seed": 7977138842745944844,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_west",
"parent": "sanctuary",
"direction": "west",
"climate": "boreal",
"diameter": 64,
"center_x": -800,
"center_z": 0,
"seed": 8731833603567083481,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_northwest",
"parent": "sanctuary",
"direction": "northwest",
"climate": "boreal",
"diameter": 64,
"center_x": -800,
"center_z": -800,
"seed": 6470159394492078415,
"relief": "natural",
"status": "reserved",
"generation": 12
}
],
"samples": [
{
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242,
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4"
},
{
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
"base_height": 204,
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"point": [
-4,
171,
-1604
],
"id": "alpha",
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1"
},
{
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198,
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"point": [
2048,
198,
-1600
],
"id": "beta",
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093"
}
],
"world_flushed_before_shutdown": true
},
"native_structure_starts_seen": 1,
"structure_start_chunks_inspected": 106,
"ticks": 1470,
"elapsed_ms": 41563.165541,
"passed": true
},
"reloaded_world": {
"seed": 42,
"process_id": 5830,
"scope": "A second Java process opens the same saved development chunks and expansion journal, and verifies a gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client world-selection test or a multiplayer/teleportation test.",
"saved_only_collision_refusals": 8,
"previous_process_id": 5743,
"world_directory": "<development_game_test_world>",
"gold_witness": [
2048,
204,
-1600
],
"samples": [
{
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242
},
{
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"point": [
-4,
171,
-1604
],
"id": "alpha",
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
"base_height": 204
},
{
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"point": [
2048,
198,
-1600
],
"id": "beta",
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198
}
],
"regions": 3,
"native_container": [
28,
247,
73
],
"saved_generator_codec_decoded": true,
"after_reload_expansion": [
0,
1600
],
"after_reload_landing": [
-4,
153,
1596
],
"final_regions": 4,
"passed": true
}
}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
{
"version": "0.1.0-alpha.13.1",
"date": "2026-09-09",
"minecraft": "26.3-pre-2",
"java": 25,
"host_os": "macOS",
"windows_native_tested": false,
"windows_policy_tested_by_injection": true,
"journal_contracts": [
"Windows policy skips only directory channel",
"file force and atomic replacement and strict read-back remain required",
"supported directory AccessDenied propagates after replacement",
"failed session refuses further writes",
"verified reload recovers persisted reservation",
"post-replacement corruption fails closed"
],
"jar_sha256": "b340dfc58f3d2d1a2a375dc527ac6721c61430600622046db26228e0da46061b",
"logs": {
"journal-smoke": {
"path": "build/alpha131-journal-smoke.log",
"sha256": "c02e94713cf3f9a3c76674700cc5c800aecd84b213b2be9ed53629f8eb9cd3d0",
"passed": true,
"required_game_tests": null
},
"check": {
"path": "build/alpha131-check.log",
"sha256": "68f6cb97e2f80669d39e37623076229e030a0d9826c13e9420f51f6ba23885bd",
"passed": true,
"required_game_tests": 8
},
"expansion-create": {
"path": "build/alpha131-expansion-create.log",
"sha256": "61fa4513aef71290a1419c3425a329047c8fe623a561544c6a8d916fe50e226c",
"passed": true,
"required_game_tests": 2
},
"expansion-reload": {
"path": "build/alpha131-expansion-reload.log",
"sha256": "44fafb4636ff90707a068f325a245dbf9820ccb1bdf785c912f7e636fe7cfa43",
"passed": true,
"required_game_tests": 2
}
},
"binary_comparison": {
"base_version": "0.1.0-alpha.13",
"version": "0.1.0-alpha.13.1",
"added": [
"fr/koka/sanctuary/expansion/ExpansionJournal$DirectorySync.class"
],
"removed": [],
"changed": [
"fabric.mod.json",
"fr/koka/sanctuary/expansion/ExpansionJournal$1.class",
"fr/koka/sanctuary/expansion/ExpansionJournal.class"
],
"synthetic_switch_instructions_identical": true,
"all_other_entries_identical": true
},
"expansion": {
"seed": 42,
"initial_diameter": 724,
"generation": 13,
"creation_process": 13148,
"reload_process": 13279,
"creation_passed": true,
"reload_passed": true,
"creation_islands": [
{
"landing_biome": "minecraft:jungle",
"landing": [
0,
202,
-1792
],
"climate": "tropical",
"upper_biome": "minecraft:jungle",
"diameter": 64,
"centre": [
0,
-1792
],
"id": "alpha",
"parent": "sanctuary"
},
{
"landing_biome": "minecraft:desert",
"landing": [
2048,
198,
-1792
],
"climate": "arid",
"upper_biome": "minecraft:desert",
"diameter": 64,
"centre": [
2048,
-1792
],
"id": "beta",
"parent": "alpha"
}
],
"reloaded_regions": 3,
"saved_generator_codec_decoded": true,
"after_reload_expansion": [
0,
1792
],
"final_regions": 4,
"native_structure_status": "not_observed_after_complete_scan",
"native_structure_witness_required": false,
"limitation": "Optional native camp witness absent after complete search on seed 42, as in alpha.13; no new structures claim."
},
"scope": "No generation or journal schema change. Test worlds only; no personal world opened or regenerated. Windows native runtime and power-loss durability not tested."
}
+72 -40
View File
@@ -1,30 +1,53 @@
# 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.
Ce document distingue les outils de génération et la progression collective
encore à construire. Lalpha.13 réunit l’île de départ et
les commandes dexpansion dans le choix public **Sanctuary**. Le
[guide alpha.13](alpha13-worldgen.md) décrit leur utilisation. Les versions
alpha.12/12.1 et leur [laboratoire](alpha12-laboratory.md) restent historiques.
Le déblocage par recherches, XP et contributions nest pas encore implémenté.
## Capacité de l’île initiale — alpha.10
## Taille initiale et extensions — alpha.13
Le choix de départ propose 5, 20 ou 100 joueurs, avec 20 par défaut. Laire
nominale est proportionnelle à la capacité : base de 512 blocs de diamètre
pour cinq joueurs, puis 1 024 pour vingt et environ 2 290 pour cent.
La hauteur reste de 384 blocs. Le contour réel et les stocks dépendent de
la génération ; laire nominale ne garantit pas une surface constructible
ou une quantité de minerai par joueur.
Le bouton natif **Personnaliser** propose **Petit** (512 blocs de diamètre
nominal), **Moyen** (724, par défaut) et **Grand** (1 024). La hauteur reste de
384 blocs. Ce choix configure un nouveau monde ; il ne promet ni nombre de
joueurs, ni quantité de ressources, ni aire entièrement constructible.
Cette taille est choisie pour un nouveau monde et ne remplace pas le futur
déblocage collectif de continents. Elle ne redimensionne aucune sauvegarde
existante et ne change pas la limite de connexions du serveur. Les contrôles
de génération des trois tailles passent sur la graine `0`, dans les emprises
consignées dans [Validation](testing.md), sans simuler cent joueurs simultanés.
L’île initiale reprend le relief, les forêts et lhydrologie de Population,
avec les poches de cavités natives issues du laboratoire. Les îles ajoutées
conservent les tailles **64/128/256/512**, les douze profils climatiques et les
quatre reliefs dexpansion. Leur génération ne reprend pas automatiquement
la grande rivière et les terrasses de l’île initiale.
## Les huit directions climatiques
Les commandes opérateur `quick`, `create`, `preview`, `list`, `visit` et
`resume` reprennent le fonctionnement alpha.12.1. Le paramètre final de distance
est facultatif ; les sites occupés sont écartés en cherchant plus loin dans la
même direction. Même un chunk de vide déjà généré est protégé. Les recherches
collectives et les objets de déblocage ne sont pas nécessaires à ces outils.
La racine alpha.13 est de génération 13 ; les extensions restent de génération
12, dans le journal `data/sanctuary-world-v13/expansions.json` du nouveau monde.
Les anciens mondes gardent leurs codecs, leurs paramètres et leur journal.
Les choix séparés Petit/Grand et Laboratoire sont désormais cachés à la
création. Aucun monde existant nest converti ou redimensionné.
## Tailles et mesures historiques
Lalpha.11 proposait Petit (512) et Grand (724), avec Grand par défaut ; ses
repères internes 5/10 ne correspondaient pas à une capacité multijoueur promise.
Les références `population_5/10/20/100` restent celles des sauvegardes historiques.
Les mesures alpha.3 à alpha.11 conservées ci-dessous décrivent uniquement les
versions et les emprises observées à leur date. Les contrôles de lalpha.13,
les rechargements et le benchmark Moyen sont consignés dans [Validation](testing.md).
## Les huit directions et les branches 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.
La boussole climatique est une convention facultative : nord froid, sud chaud,
ouest sec et est humide. Les diagonales combinent ces tendances. **La direction
géographique ne contraint plus le climat.** Les joueurs pourront choisir de
prolonger une voie climatique ou de bifurquer vers dautres découvertes.
| Direction | Axe Minecraft depuis Sanctuary | Tendance climatique |
| --- | --- | --- |
@@ -37,7 +60,11 @@ l'est vers l'humide. Les diagonales combinent ces tendances.
| Ouest | `X` | Sec |
| Nord-ouest | `X, Z` | Froid et sec |
Ces directions définissent des familles de climats, pas huit biomes uniformes.
Ces associations sont les raccourcis par défaut, pas une loi du monde.
Une branche conserve son parent, sa direction et son climat explicite. Plusieurs
couronnes peuvent donc proposer des biomes nouveaux au nord comme au sud.
Le point dorigine reste enregistré même si un joueur déplace son lit ou le spawn.
Les familles de climats ne sont pas des 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
@@ -124,12 +151,15 @@ Une graine peut donc manquer dune ressource ; la présence de tout le parcour
Minecraft nest pas garantie. Les structures donnant accès à lEnd et leur lien
avec lexpansion restent à concevoir.
## Relevé de développement disponible
## Relevé de développement — générateurs historiques
`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.
Depuis lalpha.13, `runGameTest` et `check` sélectionnent la suite unifiée par
défaut. Les commandes ci-dessous demandent explicitement une suite historique ;
elles ne constituent pas un relevé du nouveau monde unifié.
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
@@ -140,21 +170,23 @@ 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
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -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
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
# Désactiver seulement le relevé pour un autre diagnostic.
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=off --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryResourceSurvey=off --console=plain
```
Les modes sont transmis à la propriété JVM de test
`sanctuary.test.resourceSurvey`. La propriété Gradle `-PsanctuaryTestPlayers=5`,
`20` ou `100` choisit le nouveau profil ; sa valeur par défaut est `20`.
Le mode `island` calcule lenveloppe de la capacité active, avec 16 blocs de
marge de décoration et un halo de préparation dun chunk. Les tailles ci-dessous
sont les bornes calculées du travail demandé, pas des relevés déjà exécutés :
`sanctuary.test.resourceSurvey`. La propriété Gradle `-PsanctuaryTestPlayers`
choisit le profil interne. Lalpha.11 utilise `5` pour Petit et `10` pour Grand,
ce dernier par défaut ; `20` et `100` servent à la compatibilité historique.
Le mode `island` calcule lenveloppe du profil actif, avec 16 blocs de marge de
décoration et un halo de préparation dun chunk. Les valeurs ci-dessous sont
les bornes calculées lors de lalpha.10, pas des relevés exécutés ni un nombre
de chunks déjà validé pour lancien Grand alpha.11 :
| Profil | Chunks comptés par `island` | Chunks FULL préparés avec halo |
| --- | --- | --- |
@@ -249,16 +281,16 @@ 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
## Contrat de progression collective restant à construire
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.
Le registre et la réservation des alpha.12/12.1 sont repris dans lalpha.13 :
identifiant, parent, direction, centre, emprise, graine, génération, climat et
statut sont enregistrés côté serveur. Les chunks existants, y compris le vide,
sont protégés pendant la recherche dune nouvelle région.
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.
Le prochain travail de progression doit relier ces ouvertures aux recherches,
aux contributions et à leurs coûts. Un éventuel relevé de ressources intervient
après la génération réelle, avec une complétude explicite ; il ne choisit pas
une graine pour remplir un stock attendu. Les prix, les objets de déblocage et
les règles de contribution restent à concevoir. Aucun contrat de migration
ou de régénération danciennes parties nest fourni par ce diagnostic.
+105 -15
View File
@@ -13,34 +13,113 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
de l'ancien pack 26.2.
## Livrer une mise à jour
## Alpha.13 publiée — 9 septembre 2026
Dernière livraison vérifiée le 9 septembre 2026 :
[Sanctuary 0.1.0-alpha.10](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.10),
depuis le commit source `ef5d1ea`. Le JAR, le `.mrpack` et le ZIP damorçage
Prism sont publiés et leurs téléchargements publics vérifiés. Le canal stable
pointe sur cette version. SHA-256 du JAR :
[Sanctuary 0.1.0-alpha.13](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.13)
est publiée depuis le commit source `78b63be`. Le canal stable utilise désormais
cette version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.13/Sanctuary-0.1.0-alpha.13.mrpack)
et le ZIP damorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
```text
261f727c6b7fdf8dfa81395d805967885b52143147a41709d1307cc881dc7d7b
53eb5d8eda54d322250784203824993bd675de1cb90467e74d67589b15769e76
```
Deux synchronisations isolées puis deux passages dans la même instance Prism
ont réussi. Le second passage laisse les fichiers gérés identiques ; un seul
JAR Sanctuary alpha.13 est actif. Les **601 fichiers personnels et réglages
suivis** conservent leurs hashes, notamment les mondes et mods tiers. Aucune
sauvegarde personnelle na été ouverte, convertie ou régénérée.
Linstance conserve son dossier `Sanctuary-0.1.0-alpha.1`, sans réimportation.
Sauvegarde ciblée : `sanctuary-backups/before-0.1.0-alpha.13/` dans cette instance.
Reçus locaux : `build/alpha13-isolated-validation.json` et
`build/alpha13-prism-validation.json`.
Pour tester : créer un **nouveau monde**, choisir **Sanctuary**, puis le bouton
natif **Personnaliser** : Petit 512, Moyen 724 par défaut, Grand 1 024.
Le laboratoire nest plus un choix public ; ses cavités et outils dexpansion
rejoignent le nouveau monde. Les anciennes générations restent chargeables.
Voir [le guide alpha.13](alpha13-worldgen.md) et [les preuves](testing.md).
## Correctif alpha.12.1 publié — 9 septembre 2026
[Sanctuary 0.1.0-alpha.12.1](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.12.1)
est publiée depuis le commit source `332d60b`. Le canal packwiz utilisait cette
version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12.1/Sanctuary-0.1.0-alpha.12.1.mrpack)
et le ZIP damorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
```text
c9df4da956cfa54581b3fde21de006bf8ac3ec025b8dd0daa5d060bcb70aabd4
```
Deux synchronisations isolées puis deux passages dans la même instance Prism
ont réussi ; le second laisse les fichiers gérés identiques. Un seul JAR
Sanctuary alpha.12.1 est actif, avec **466 fichiers personnels et réglages suivis
conservés**, y compris les mondes et les mods tiers. Aucun monde na été ouvert
ou régénéré. La sauvegarde ciblée est dans
`sanctuary-backups/before-0.1.0-alpha.12.1/` de linstance existante ; reçus locaux
`build/alpha121-isolated-validation.json` et `build/alpha121-prism-validation.json`.
WG-14 ajoute la distance et le contournement automatique des zones occupées.
Les mondes laboratoire alpha.12 restent utilisables sans conversion : le journal
v1 et les descripteurs de génération 12 sont conservés. Voir le [guide](alpha12-laboratory.md).
## Alpha.12 publiée — 9 septembre 2026
[Sanctuary 0.1.0-alpha.12](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.12)
est publiée depuis le commit source `cdfbba0`. Le canal stable utilisait alors
cette version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12/Sanctuary-0.1.0-alpha.12.mrpack)
et le ZIP damorçage Prism ont été téléchargés publiquement et vérifiés contre
les artefacts locaux. SHA-256 du JAR :
```text
f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9
```
Le véritable installateur packwiz a réussi deux passages dans un dossier de
développement neuf, puis deux dans la même instance Sanctuary Beta. Le second
passage conserve les hashes des fichiers gérés. Un seul JAR Sanctuary alpha.12
est actif ; les **466 fichiers personnels et réglages suivis** conservent
leurs hashes, y compris les sauvegardes, mods tiers et configurations. Aucun
monde na été ouvert, converti ou régénéré par cette livraison.
La sauvegarde ciblée est dans `sanctuary-backups/before-0.1.0-alpha.12/` de
linstance existante. Les reçus locaux ignorés sont
`build/alpha12-isolated-validation.json` et `build/alpha12-prism-validation.json`.
Le [guide](alpha12-laboratory.md) décrit le nouveau preset **Sanctuary — Laboratoire** :
créer un nouveau monde de ce type pour essayer les expansions. Les anciennes
parties et les presets Petit/Grand gardent leur génération.
## Livrer une mise à jour
Livraison précédente, vérifiée le 9 septembre 2026 :
[Sanctuary 0.1.0-alpha.11](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.11),
depuis le commit source `f30a350`. Le JAR, le `.mrpack` et le ZIP damorçage
Prism sont publiés et leurs téléchargements publics vérifiés. Le canal stable
pointait alors sur cette version. SHA-256 du JAR :
```text
f075ad8a0b97cd53e4891273adbee5f79f04ab3984442950d5d85f58d2ded589
```
Le véritable installateur packwiz a réussi deux fois dans un dossier de
développement neuf, puis deux fois dans linstance Sanctuary Beta existante.
Le second passage laisse les fichiers gérés identiques. Un seul JAR Sanctuary
alpha.10 est actif ; les 363 fichiers personnels et réglages suivis conservent
leurs hashes. Lancien JAR et les manifestes locaux sont sauvegardés hors de
`mods/`, dans `sanctuary-backups/before-0.1.0-alpha.10/` de cette même instance.
alpha.11 est actif ; les 364 fichiers personnels et réglages suivis conservent
leurs hashes, y compris les fichiers des mods tiers et leurs configurations.
Lancien JAR et les manifestes locaux sont sauvegardés hors de `mods/`, dans
`sanctuary-backups/before-0.1.0-alpha.11/` de cette même instance.
Aucun monde existant na été ouvert, converti ou régénéré par la livraison.
Les reçus locaux ignorés sont `build/alpha10-isolated-validation.json` et
`build/alpha10-prism-validation.json` ; les listes de hashes avant et après
figurent dans le dossier de sauvegarde.
Les reçus locaux ignorés sont `build/alpha11-isolated-validation.json` et
`build/alpha11-prism-validation.json` ; les listes de hashes avant et après
figurent dans le dossier de sauvegarde. Les preuves du build, des deux tailles
et de performance figurent dans [Validation](testing.md).
Après les vérifications, le commit et le push de la branche du ticket, le script
de publication réalise les étapes de release et de canal ci-dessous :
```sh
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.10.mrpack
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.12.mrpack
```
Les options sont facultatives. Ce script **publie** sur le Git configuré dans
@@ -59,7 +138,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.10/sanctuary-0.1.0-alpha.10.jar
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12/sanctuary-0.1.0-alpha.12.jar
```
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
@@ -175,6 +254,17 @@ nécessite pas de réimporter le ZIP damorçage. Les fichiers de sauvegarde,
captures, packs graphiques et réglages personnels suivis ont conservé leurs
hashes lors des deux synchronisations de livraison.
Lalpha.11 propose deux choix : **Petit** et **Grand**, Grand par défaut.
Le preset `sanctuary:sanctuary` crée un nouveau monde `population_10` ;
Petit garde `sanctuary:sanctuary_5` / `population_5`. Les anciens mondes
20/100 restent chargeables à leur taille enregistrée, alors que leurs formats
sont cachés de la création. Les formats de sauvegarde sont inchangés.
Loptimisation conserve les résultats pour un même réglage. La livraison a
préservé les fichiers personnels suivis, les mods tiers et leurs configurations.
Linstance na pas été réimportée et aucun mod tiers na été retiré. Les
sauvegardes personnelles sont restées fermées pendant les essais et la synchronisation.
Références officielles : [installation packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/),
[commandes Prism](https://prismlauncher.org/wiki/help-pages/custom-commands/),
[bootstrap v0.0.3](https://github.com/packwiz/packwiz-installer-bootstrap/releases/tag/v0.0.3).
+343 -4
View File
@@ -19,6 +19,262 @@ Pour générer de vrais chunks et vérifier le spawn sur d'autres graines :
La propriété ne s'applique qu'au mod de test, jamais au mod distribué ni à une
sauvegarde de joueur. Chaque commande repart d'un monde de développement neuf.
## Correctif alpha.13.1 — journal dexpansion sous Windows
Le 9 septembre 2026, le contrôle ciblé suivant passe sur macOS et Java 25 :
```sh
./gradlew :sanctuary:expansionJournalSmoke --console=plain
```
Le journal de cette exécution est `build/alpha131-journal-smoke.log` :
`BUILD SUCCESSFUL`, en 7 secondes. Le test écrit et relit de vrais fichiers
temporaires ; il nouvre aucune sauvegarde Minecraft personnelle.
Les contrôles existants de persistance, de corruption, dhistorique immuable
et de compatibilité des racines restent actifs. Trois scénarios couvrent le
correctif :
- La politique Windows, sélectionnée par le séparateur `\`, permet la création
du journal, une réservation, sa finalisation et leurs rechargements. Le
callback de synchronisation du dossier, qui lèverait `AccessDeniedException`,
nest jamais appelé. Les écritures, remplacements atomiques et relectures du
fichier ont réellement lieu sur le système de fichiers du Mac.
- La politique Unix conserve une erreur `AccessDeniedException` injectée après
le remplacement atomique. La session devient indisponible, garde son ancien
état en mémoire et refuse toute nouvelle écriture. Le journal sur disque
contient la réservation ; une nouvelle instance la relit et peut la terminer.
- Une corruption injectée après le remplacement mais avant la relecture
empêche la publication du nouveau plan et conserve le fichier endommagé.
Le rechargement refuse ce journal au lieu de créer un historique vide.
**La branche Windows est simulée ; aucun processus Java ou Minecraft Windows
natif na encore été exécuté pour cette validation.**
`check build assemblePack` passe sur les sources alpha.13.1, en 1 min 27 s,
avec les huit GameTests du monde Moyen de graine 0. Les scénarios dexpansion
passent ensuite dans deux processus Minecraft distincts, sur la graine 42 et
le diamètre 724 : création (1 min 32 s), puis réouverture et nouvelle expansion
(26 s). Deux tests requis passent à chaque étape. Commandes de reproduction :
```sh
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
```
Lancer les deux scénarios dexpansion consécutivement ; le second réutilise
uniquement leur monde de développement. Comme pour lalpha.13, le témoin de
camp naturel est facultatif et absent après recherche complète sur cette
graine ; aucune nouvelle preuve de structure nest revendiquée.
Les [résultats alpha.13.1](benchmarks/alpha131-journal.json) conservent les
hashes des journaux de test, les identifiants des deux processus et les régions.
La comparaison des JAR confirme que seuls le journal, sa classe de stratégie
et la version changent ; le switch synthétique ne change que ses numéros de
lignes. Toutes les autres classes et ressources restent identiques.
Les résultats des trois tailles et de linterface ci-dessous restent ceux de
la livraison alpha.13 ; ils nont pas été réexécutés pour ce correctif de stockage.
La cause est établie par les sources de
[WindowsChannelFactory dans OpenJDK 25](https://github.com/openjdk/jdk/blob/jdk-25-ga/src/java.base/windows/classes/sun/nio/fs/WindowsChannelFactory.java#L206-L272) :
`FileChannel.open` najoute pas le drapeau `FILE_FLAG_BACKUP_SEMANTICS`, exigé
pour ouvrir un répertoire selon la
[documentation Microsoft de CreateFile](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew#directories).
Seule cette synchronisation du dossier est omise sous Windows ; les exceptions
d’écriture, de remplacement ou de relecture restent bloquantes.
Le fichier temporaire est toujours synchronisé par `force(true)`, puis remplacé
atomiquement et vérifié. Cette séquence ne garantit pas sous Windows la même
durabilité du renommage en cas de coupure secteur quune synchronisation du
répertoire sous Unix :
[OpenJDK utilise MoveFileEx sans WRITE_THROUGH](https://github.com/openjdk/jdk/blob/jdk-25-ga/src/java.base/windows/classes/sun/nio/fs/WindowsFileCopy.java#L279-L294),
et le contrat de
[FileChannel.force](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/channels/FileChannel.html#force(boolean))
porte sur les modifications du fichier. Aucun changement de format, de graine,
de générateur ou de réservation enregistrée nest introduit.
## Alpha.13 — génération unifiée
`check build assemblePack` passe, y compris le monde Moyen de graine 0 et les
smokes. Les preuves exportées, journaux, paramètres, mesures et hashes figurent
dans [le rapport alpha.13](benchmarks/alpha13-worldgen.json).
Le test courant charge `sanctuary:sanctuary`, puis règle son vrai générateur
`sanctuary:island_v13` comme le fait la personnalisation. Les suites historiques
restent accessibles avec `-PsanctuaryUnifiedTests=false`.
Les trois tailles passent les huit tests moteur sur la graine **42**, avec
1 800 ticks de fluides. Chaque inspection des cavités utilise au plus 27 chunks
FULL, choisis sur des sols et plafonds naturels ; les contrôles dhydrologie
emploient un autre échantillon borné. Ces volumes ne sont pas un inventaire de
l’île et ne mesurent pas le rendu du client.
| Taille | Chunks FULL du diagnostic deau | Blocs deau retenus après 1 800 ticks | Pilier de spéléothèmes dans lair naturel |
| --- | ---: | ---: | ---: |
| Petit, 512 | 116 | 4 722 | 25 blocs |
| Moyen, 724 | 80 | 1 196 | 18 blocs |
| Grand, 1 024 | 136 | 1 434 | 18 blocs |
Les trois cavités natives sont réellement décorées dans chacun de ces mondes :
mousse et lianes récoltables pour le luxuriant, pointes et piliers pour les
spéléothèmes, roche soufrée et pointes pour le soufre. Musiques, brouillard et
table du `sulfur_cube` restent ceux des biomes Minecraft. La graine 0 présente
seulement du luxuriant dans le raster naturel inspecté ; elle ne justifie pas
de forcer les deux autres biomes dans la roche. Une livraison exige les preuves
cumulées des trois types, pas leur présence sur toute graine.
Le garde-fou est exercé par une vraie écriture `WorldGenRegion` : eau, lit,
parois naturelles, sources et sorties sont protégés. Quatre témoins de berge
appartiennent à un chunk voisin de leur cellule deau. Une écriture hors volume
reste possible. Le test utilise une vue modifiable de chunk dédiée, car les
holders FULL exposent normalement une vue de génération en lecture seule.
Les blocs témoins temporaires sont restaurés dans le monde de développement.
Lessai client ouvre le bouton natif **Personnaliser**, parcourt les trois
tailles et vérifie Terminé, Annuler, Échap, réouverture et rafraîchissement des
réglages. L’écriture/relecture réelle des paramètres conserve la taille, la
graine, les options, le Nether et lEnd. Les anciens générateurs ne deviennent
pas personnalisables par erreur ; l’éditeur Superflat vanilla reste disponible.
Une capture confirme la lisibilité de l’écran. Cet essai ne constitue pas une
visite graphique de l’île.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=512 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=1024 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
```
Les diagnostics détaillés restent dans le dossier ignoré
`mods/sanctuary/build/run/gameTest/diagnostics/unified-<diamètre>-seed-<graine>-*.json`.
Les anciennes mesures alpha.11 ci-dessous ne sont pas des mesures alpha.13.
### Mesure alpha.13 de Moyen
Sur le Mac M1 à 8 Go, Java 25.0.3 et heap de 2 Gio, graine 0, un seul processus
de jeu actif : la création initiale prend **17,65 s**, puis 289 chunks FULL autour
du spawn **46,01 s**, soit **63,66 s** cumulées. Le couloir suivant de 80 chunks
FULL prend **7,82 s**. Le maximum observé pour une requête de chunk est de 1,30 s
autour du spawn et de 0,79 s dans ce couloir.
Le protocole conserve les anneaux 17 × 17 et le couloir est de lalpha.11.
Lancien Grand et le nouveau Moyen ont le même diamètre nominal de 724 blocs,
mais la décoration et le spawn peuvent différer : ce nest pas une mesure
d’équivalence de génération. Le résultat de départ reste proche des 62,67 s
mesurées pour lalpha.11. Un seul passage par version, sans mesure du GPU,
des FPS, du réseau, de la disponibilité des chunks pour les ticks ou dune
charge multijoueur. Grand na pas de benchmark complet 289 + 80 dans cette
livraison ; ses tests moteur ci-dessus ont une autre charge de travail.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryBenchmark=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Rapport local : `diagnostics/generation-performance-unified-724-seed-0.json`,
log `build/alpha13-benchmark-medium.log`.
### Expansions et rechargement alpha.13
Sur le monde unifié Moyen de graine 42, les deux processus réussissent :
refus des huit premiers sites occupés, tropicale plus loin au nord, branche
aride à au moins 2 048 blocs de son parent, relecture du journal et des blocs
témoins, puis nouvelle expansion après redémarrage. Les colonnes de terrain
et les biomes enregistrés restent identiques après décodage du générateur.
Le framework reconstruit les paramètres actifs depuis le preset ; le test
vérifie séparément le codec sauvegardé et les fichiers réellement rechargés.
Aucun camp admissible na été observé sur lorigine unifiée des graines 42 et
8 675 309 : les fondations naturelles ou la protection hydrologique écartent
les candidats. Les essais stricts y échouent donc sur la preuve de structure.
La paire unifiée validée utilise explicitement un témoin de structure
facultatif, seulement après inspection complète des candidats. Ses rapports
indiquent `native_structure_observed=false` et ne constituent aucune preuve
positive de placement ou de butin dans lorigine alpha.13. Les critères de
fondation et la génération ne sont pas assouplis pour faire passer un test.
Exécuter les deux commandes sans lancer un autre GameTest entre elles :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Sans ce drapeau explicite, le scénario exige toujours une vraie structure avec
son conteneur et sa table de butin native. La réouverture vérifie toujours ces
trois éléments lorsquils ont été observés dans le premier processus.
La paire historique, rejouée avec le binaire alpha.13, réussit sur la graine
42 sans rendre le témoin facultatif : camp abandonné de forêt, bloc du
conteneur et table de butin native retrouvés après redémarrage. Cela valide
la chaîne génération 12 conservée ; ce résultat ne vaut pas comme preuve
dun camp dans lorigine unifiée. Le smoke du journal teste aussi une origine
13 de diamètre 1 024 avec un enfant 12, le schéma 1 et le refus dune lecture
avec une autre génération ou taille, sans modifier les octets enregistrés.
## Correctif alpha.12.1 — tous les premiers emplacements occupés
Le scénario laboratoire utilise les mêmes commandes Gradle ci-dessous. Il
prépare maintenant un vrai chunk FULL vide sur chacun des huit premiers sites,
constate leur occupation puis ouvre une tropicale plus au nord. Une branche
aride demande ensuite une distance explicite de 2 048 blocs depuis cette île.
Les anciens chunks vides et le bloc témoin restent inchangés.
Le second processus vérifie dabord les huit refus sur disque avec les chunks
témoins non chargés, puis ouvre une autre île plus loin au sud et attend du
terrain prêt. Les preuves se trouvent dans
`diagnostics/expansion-alpha12-1-seed-42.json` et
`diagnostics/expansion-alpha12-1-reload-seed-42.json` sous le dossier GameTest.
Le rapport exporté est [alpha.12.1](benchmarks/alpha121-expansion.json).
`expansionPlacementSmoke`, inclus dans `check`, vérifie les huit directions,
les quatre tailles, les distances minimales diagonales/cardinales, les bornes,
les réservations existantes et limmutabilité. Les tests de commandes contrôlent
les anciennes syntaxes, les retours `preview` à 2 048 blocs et les distances
hors limites. Le correctif ne change ni le codec, ni la génération 12, ni le journal v1.
## Laboratoire alpha.12 — validation locale
`check build assemblePack` passe sur Java 25 / Minecraft 26.3-pre-2, avec
les douze tests moteur Grand de graine 0 et 1 800 ticks de fluides. Les smokes
`expansionJournalSmoke` et `expansionReliefSmoke` sont inclus dans `check`.
Le premier contrôle atomicité, corruption, immutabilité, relecture et réservation
interrompue ; le second couvre cinq graines, quatre diamètres et quatre reliefs.
Le laboratoire possède un scénario moteur distinct, à lancer puis à rouvrir
**dans cet ordre**, sans autre GameTest entre les deux :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryExpansionTests=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Le premier passage crée le monde jetable. Le second saute volontairement
`cleanGameTestWorld` et ouvre cette même sauvegarde dans un nouveau processus
Java. Il échoue si les preuves du premier passage manquent ; il ne les fabrique
pas après rechargement. La propriété reload est réservée à cette paire de tests.
Les deux passages réussissent sur la graine 42. Le premier ouvre une tropicale
au nord puis une branche aride, vérifie les vrais chunks FULL, les biomes,
les commandes opérateur, les refus de doublons incohérents et du vide déjà
chargé, et la conservation dun bloc témoin. Il observe un camp Vanilla placé
avec un tonneau et sa table de butin native. Le second retrouve le journal,
un bloc dor écrit avant larrêt, les colonnes de blocs/biomes/densités et le
tonneau. Il décode également le codec sauvegardé, mais le framework reconstruit
les réglages actifs depuis le preset : aucun parcours client normal ou joueur
connecté nest simulé. Le crash forcé au milieu dune préparation reste à essayer.
La graine 0 a fourni les deux expansions mais aucune structure acceptable :
neuf sites étaient vides et deux avaient une fondation trop mince. Le scénario
exigeant une preuve positive de butin y échoue ; cela ne justifie aucun ajout
forcé de terrain ni relâchement des fondations. Les validations visuelles des
villages, temples, reliefs et grandes tailles restent ouvertes.
Les preuves exportées figurent dans [le rapport alpha.12](benchmarks/alpha12-expansion.json).
Les diagnostics complets de chaque exécution restent dans
`mods/sanctuary/build/run/gameTest/diagnostics/expansion-alpha12[-reload]-seed-42.json`.
Les commandes et le contrat serveur sont dans [Laboratoire](alpha12-laboratory.md).
Aucun monde personnel na servi aux essais.
## Tests de forme
La tâche `:sanctuary:worldgenSmoke`, incluse dans `check`, vérifie les invariants de
@@ -43,7 +299,8 @@ labsence de remplissage de secours lorsque la géométrie ne convient pas.
`runGameTest` démarre le serveur de test headless officiel de Fabric dans
`mods/sanctuary/build/run/gameTest/`. La tâche `cleanGameTestWorld` supprime uniquement
son monde jetable avant chaque exécution afin de générer des chunks neufs.
son monde jetable avant chaque exécution afin de générer des chunks neufs, sauf
lors du second passage explicite `sanctuaryExpansionReload` décrit ci-dessus.
Il ne déploie rien dans un serveur ou une installation de jeu personnels.
La configuration garde `eula=false` : aucun fichier d'acceptation n'est écrit par
Loom. Fabric dispose d'un chemin de démarrage propre à ses tests automatisés.
@@ -54,9 +311,10 @@ Deux adaptations y sont nécessaires, vérifiées contre les classes Minecraft
- `GameTestServer` sélectionne normalement `minecraft:flat_all_dimensions`.
Le mixin de test sélectionne directement le preset de production
`sanctuary:sanctuary` pour 20 joueurs par défaut.
`-PsanctuaryTestPlayers=5` ou `100` sélectionne sa variante de production,
sans recopier ses JSON.
`sanctuary:sanctuary`, sans recopier ses JSON. Lalpha.11 utilise le défaut
Grand (`-PsanctuaryTestPlayers=10`) et Petit (`5`). Les profils historiques
restent accessibles aux diagnostics de compatibilité, séparément de la liste
publique de création.
- Le framework désactive normalement les structures. Le test les active dans
`WorldOptions` pour examiner les chunks après toutes les étapes de génération.
La seed du serveur de test est `0` par défaut ; `-PsanctuaryTestSeed` est transmis
@@ -193,6 +451,87 @@ Pour cet essai visuel en monde de développement créatif, comparer la même vue
Y=63 ni assombrissement d'altitude au passage de Y=32. Les brouillards de l'eau,
de la météo et des effets d'aveuglement ou d'obscurité doivent rester présents.
## Petit, Grand et optimisation alpha.11 — 9 septembre 2026
`check build assemblePack` réussit sur les sources finales avec Grand, puis
la suite moteur réussit également sur Petit. Douze tests requis passent dans
chaque monde neuf, graine `0`. Les mondes personnels restent fermés.
| Choix | Chunks FULL inspectés | Eau retenue | Ticks de fluides |
| --- | --- | --- | --- |
| Petit (`population_5`) | 110 | 5 656 | 1 800 |
| Grand (`population_10`, défaut) | 125 | 5 923 | 1 800 |
Les deux essais vérifient les sources, déversoirs, coulées de lave et témoins
de réentrée après post-traitement. La persistance réelle sur disque préserve
les quatre références 5/10/20/100, le défaut Grand, les deux seuls choix publics
et lancien défaut 20 lorsque le champ `players` est absent dune densité sauvée.
Loracle alpha.10 effectue **3 183 804 comparaisons bit à bit** de densités
compilées réelles : références 5/10/20/100, graines `0`, `42`, `8675309`,
volumes, coordonnées signées, frontières et accès concurrents. Pour 10,
loracle utilise lancien algorithme paramétré à cette nouvelle taille ;
aucun ancien preset 10 nest supposé. Les tests synthétiques ajoutent
122 528 comparaisons sur les arrondis, collisions et changements de contexte.
Les expressions de datapacks non reconnues gardent le sampler vanilla.
Un test de concurrence vérifie quun plan prêt reste lisible pendant un
calcul lent, sans publier de plan incomplet ni conserver un échec en cache.
### Mesure à géométrie identique et nouveau Grand
Machine : Apple M1, 8 Gio de RAM, Java 25.0.3, heap 2 Gio, huit processeurs
visibles, Minecraft 26.3-pre-2 / Fabric 0.19.5. **Une exécution par configuration**,
graine `0`, nouveau monde à chaque fois, spawn `(0,252,0)`. La référence
alpha.10 est le commit `a26b72f` avec uniquement le harnais de mesure ajouté :
aucun fichier de production modifié. Aucun autre serveur ou build simultané.
| Phase serveur | Alpha.10, ancien 20 | Alpha.11, ancien 20 | Alpha.11, Grand 10 |
| --- | --- | --- | --- |
| Création du monde et spawn | 96,766 s | 18,888 s | 18,376 s |
| 289 chunks autour du spawn | 310,664 s | 43,647 s | 44,291 s |
| Somme création + zone | 407,430 s | 62,535 s | 62,667 s |
| 80 chunks dexploration | 103,142 s | 14,051 s | 7,685 s |
À géométrie 20 identique, la somme initiale baisse de **84,65 %** et
lexploration de **86,38 %**. Le gain minimal de 30 % est dépassé sur ce
protocole. Grand respecte le proxy de 120 secondes pour créer le monde et
produire la zone demandée. Sa surface différente ne compte pas comme preuve
du gain algorithmique. La latence maximale dun chunk dexploration sur
lancien 20 passe de 25,13 à 5,15 secondes : une région froide conserve donc
un coût ponctuel. Le cache ne rend pas toute exploration instantanée.
Le benchmark demande 289 chunks `FULL` par anneaux jusquau rayon huit,
puis 80 dans un corridor vers lest. Il distingue la création relevée dans
`Time elapsed` des phases mesurées dans le test. **FULL ne garantit pas le
statut de simulation ni la fin du post-traitement.** Le libellé du rapport
brut alpha.10 affirmait par erreur inclure ce dernier ; seul ce texte a été
corrigé dans le harnais alpha.11, sans changer les demandes de chunks.
Ni rendu client, ni réseau, ni FPS, ni délai dentrée visuellement confirmé,
ni capacité multijoueur ne sont mesurés. Une graine et une répétition ne
mesurent pas la variance sur toutes les îles ou machines.
Les [résultats structurés et hashes](benchmarks/alpha11-generation.json)
conservent les phases, anneaux, latences, périmètres et preuves moteur.
Les journaux bruts locaux sont `build/alpha11-baseline-20.log`,
`build/alpha11-optimized-20.log`, `build/alpha11-grand-10.log`,
`build/alpha11-check-build.log` et `build/alpha11-small-5.log`.
Reproduire le benchmark sur cette version (utiliser `10` pour Grand) :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryBenchmark=true -PsanctuaryTestPlayers=20 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
```
Loption sélectionne le harnais de performance à la place de la suite moteur,
jamais dans le JAR livré. Le build obligatoire reste une exécution normale :
```sh
./gradlew check build assemblePack -PsanctuaryTestPlayers=10 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryTestPlayers=5 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
```
Les tableaux alpha.10 suivants restent historiques.
## Contrôles et validation alpha.10 — 9 septembre 2026
Les profils `sanctuary:population_5`, `population_20` et `population_100`
+34 -14
View File
@@ -50,15 +50,19 @@ Les intégrations communautaires envisagées comprennent Fabric API, Sodium, Iri
L'île de Sanctuary est le point de départ commun. Elle flotte dans un vide qui ne doit pas être rempli par la génération habituelle du monde. Les continents suivants sont de vastes terres suspendues, créées progressivement selon une taille et une direction maîtrisées.
La capacité prévue du groupe choisit la taille initiale : **5, 20 ou
100 joueurs**, avec **20 par défaut**. Laire nominale doit évoluer
proportionnellement à cette capacité, en prenant l’île de 512 blocs de
diamètre comme référence pour cinq joueurs. Cela donne 1 024 blocs pour
vingt joueurs et environ 2 290 pour cent joueurs, sur une hauteur conservée
de 384 blocs. Ce choix appartient à la création du monde ; il nagrandit pas
une sauvegarde existante ni ne suit automatiquement les connexions.
Il sagit dun dimensionnement du terrain, sans quota de ressources imposé
ou garantie de performances. Lalpha.10 met ces trois tailles à disposition ; leurs contrôles moteur portent sur la graine `0` et des emprises explicites.
Le monde doit se créer par un seul choix **Sanctuary**, avec le bouton natif
**Personnaliser** pour régler sa taille : **Petit** (512 blocs de diamètre
nominal), **Moyen** (724, par défaut) et **Grand** (1 024). La hauteur reste de
384 blocs. Ces dimensions ne promettent ni une aire entièrement habitable,
ni un stock de ressources, ni une capacité multijoueur.
Lalpha.13 implémente cette interface et la réunion de l’île complète de Population,
de ses forêts et de son hydrologie, avec les cavités natives du laboratoire.
Lalpha.11 et ses anciens choix Petit/Grand restent historiques. Les anciens
codecs et paramètres sauvegardés restent chargeables ; les choix précédents
sont cachés à la création et aucune partie nest convertie ou redimensionnée.
L’état dintégration est décrit dans le [guide alpha.13](alpha13-worldgen.md) ;
les observations réelles et leurs limites restent dans [Validation](testing.md).
L'île initiale doit être **tempérée et légèrement humide**, avec quelques zones
plus sèches, notamment de larges plages et dépôts irréguliers de sable, gravier,
@@ -130,11 +134,23 @@ facultatives selon le relief ; leur existence doit être démontrée en jeu.
L’état de chaque incrément et ses limites
figurent dans [Génération](worldgen.md).
Le premier champ de recherche des expansions associe les **huit directions**
autour de Sanctuary à des signatures de température et d'humidité. En regardant
la carte avec le nord en haut : **nord froid, sud chaud, ouest sec et est humide**.
Les diagonales combinent ces tendances. La recherche porte sur des climats et
leurs distributions, sans imposer la même forme de continent à chaque direction.
La progression des expansions se fait par **choix en branchement**. La direction
géographique, le climat et larchétype sont des choix distincts. La convention
**nord froid, sud chaud, ouest sec et est humide** reste une voie possible, avec
ses diagonales. Le joueur peut aussi ouvrir une branche alternative et composer
des couronnes de nouvelles îles aux climats différents. Une île enregistre son
parent ; le serveur conserve ainsi lhistoire des découvertes de son univers.
La recherche porte sur des climats et leurs distributions, sans imposer la même
forme ni le même climat à une direction pour toutes les parties.
Les alpha.12 et alpha.12.1 ont fourni un laboratoire à cette conception :
commandes opérateur, climats et reliefs indépendants, structures vanilla
compatibles et recherche demplacements libres. Lalpha.13 reprend ces mêmes
outils dans le nouveau monde Sanctuary unifié. Son journal est séparé de celui
des anciens laboratoires, sans conversion de leur génération.
Les recherches, mailboxes et deposit boxes viendront ensuite ; elles ne sont
pas nécessaires aux commandes dessai. Le passage en bêta dépendra du périmètre
de progression retenu et de sa validation, pas du seul changement de générateur.
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,
@@ -150,6 +166,10 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
### Structures et territoires
- **Structures vanilla** : admises dans les nouvelles générations lorsque leur
biome, leur emprise et leurs fondations conviennent au terrain flottant.
Lautorisation ne garantit pas leur présence sur chaque île. Le laboratoire
commence par les villages, temples, cabanes de marais et camps abandonnés.
- **Lost Cities** : des zones traitées comme des biomes, où des bâtiments en ruine remplacent la végétation dominante. Le nom exprime ici un type de territoire souhaité, sans décider encore s'il sera produit par une intégration ou par du code Sanctuary.
- **Structures contemporaines** : parcs, parkings et autres vestiges. Les parkings sont souterrains et peuvent s'étendre comme une infestation. Ils n'apparaissent pas dans la dimension des cavernes.
- **Structures uniques et donjons** : d'abord sur Sanctuary Island, puis dans les continents. Ils peuvent contenir des clés de chunk et d'autres ressources rares.
+69 -29
View File
@@ -1,41 +1,80 @@
# Premier monde Sanctuary
Le preset `sanctuary:sanctuary` crée l'île principale à l'origine de l'Overworld,
entourée de vide sans répétition d'îles à grande distance. C'est une première
tranche de génération pour Minecraft **26.3-pre-2 / Fabric**, pas encore le
système d'expansion collective.
## Alpha.13 — génération unifiée
Lalpha.10 propose trois tailles d’île pour 5, 20 ou 100 joueurs, avec
20 joueurs par défaut. Les trois profils passent les contrôles moteur sur
la graine `0`, dans des emprises explicitement inspectées. Les générations
historiques conservent leurs réglages, notamment `sanctuary:sanctuary_cavern`
pour lalpha.9 et `sanctuary:sanctuary_rift` pour lalpha.8. Les observations
de chaque version restent séparées dans [Validation](testing.md).
Le choix public **Sanctuary** (`sanctuary:sanctuary`) crée l’île principale à
lorigine de lOverworld, entourée de vide. Son bouton natif **Personnaliser**
propose **Petit** (512 blocs de diamètre nominal), **Moyen** (724, par défaut)
et **Grand** (1 024). Les types vanilla restent disponibles. La hauteur reste
de 384 blocs ; les dimensions nominales ne donnent ni aire habitable garantie
ni capacité multijoueur.
## Créer un monde
L’île initiale reprend la densité, les forêts et lhydrologie complète de la
famille Population, avec les poches de biomes souterrains natives du laboratoire.
Les extensions conservent leurs profils climatiques et leurs commandes
alpha.12.1. Leurs règles de génération restent distinctes de celles de l’île
initiale : la grande hydrologie de départ nest pas automatiquement reproduite
sur chaque continent ajouté.
En solo : créer un **nouveau** monde et choisir le type de monde **Sanctuary**
dans les options de génération. Les types de monde vanilla restent disponibles.
Lalpha.10 utilise ce choix pour 20 joueurs par défaut, avec des variantes
5 et 100 joueurs. Les identifiants sont `sanctuary:sanctuary` (20),
`sanctuary:sanctuary_5` (5) et `sanctuary:sanctuary_100` (100).
Les trois tailles et les cavités passent les contrôles moteur. Le [guide alpha.13](alpha13-worldgen.md)
décrit linterface, les paramètres sauvegardés et les outils dexpansion ;
[Validation](testing.md) conserve la portée des essais de chaque version.
Sur un nouveau serveur Fabric disposant du mod et de Fabric API :
## Créer un nouveau monde
En solo, choisir **Sanctuary**, ouvrir **Personnaliser**, puis confirmer la
taille avec **Terminé**. **Annuler** conserve le réglage précédent. Cette
interface configure le monde à créer ; elle ne redimensionne pas une partie.
Sur un nouveau serveur Fabric avec Fabric API, le preset public utilise la
taille **Moyen**, 724 blocs :
```properties
level-type=sanctuary:sanctuary
level-name=sanctuary
level-name=sanctuary-dev
level-seed=0
```
Pour créer une variante alpha.10, remplacer seulement
`level-type` par `sanctuary:sanctuary_5` ou `sanctuary:sanctuary_100`, avec un
nouveau `level-name`. Ces exemples concernent une création de monde, pas le
changement de taille dune sauvegarde existante.
La graine est libre. Utiliser un dossier neuf pour un essai ; modifier
`level-type` ne convertit aucun monde enregistré. Aucune commande de migration
ou de régénération des anciennes îles nest fournie.
La seed est libre. Modifier `level-type` ne convertit pas un monde existant :
utiliser un nouveau `level-name` pour tester une autre génération. Les fichiers
de test et les sauvegardes personnelles doivent rester distincts.
## Compatibilité et versions historiques
Lalpha.13 enregistre le générateur `sanctuary:island_v13`, son diamètre initial
et une racine de génération 13. Les nouvelles expansions restent de génération
12. Leur journal utilise le dossier propre au monde
`data/sanctuary-world-v13/`. Les anciens codecs et presets sont conservés, mais
cachés de la sélection publique. Les mondes déjà créés conservent leurs réglages
et leur journal ; aucun chunk nest remplacé par cette mise à jour.
### Alpha.12 et alpha.12.1 — laboratoire historique
Le preset distinct `sanctuary:expansion_lab` proposait une origine de 512 blocs
et des commandes opérateur pour ajouter des îles de 64/128/256/512 blocs.
Son relief expérimental et ses biomes natifs ne reprenaient pas les grandes
rivières et terrasses de Population. Lalpha.12.1 ajoutait une distance
facultative et la recherche dun emplacement libre au-delà des chunks occupés.
Ces mondes gardent leur génération 12 et leur dossier
`data/sanctuary-expansion-v1/`. Le [guide du laboratoire](alpha12-laboratory.md)
reste la référence historique de ces versions, avec leurs seules observations.
### Alpha.11 — Petit et Grand historiques
| Ancien choix public | Diamètre nominal | Ancien preset | Réglage enregistré |
| --- | --- | --- | --- |
| Petit | 512 blocs | `sanctuary:sanctuary_5` | `sanctuary:population_5` |
| Grand, ancien défaut | Environ 724 blocs | `sanctuary:sanctuary` | `sanctuary:population_10` |
Lancien Grand correspond à la taille nominale du nouveau Moyen. Les mondes
historiques `population_5`, `population_10`, `population_20` et `population_100`
restent chargeables avec leurs dimensions. Le preset public est une recette
de création ; sa nouvelle définition ne remplace pas leur générateur sauvegardé.
Loptimisation alpha.11 conserve larithmétique de linterpolation et les règles
de terrain et dhydrologie. Ses mesures et contrôles sont historiques, sans
validation anticipée des ajouts de lalpha.13.
## Socle de génération conservé
@@ -86,11 +125,12 @@ chaque seed. Laccès à chaque progression vanilla et les ressources renouvel
nécessitent des vérifications concrètes. Les stocks réels sont relevés après la
génération, sans corriger une île pour atteindre un quota.
## Capacité de l’île — génération v10
## Capacité de l’île — génération v10 historique
Lalpha.10 propose trois tailles accessibles à la création dun monde.
Le choix **Sanctuary** vise 20 joueurs par défaut, accompagné de variantes
pour 5 et 100 joueurs. Les presets et paramètres de bruit sont associés ainsi :
Lors de lalpha.10, trois tailles étaient accessibles à la création dun monde.
Le choix **Sanctuary** visait 20 joueurs par défaut, accompagné de variantes
pour 5 et 100 joueurs. Le tableau suivant conserve cette association historique ;
lalpha.11 utilise le nouveau défaut Grand indiqué plus haut :
| Preset de création | Paramètres enregistrés dans le monde |
| --- | --- |
+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.10
pack_version=0.1.0-alpha.10
mod_version=0.1.0-alpha.13.1
pack_version=0.1.0-alpha.13.1
maven_group=fr.koka.sanctuary
+54 -2
View File
@@ -26,16 +26,37 @@ if (clientRenderTests) {
}
}
// Every integration run must exercise fresh generation, never stale saved chunks.
// Fresh generation by default; the explicit laboratory reload verifies the same saved test world.
tasks.register('cleanGameTestWorld', Delete) {
onlyIf { !providers.gradleProperty('sanctuaryExpansionReload').map { it.toBoolean() }.getOrElse(false) }
delete(layout.buildDirectory.dir('run/gameTest/world'))
}
tasks.named('runGameTest') {
dependsOn('cleanGameTestWorld')
systemProperty('sanctuary.test.seed', providers.gradleProperty('sanctuaryTestSeed').getOrElse('0'))
systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('20'))
systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('10'))
systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample'))
systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true'))
systemProperty('sanctuary.test.unified', providers.gradleProperty('sanctuaryUnifiedTests').getOrElse('true'))
systemProperty('sanctuary.test.diameter', providers.gradleProperty('sanctuaryTestDiameter').getOrElse('724'))
systemProperty('sanctuary.test.expansion', providers.gradleProperty('sanctuaryExpansionTests').getOrElse('false'))
systemProperty('sanctuary.test.expansionReload', providers.gradleProperty('sanctuaryExpansionReload').getOrElse('false'))
systemProperty('sanctuary.test.optionalStructureWitness', providers.gradleProperty('sanctuaryOptionalStructureWitness').getOrElse('false'))
}
tasks.named('processGametestResources') {
def benchmark = providers.gradleProperty('sanctuaryBenchmark').map { it.toBoolean() }.getOrElse(false)
inputs.property('benchmark', benchmark)
def unified = providers.gradleProperty('sanctuaryUnifiedTests').map { it.toBoolean() }.getOrElse(true)
inputs.property('unified', unified)
def expansion = providers.gradleProperty('sanctuaryExpansionTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('expansion', expansion)
filesMatching('fabric.mod.json') {
expand(gametest_entrypoint: expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests'
: unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests'
: 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests')
}
}
dependencies {
@@ -222,3 +243,34 @@ tasks.register('populationRegionPlanSmoke', JavaExec) {
mainClass = 'fr.koka.sanctuary.worldgen.PopulationRegionPlanSmoke'
}
tasks.named('check') { dependsOn('populationRegionPlanSmoke') }
tasks.register('populationPlanCacheSmoke', JavaExec) {
group = 'verification'
description = 'Check that completed regions remain readable while new regions are planned.'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.worldgen.PopulationPlanCacheSmoke'
}
tasks.named('check') { dependsOn('populationPlanCacheSmoke') }
tasks.register('expansionJournalSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionJournalSmoke'
}
tasks.register('expansionReliefSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionReliefSmoke'
}
tasks.named('check') { dependsOn('expansionJournalSmoke', 'expansionReliefSmoke') }
tasks.register('expansionPlacementSmoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.ExpansionPlacementSmoke'
}
tasks.named('check') { dependsOn('expansionPlacementSmoke') }
@@ -16,6 +16,7 @@ public final class SanctuaryClientRenderTests implements FabricClientGameTest {
"net.minecraft.client.renderer.fog.FogRenderer", "sanctuary$openVoidFog");
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
});
SanctuaryWorldOptionsClientChecks.run(context);
}
private static void assertApplied(ClassLoader loader, String target, String hook) {
@@ -0,0 +1,179 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.client.SanctuaryWorldOptionsScreen;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.CycleButton;
import net.minecraft.client.gui.components.tabs.TabNavigationBar;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.RegistryOps;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.WorldGenSettings;
import net.minecraft.world.level.storage.LevelStorageSource;
/** The real new-world button path and saved settings, without starting or opening a game world. */
final class SanctuaryWorldOptionsClientChecks {
private static final KeyEvent PRESS = new KeyEvent(InputConstants.KEY_RETURN, 0, 0);
static void run(ClientGameTestContext context) {
Screen previous = context.computeOnClient(client -> client.gui.screen());
context.runOnClient(client -> CreateWorldScreen.openFresh(client, () -> client.gui.setScreen(previous)));
context.waitFor(client -> client.gui.screen() instanceof CreateWorldScreen, 1200);
CreateWorldScreen create = context.computeOnClient(client -> (CreateWorldScreen) client.gui.screen());
List<Map<String, Object>> savedSizes = new ArrayList<>();
try {
context.runOnClient(client -> {
TabNavigationBar tabs = create.children().stream().filter(TabNavigationBar.class::isInstance)
.map(TabNavigationBar.class::cast).findFirst().orElseThrow();
tabs.selectTab(1, false); // The vanilla World tab owns the world type and Customize widgets.
var state = create.getUiState();
long sanctuaryChoices = state.getNormalPresetList().stream().filter(entry -> entry.preset() != null
&& entry.preset().getRegisteredName().startsWith("sanctuary:")).count();
require(sanctuaryChoices == 1, "Exactly one public Sanctuary choice must remain");
require(state.getAltPresetList().stream().filter(entry -> entry.preset() != null
&& entry.preset().getRegisteredName().startsWith("sanctuary:")).count() == 1,
"The extended world-type cycle must not expose the old laboratory or sizes");
CycleButton<?> worldType = create.children().stream().filter(CycleButton.class::isInstance)
.map(CycleButton.class::cast)
.filter(button -> button.getValue() instanceof WorldCreationUiState.WorldTypeEntry)
.findFirst().orElseThrow();
int attempts = state.getNormalPresetList().size();
while (!isSanctuary(state) && attempts-- > 0) worldType.onPress(PRESS);
require(isSanctuary(state), "The real world-type cycle must reach Sanctuary");
require(diameter(create) == 724, "Sanctuary defaults to Medium");
state.setSeed("42");
state.setGenerateStructures(false);
state.setBonusChest(true);
});
// Cancel and Escape both leave the pending generator untouched.
context.clickScreenButton("selectWorld.customizeType");
context.waitForScreen(SanctuaryWorldOptionsScreen.class);
context.runOnClient(client -> chooseSize(client.gui.screen(), "LARGE"));
context.clickScreenButton("gui.cancel");
context.runOnClient(client -> require(diameter(create) == 724, "Cancel must not apply Large"));
context.clickScreenButton("selectWorld.customizeType");
context.runOnClient(client -> {
chooseSize(client.gui.screen(), "SMALL");
client.gui.screen().keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE, 0, 0));
require(client.gui.screen() == create && diameter(create) == 724,
"Escape must return to creation without applying Small");
});
Path diagnostics = Path.of("diagnostics").toAbsolutePath();
Files.createDirectories(diagnostics);
Path testRoot = Files.createTempDirectory(diagnostics, "world-options-settings-");
var storage = LevelStorageSource.createDefault(testRoot);
for (var size : Map.of("SMALL", 512, "MEDIUM", 724, "LARGE", 1024).entrySet()) {
var before = context.computeOnClient(client -> create.getUiState().getSettings());
context.clickScreenButton("selectWorld.customizeType");
context.waitForScreen(SanctuaryWorldOptionsScreen.class);
context.runOnClient(client -> chooseSize(client.gui.screen(), size.getKey()));
context.clickScreenButton("gui.done");
context.runOnClient(client -> {
require(client.gui.screen() == create && diameter(create) == size.getValue(),
"Done must apply the chosen diameter and return to creation");
var after = create.getUiState().getSettings();
require(after.options().equals(before.options()), "Size must preserve seed, structures and bonus chest");
for (var dimension : List.of(LevelStem.NETHER, LevelStem.END)) {
require(after.selectedDimensions().get(dimension).equals(before.selectedDimensions().get(dimension)),
"Size must preserve other dimensions");
}
require(isSanctuary(create.getUiState()), "Customization must keep the single Sanctuary world type");
create.getUiState().setSettings(after.withOptions(options -> options));
require(diameter(create) == size.getValue(),
"Refreshing world settings must preserve the custom size instead of restoring the preset default");
String name = size.getKey().toLowerCase(java.util.Locale.ROOT);
var settings = new WorldGenSettings(after.options(), after.selectedDimensions());
try (var save = storage.createAccess(name)) {
LevelStorageSource.writeWorldGenSettings(after.worldgenLoadContext(), storage.getLevelPath(name), settings);
var restored = LevelStorageSource.readExistingSavedData(save, after.worldgenLoadContext(),
WorldGenSettings.TYPE).getOrThrow();
require(restored.dimensions().overworld() instanceof SanctuaryChunkGenerator generator
&& generator.initialDiameter() == size.getValue(), "Selected size must survive actual settings storage");
var ops = RegistryOps.create(JsonOps.INSTANCE, after.worldgenLoadContext());
require(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow()
.equals(WorldGenSettings.CODEC.encodeStart(ops, settings).getOrThrow()),
"Stored options and dimensions must remain unchanged");
savedSizes.add(Map.of("size", name, "diameter", size.getValue(), "disk_round_trip", true));
}
});
context.clickScreenButton("selectWorld.customizeType");
context.runOnClient(client -> require(sizeButton(client.gui.screen()).getValue().toString().equals(size.getKey()),
"Reopening Customize must restore the saved selection"));
if (size.getKey().equals("MEDIUM")) context.takeScreenshot("sanctuary-world-options-medium");
context.clickScreenButton("gui.cancel");
}
context.runOnClient(client -> {
var state = create.getUiState();
var settings = state.getSettings();
var presets = settings.worldgenLoadContext().lookupOrThrow(Registries.WORLD_PRESET);
var legacy = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_5")))
.value().createWorldDimensions().overworld();
state.updateDimensions((registries, dimensions) -> dimensions.replaceOverworldGenerator(registries, legacy));
require(state.getPresetEditor() == null,
"A legacy generator under the public key must not gain an implicit conversion editor");
state.setSettings(settings);
var flat = presets.getOrThrow(net.minecraft.world.level.levelgen.presets.WorldPresets.FLAT);
state.setWorldType(new WorldCreationUiState.WorldTypeEntry(flat));
require(state.getPresetEditor() != null
&& !(state.getPresetEditor().createEditScreen(create, state.getSettings()) instanceof SanctuaryWorldOptionsScreen),
"Vanilla flat-world customization must remain available");
});
Files.writeString(diagnostics.resolve("sanctuary-world-options-client.json"),
new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"public_sanctuary_choices", 1, "default_diameter", 724,
"saved_sizes", savedSizes, "cancel_and_escape_preserved", true,
"settings_refresh_preserved", true,
"legacy_editor_guard", true, "vanilla_editor_preserved", true,
"test_settings_directory", testRoot.toString(), "passed", true)) + "\n");
SanctuaryMod.LOGGER.info("Sanctuary world options client tests passed: one world type, three saved sizes, Done/Cancel/Escape.");
} catch (IOException failure) {
throw new AssertionError("Unable to verify the selected world settings on disk", failure);
} finally {
context.runOnClient(client -> create.onClose());
}
}
private static boolean isSanctuary(WorldCreationUiState state) {
var preset = state.getWorldType().preset();
return preset != null && preset.getRegisteredName().equals("sanctuary:sanctuary");
}
private static int diameter(CreateWorldScreen create) {
return ((SanctuaryChunkGenerator) create.getUiState().getSettings().selectedDimensions().overworld()).initialDiameter();
}
private static CycleButton<?> sizeButton(Screen screen) {
require(screen instanceof SanctuaryWorldOptionsScreen, "Customize must open the Sanctuary screen");
return screen.children().stream().filter(CycleButton.class::isInstance).map(CycleButton.class::cast)
.findFirst().orElseThrow();
}
private static void chooseSize(Screen screen, String target) {
CycleButton<?> button = sizeButton(screen);
int attempts = 3;
while (!button.getValue().toString().equals(target) && attempts-- > 0) button.onPress(PRESS);
require(button.getValue().toString().equals(target), "Size cycle must expose " + target);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,70 @@
// Frozen test oracle from a26b72f PopulationIslandDensity.java.
// Original SHA-256: fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1
// Only package/imports/class name differ; do not optimize this reference.
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.worldgen.*;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.util.Interval;
import net.minecraft.world.level.levelgen.densityfunction.*;
/** Capacity changes the envelope; all natural noise is still sampled at world coordinates. */
public record Alpha10PopulationDensityReference(DensityFunction terrain, DensityFunction distortion,
DensityFunction sculpt, DensityFunction detail, DensityFunction underside,
DensityFunction riftWarp, DensityFunction riftDetail, IslandCapacity capacity) implements DensityFunction {
public static final MapCodec<Alpha10PopulationDensityReference> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
DensityFunction.CODEC.fieldOf("terrain").forGetter(Alpha10PopulationDensityReference::terrain),
DensityFunction.CODEC.fieldOf("distortion").forGetter(Alpha10PopulationDensityReference::distortion),
DensityFunction.CODEC.fieldOf("sculpt").forGetter(Alpha10PopulationDensityReference::sculpt),
DensityFunction.CODEC.fieldOf("detail").forGetter(Alpha10PopulationDensityReference::detail),
DensityFunction.CODEC.fieldOf("underside").forGetter(Alpha10PopulationDensityReference::underside),
DensityFunction.CODEC.fieldOf("rift_warp").forGetter(Alpha10PopulationDensityReference::riftWarp),
DensityFunction.CODEC.fieldOf("rift_detail").forGetter(Alpha10PopulationDensityReference::riftDetail),
IslandCapacity.CODEC.optionalFieldOf("players", IslandCapacity.TWENTY).forGetter(Alpha10PopulationDensityReference::capacity)
).apply(instance, Alpha10PopulationDensityReference::new));
@Override public DensitySampler compileSampler(CompileContext context) {
var raw = terrain.compileSampler(context); var edge = distortion.compileSampler(context);
var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context);
var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context);
var fractureDetail = riftDetail.compileSampler(context);
long seed = context.createRandom(SanctuaryMod.id("population_rifts_alpha10")).nextLong();
var layouts = new ConcurrentHashMap<IslandCapacity.Region, RiftShape.Layout>();
return new DensitySampler() {
@Override public float sampleValue(SamplerContext sample, int x, int y, int z) {
if (Math.hypot((double) x, z) >= capacity.terrainLimit() || y <= 0 || y >= 384) return -1;
int localY = y - 64;
float natural = PopulationIslandShape.raisedDensity(x, y, z, raw.sampleValue(sample, x, localY, z),
edge.sampleValue(sample, x, 0, z), coarse.sampleValue(sample, x, localY, z),
fine.sampleValue(sample, x, localY, z), bottom.sampleValue(sample, x, localY, z), 64, 384, capacity);
if (natural <= 0) return natural;
var region = capacity.regionAt(x, z);
var layout = layouts.computeIfAbsent(region, r -> RiftShape.layout(regionSeed(seed, r)));
int rx = x - region.originX(), rz = z - region.originZ();
if (!RiftShape.mayAffect(layout, rx, rz)) return natural;
return RiftShape.density(layout, rx, y, rz, natural,
warp.sampleValue(sample, x, y, z), fractureDetail.sampleValue(sample, x, y, z));
}
@Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) {
DensitySampler.sampleVolumeNaive(sample, buffer, volume, this);
}
};
}
public static long regionSeed(long seed, IslandCapacity.Region region) {
long value = seed ^ ((long) region.x() << 32) ^ (region.z() & 0xffffffffL) ^ 0xA10CA7E1L;
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return value ^ (value >>> 31);
}
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) {
return new Alpha10PopulationDensityReference(rule.rewrite(terrain), rule.rewrite(distortion), rule.rewrite(sculpt),
rule.rewrite(detail), rule.rewrite(underside), rule.rewrite(riftWarp), rule.rewrite(riftDetail), capacity);
}
@Override public Interval range() { return Interval.of(-1, 1); }
@Override public int domainAxes() { return ALL_AXES; }
@Override public MapCodec<Alpha10PopulationDensityReference> codec() { return CODEC; }
}
@@ -0,0 +1,139 @@
package fr.koka.sanctuary.gametest;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionRuntime;
import java.io.IOException;
import java.util.ArrayList;
import net.minecraft.commands.CommandSource;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.level.ChunkLevel;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.server.permissions.PermissionLevel;
import net.minecraft.server.permissions.PermissionSet;
/** Tests real registered command permissions using sources; no real or simulated creative player is created. */
public final class ExpansionCommandChecks {
private ExpansionCommandChecks() { }
public static void verify(GameTestHelper helper) throws IOException {
var level = helper.getLevel();
var server = level.getServer();
var session = ExpansionRuntime.session(level);
var before = session.islands();
var dispatcher = server.getCommands().getDispatcher();
var console = server.createCommandSourceStack().withLevel(level).withSource(CommandSource.NULL).withSuppressedOutput();
var levelZero = console.withPermission(LevelBasedPermissionSet.forLevel(PermissionLevel.ALL));
var noPermissions = console.withPermission(PermissionSet.NO_PERMISSIONS);
var levelOne = console.withPermission(LevelBasedPermissionSet.MODERATOR);
var operatorTwo = console.withPermission(LevelBasedPermissionSet.GAMEMASTER);
var admin = console.withPermission(LevelBasedPermissionSet.ADMIN);
helper.assertTrue(console.getEntity() == null, "Command permission checks use console-derived sources, not a creative-player simulation");
helper.assertTrue(ChunkLevel.RADIUS_AROUND_FULL_CHUNK * 16 <= ExpansionIsland.GENERATION_HALO,
"The declared expansion halo must include the exact engine FULL generation dependency radius");
var sanctuary = dispatcher.getRoot().getChild("sanctuary");
helper.assertTrue(sanctuary != null && sanctuary.getChild("expansion") != null,
"The production Sanctuary expansion command tree must be registered");
helper.assertTrue(!sanctuary.canUse(levelZero) && !sanctuary.canUse(noPermissions) && !sanctuary.canUse(levelOne),
"Expansion commands must reject level 0, absent permissions and OP1 regardless of any game-mode assumption");
helper.assertTrue(sanctuary.canUse(operatorTwo) && sanctuary.canUse(admin) && sanctuary.canUse(console),
"Expansion commands must admit OP2 and higher, including the dedicated server console");
String create = "sanctuary expansion create command_denied sanctuary north boreal 64 natural";
expectDenied(helper, dispatcher, levelZero, create, "level 0");
expectDenied(helper, dispatcher, noPermissions, create, "no permissions");
expectDenied(helper, dispatcher, levelOne, create, "OP1");
helper.assertTrue(session.islands().equals(before), "Denied command sources must not add or change expansion regions");
String preview = "sanctuary expansion preview command_preview sanctuary north boreal 64 natural";
try {
var parsed = dispatcher.parse(preview, console);
helper.assertTrue(!parsed.getReader().canRead() && parsed.getExceptions().isEmpty(),
"The console must parse a complete preview with the known Sanctuary parent");
helper.assertTrue(dispatcher.execute(parsed) == 1, "The console preview must execute successfully through the registered dispatcher");
helper.assertTrue(dispatcher.execute(preview, operatorTwo) == 1, "OP2 must execute the same read-only preview successfully");
helper.assertTrue(session.islands().equals(before), "Preview must not reserve or activate an island");
var feedback = new ArrayList<Component>();
var previewSource = server.createCommandSourceStack().withLevel(level).withPermission(LevelBasedPermissionSet.GAMEMASTER)
.withSource(new CommandSource() {
@Override public void sendSystemMessage(Component message) { feedback.add(message); }
@Override public boolean acceptsSuccess() { return true; }
@Override public boolean acceptsFailure() { return true; }
@Override public boolean shouldInformAdmins() { return false; }
});
var parent = session.island("sanctuary");
for (String direction : new String[] {"north", "northeast"}) {
feedback.clear();
helper.assertTrue(dispatcher.execute("sanctuary expansion preview distance_preview sanctuary " + direction
+ " boreal 64 natural 2048", previewSource) == 1, "The optional distance must execute for " + direction);
helper.assertTrue(feedback.size() == 1 && feedback.getFirst().getContents() instanceof TranslatableContents,
"A successful distance preview must return its proposed coordinates");
var result = (TranslatableContents) feedback.getFirst().getContents();
helper.assertTrue(result.getKey().equals("sanctuary.expansion.preview"), "Distance preview must return the production preview response");
var args = result.getArgs();
int x = ((Number) args[1]).intValue(), z = ((Number) args[2]).intValue();
int dx = x - parent.centerX(), dz = z - parent.centerZ();
helper.assertTrue(Math.hypot(dx, dz) >= 2048,
"The command distance must be a minimum centre-to-centre distance, including diagonals");
helper.assertTrue(dz < 0 && (direction.equals("north") ? dx == 0 : dx == -dz),
"The distance preview must preserve the exact requested direction");
helper.assertTrue(session.islands().equals(before), "Distance preview must not reserve or activate an island");
}
for (String command : new String[] {
"sanctuary expansion quick north",
"sanctuary expansion quick north boreal 64 natural",
"sanctuary expansion quick north boreal 64 natural 2048",
"sanctuary expansion create distance_create sanctuary north boreal 64 natural",
"sanctuary expansion create distance_create sanctuary north boreal 64 natural 2048"}) {
var syntax = dispatcher.parse(command, operatorTwo);
helper.assertTrue(!syntax.getReader().canRead() && syntax.getExceptions().isEmpty() && syntax.getContext().getCommand() != null,
"Old and distance-aware create/quick syntax must remain executable: " + command);
}
helper.assertTrue(dispatcher.execute(preview + " 0", operatorTwo) == 1,
"An explicit zero distance must preserve automatic positioning");
for (String distance : new String[] {"-1", "100001"}) {
for (String command : new String[] {
preview,
"sanctuary expansion create distance_invalid sanctuary north boreal 64 natural",
"sanctuary expansion quick north boreal 64 natural"}) {
boolean distanceRejected = false;
try { dispatcher.execute(command + " " + distance, operatorTwo); }
catch (CommandSyntaxException expected) { distanceRejected = true; }
helper.assertTrue(distanceRejected && session.islands().equals(before),
"All expansion commands must reject an out-of-range distance without mutation");
}
}
// 65 is accepted by Brigadier's numeric bounds, then rejected by the production size contract.
int invalid = dispatcher.execute("sanctuary expansion create command_invalid sanctuary east boreal 65 natural", operatorTwo);
helper.assertTrue(invalid == 0, "An unsupported nominal diameter must fail in the production command handler");
helper.assertTrue(session.islands().equals(before), "Rejecting an unsupported diameter must preserve the registry");
boolean outsideRangeRejected = false;
try { dispatcher.execute("sanctuary expansion create command_out_of_range sanctuary east boreal 513 natural", console); }
catch (CommandSyntaxException expected) { outsideRangeRejected = true; }
helper.assertTrue(outsideRangeRejected && session.islands().equals(before),
"The parser must reject a diameter above 512 before changing the registry");
} catch (CommandSyntaxException exception) {
throw new IOException("Registered expansion command checks failed", exception);
}
}
private static void expectDenied(GameTestHelper helper, CommandDispatcher<CommandSourceStack> dispatcher,
CommandSourceStack source, String command, String label) {
var parsed = dispatcher.parse(command, source);
helper.assertTrue(parsed.getContext().getNodes().isEmpty() && parsed.getReader().canRead(),
"The " + label + " source must not parse the restricted expansion command tree");
boolean rejected = false;
try { dispatcher.execute(parsed); }
catch (CommandSyntaxException expected) { rejected = true; }
helper.assertTrue(rejected, "The " + label + " source must not execute an expansion command");
}
}
@@ -0,0 +1,533 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionAdmission;
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionJournal;
import fr.koka.sanctuary.expansion.ExpansionRuntime;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource;
/** Opt-in end-to-end exercise on a new laboratory world; never rewrites chunks or a personal world. */
public final class ExpansionWorldGameTests {
private static final boolean OPTIONAL_STRUCTURE_WITNESS = Boolean.getBoolean("sanctuary.test.optionalStructureWitness");
@GameTest(maxTicks = 6000)
public void islandsBranchPersistAndProtectVisitedChunks(GameTestHelper helper) {
helper.assertTrue(Boolean.getBoolean("sanctuary.test.expansion"), "Expansion GameTest must be explicitly enabled");
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator,
"Expansion GameTest must load the new laboratory preset");
if (Boolean.getBoolean("sanctuary.test.expansionReload")) {
verifyProcessReload(helper);
return;
}
new Scenario(helper).advance();
}
private static void verifyProcessReload(GameTestHelper helper) {
ServerLevel level = helper.getLevel();
var generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
var session = ExpansionRuntime.session(level);
Map<String, Object> report = new LinkedHashMap<>();
report.put("seed", level.getSeed());
report.put("process_id", ProcessHandle.current().pid());
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
report.put("scope", "A second Java process opens the same saved development chunks and expansion journal, and verifies a "
+ "gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; "
+ "the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client "
+ "world-selection test or a multiplayer/teleportation test.");
try {
JsonObject previous = JsonParser.parseString(Files.readString(diagnostic(level, false))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean(), "Reload requires a successful first integration run");
helper.assertTrue(previous.has("restart_snapshot"), "The first process must save a restart witness before shutting down");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(),
"The reload proof must run in another Java process");
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed(), "Reload must keep the original world seed");
JsonObject saved = previous.getAsJsonObject("restart_snapshot");
List<ExpansionIsland> blockers = ExpansionIsland.CODEC.listOf().parse(JsonOps.INSTANCE, saved.get("blocked_sites")).getOrThrow();
int savedRefusals = 0;
for (var site : blockers) {
helper.assertTrue(level.getChunkSource().chunkMap.getUpdatingChunkIfPresent(new ChunkPos(site.centerX() >> 4, site.centerZ() >> 4).pack()) == null,
"Reload collision proof must start with an unloaded saved witness");
try { ExpansionAdmission.verify(level, site); }
catch (ExpansionAdmission.OccupiedException expected) {
helper.assertTrue(expected.getMessage().contains("saved chunk"), "The reload proof must detect disk occupancy, not just a loaded holder");
savedRefusals++;
}
}
helper.assertTrue(savedRefusals == 8, "All eight first sites must remain unavailable on disk after restart");
report.put("saved_only_collision_refusals", savedRefusals);
helper.assertTrue(saved.get("world_directory").getAsString().equals(worldDirectory(level)),
"Reload must use exactly the same development save directory");
helper.assertTrue(session.islands().size() == 3 && session.islands().stream().allMatch(i -> i.status().equals("ready")),
"The loaded journal must contain exactly the ready root, alpha and beta");
helper.assertTrue(session.island("alpha").parent().equals("sanctuary") && session.island("beta").parent().equals("alpha"),
"Reload must preserve the expansion branch");
var encodedIslands = ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow();
helper.assertTrue(encodedIslands.equals(saved.get("islands")), "Every persisted region field must survive the process restart");
helper.assertTrue(sha256(Files.readString(journalFile(level))).equals(saved.get("journal_sha256").getAsString()),
"Loading a ready journal must preserve its exact saved contents");
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var liveCodec = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
helper.assertTrue(liveCodec.equals(saved.get("generator_codec")), "Generator settings and biome holder identities must remain stable");
var decoded = ChunkGenerator.CODEC.parse(ops, saved.get("generator_codec")).getOrThrow();
helper.assertTrue(decoded instanceof ExpansionChunkGenerator, "The saved generator codec must decode to the laboratory generator");
var restored = (ExpansionChunkGenerator) decoded;
restored.initialize(level, session.islands());
BlockPos witness = position(saved.getAsJsonArray("gold_witness"));
full(helper, level, witness.getX() >> 4, witness.getZ() >> 4);
helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK),
"The first process's saved gold block must exist before the reload test writes anything");
List<Map<String, Object>> samples = new ArrayList<>();
for (var element : saved.getAsJsonArray("samples")) {
JsonObject original = element.getAsJsonObject();
var island = session.island(original.get("id").getAsString());
BlockPos point = position(original.getAsJsonArray("point"));
Map<String, Object> current = sample(helper, generator, island, point);
helper.assertTrue(new GsonBuilder().create().toJsonTree(current).equals(original),
"Stored blocks, stored biomes and generated base column must survive restart: " + island.id());
helper.assertTrue(new GsonBuilder().create().toJsonTree(sample(helper, restored, island, point)).equals(original),
"Decoding the saved generator codec must reproduce its base column and climate: " + island.id());
samples.add(current);
}
boolean hasNativeStructure = previous.has("native_structure") && !previous.get("native_structure").isJsonNull();
if (hasNativeStructure) {
helper.assertTrue(!previous.has("native_structure_observed") || previous.get("native_structure_observed").getAsBoolean(),
"Stored native structure evidence must not contradict the observation result");
JsonObject nativeStructure = previous.getAsJsonObject("native_structure");
BlockPos containerPos = position(nativeStructure.getAsJsonArray("container"));
full(helper, level, containerPos.getX() >> 4, containerPos.getZ() >> 4);
helper.assertTrue(level.getBlockState(containerPos).toString().equals(nativeStructure.get("placed_block").getAsString()),
"The native structure's placed block state must survive restart");
helper.assertTrue(level.getBlockEntity(containerPos) instanceof RandomizableContainerBlockEntity,
"The native structure's placed container must be loaded from the saved chunk");
var container = (RandomizableContainerBlockEntity) level.getBlockEntity(containerPos);
helper.assertTrue(container.getLootTable() != null && container.getLootTable().identifier().toString()
.equals(nativeStructure.get("loot_table").getAsString()), "The native structure loot table must survive restart unconsumed");
report.put("native_container", coordinates(containerPos));
report.put("native_structure_observed", true);
report.put("native_container_verified", true);
report.put("native_structure_status", "placed_block_and_unconsumed_loot_verified_after_restart");
} else {
helper.assertTrue(OPTIONAL_STRUCTURE_WITNESS
&& previous.has("optional_native_structure_witness") && previous.get("optional_native_structure_witness").getAsBoolean()
&& previous.has("native_structure_observed") && !previous.get("native_structure_observed").getAsBoolean()
&& previous.has("native_structure_search_complete") && previous.get("native_structure_search_complete").getAsBoolean(),
"Missing native structure evidence is allowed only after an explicitly optional, completed first-process search");
report.put("native_structure_observed", false);
report.put("native_container_verified", false);
report.put("native_structure_status", "not_observed_after_complete_first_process_scan; no_native_structure_reload_proof");
report.put("native_structure_limitation", "No natural structure with placed vanilla loot was observed in the first process. "
+ "This reload verifies chunks, generator settings, the journal and expansion behavior, but supplies no positive structure proof.");
}
report.put("previous_process_id", previous.get("process_id").getAsLong());
report.put("world_directory", worldDirectory(level));
report.put("gold_witness", coordinates(witness));
report.put("samples", samples);
report.put("regions", session.islands().size());
report.put("saved_generator_codec_decoded", true);
var firstCandidate = session.candidate("after_reload", "sanctuary", "south", "boreal", 64, "natural");
var opened = session.create("after_reload", "sanctuary", "south", "boreal", 64, "natural");
helper.assertTrue(opened.centerZ() > firstCandidate.centerZ() && opened.centerX() == 0,
"Creation must skip the saved occupied first site and preserve the requested southern ray");
report.put("after_reload_expansion", List.of(opened.centerX(), opened.centerZ()));
awaitReloadExpansion(helper, session, blockers, witness, report);
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
try { writeDiagnostic(level, true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
SanctuaryMod.LOGGER.error("Expansion process reload scenario failed", exception);
helper.fail(exception.toString());
}
}
private static void awaitReloadExpansion(GameTestHelper helper, ExpansionRuntime.Session session,
List<ExpansionIsland> blockers, BlockPos gold, Map<String, Object> report) {
try {
helper.assertTrue(helper.getTick() < 5000, "Reload expansion preparation deadline: " + session.progress());
if (!session.island("after_reload").status().equals("ready")) {
helper.runAtTickTime(helper.getTick() + 10, () -> awaitReloadExpansion(helper, session, blockers, gold, report));
return;
}
verifyUntouchedVoid(helper, blockers);
helper.assertTrue(helper.getLevel().getBlockState(gold).is(Blocks.GOLD_BLOCK), "Opening farther away must preserve the saved player witness");
var landing = session.landing("after_reload");
helper.assertTrue(ExpansionRuntime.safeLanding(helper.getLevel(), landing), "The new region after restart must reach usable terrain");
report.put("after_reload_landing", coordinates(landing));
report.put("final_regions", session.islands().size());
report.put("passed", true);
writeDiagnostic(helper.getLevel(), true, report);
helper.succeed();
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
try { writeDiagnostic(helper.getLevel(), true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
helper.fail(exception.toString());
}
}
private static void verifyUntouchedVoid(GameTestHelper helper, List<ExpansionIsland> blockers) {
var generator = (ExpansionChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
for (var site : blockers) {
var chunk = full(helper, helper.getLevel(), site.centerX() >> 4, site.centerZ() >> 4);
helper.assertTrue(generator.islandAt(site.centerX(), site.centerZ()) == null
&& Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()),
"An occupied saved void chunk must remain unchanged after farther placement: " + site.direction());
}
}
private static final class Scenario {
private final GameTestHelper helper;
private final ServerLevel level;
private final ExpansionChunkGenerator generator;
private final ExpansionRuntime.Session session;
private final Map<String, Object> report = new LinkedHashMap<>();
private final List<Map<String, Object>> observedIslands = new ArrayList<>();
private final Map<String, BlockPos> samplePoints = new LinkedHashMap<>();
private final List<ChunkPos> structureSearch = new ArrayList<>();
private final long started = System.nanoTime();
private ExpansionIsland alpha;
private ExpansionIsland beta;
private BlockPos marker;
private BlockState previousMarker;
private int phase;
private int structureIndex;
private int validStarts;
private final List<ExpansionIsland> blockedSites = new ArrayList<>();
Scenario(GameTestHelper helper) {
this.helper = helper;
this.level = helper.getLevel();
this.generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
this.session = ExpansionRuntime.session(level);
report.put("seed", level.getSeed());
report.put("process_id", ProcessHandle.current().pid());
report.put("generator_version", generator.rootGeneration());
report.put("initial_diameter", generator.initialDiameter());
report.put("islands", observedIslands);
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
report.put("native_structure_observed", false);
report.put("native_structure_search_complete", false);
report.put("native_structure_status", "not_searched");
report.put("scope", "Fresh versioned Sanctuary development world; real FULL chunks, a natural native-structure search, asynchronous activation, "
+ "branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. "
+ "The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated. "
+ (OPTIONAL_STRUCTURE_WITNESS ? "The native structure witness is explicitly optional; an absent witness is reported as missing evidence, never as a successful structures test."
: "A naturally accepted structure with placed vanilla loot is required."));
}
void advance() {
try {
helper.assertTrue(helper.getTick() < 5800, "Expansion scenario deadline: " + session.progress());
switch (phase) {
case 0 -> start();
case 1 -> {
if (!session.island("alpha").status().equals("ready")) break;
helper.assertTrue(session.island("alpha").equals(alpha.ready()), "Activation must only advance the immutable island state");
observeIsland("alpha", Set.of("jungle", "sparse_jungle", "bamboo_jungle", "mangrove_swamp"));
verifyMarker();
beta = session.create("beta", "alpha", "east", "arid", 64, "natural", 2048);
helper.assertTrue(beta.parent().equals("alpha") && beta.centerX() > alpha.centerX()
&& beta.centerZ() == alpha.centerZ(), "A child must branch east from alpha, not from world origin");
helper.assertTrue(beta.centerX() - alpha.centerX() >= 2048, "The requested minimum distance must be honoured from the chosen parent");
verifyJournal();
phase = 2;
}
case 2 -> {
if (!session.island("beta").status().equals("ready")) break;
helper.assertTrue(session.island("beta").equals(beta.ready()), "Branch activation must preserve its planned identity");
observeIsland("beta", Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands"));
verifyMarker();
verifyJournal();
verifyUntouchedVoid(helper, blockedSites);
prepareStructureSearch();
phase = 3;
}
case 3 -> {
if (observeNativeStructure()) {
verifyMarker();
verifyJournal();
level.setBlock(marker, previousMarker, 3);
prepareRestartWitness();
report.put("islands", observedIslands);
report.put("native_structure_starts_seen", validStarts);
report.put("structure_start_chunks_inspected", structureIndex);
report.put("ticks", helper.getTick());
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000.0);
report.put("passed", true);
writeReport();
helper.succeed();
return;
}
}
default -> throw new IllegalStateException("Unknown expansion test phase");
}
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Expansion scenario phase {}: {}", phase, session.progress());
helper.runAtTickTime(helper.getTick() + 10, this::advance);
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
report.put("phase", phase);
report.put("runtime_progress", session.progress());
try { writeReport(); } catch (IOException failure) { exception.addSuppressed(failure); }
SanctuaryMod.LOGGER.error("Expansion integration scenario failed", exception);
helper.fail(exception.toString());
}
}
private void start() throws IOException {
helper.assertTrue(session.islands().size() == 1, "The test must start from a new expansion journal");
helper.assertTrue(level.structureManager().shouldGenerateStructures(), "The laboratory GameTest must enable actual vanilla structures in WorldOptions");
ExpansionCommandChecks.verify(helper);
report.put("registered_command_permission_checks", true);
helper.assertTrue(generator.source().possibleBiomes().size() >= ExpansionBiomeSource.REQUIRED_BIOMES.size(),
"Future biome holders must be declared before the first expansion");
BlockPos landing = session.landing("sanctuary");
helper.assertTrue(ExpansionRuntime.safeLanding(level, landing), "The initial laboratory island must provide a real safe landing");
full(landing.getX() >> 4, landing.getZ() >> 4);
marker = landing.above(6);
previousMarker = level.getBlockState(marker);
helper.assertTrue(level.setBlock(marker, Blocks.DIAMOND_BLOCK.defaultBlockState(), 3), "Place the parent player-edit witness");
report.put("parent_witness", coordinates(marker));
report.put("root_landing", coordinates(landing));
samplePoints.put("sanctuary", landing);
for (String direction : ExpansionIsland.DIRECTIONS) {
var site = session.candidate("blocked_" + direction, "sanctuary", direction, "boreal", 64, "natural");
var chunk = full(site.centerX() >> 4, site.centerZ() >> 4);
helper.assertTrue(Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()), "The first site must initially contain only real generated void");
blockedSites.add(site);
}
int occupied = 0;
for (var site : blockedSites) {
try { ExpansionAdmission.verify(level, site); }
catch (ExpansionAdmission.OccupiedException expected) { occupied++; }
}
helper.assertTrue(occupied == 8, "Reproduce the user failure with all eight first directions occupied");
report.put("initial_occupied_directions", occupied);
var nearestNorth = session.candidate("alpha", "sanctuary", "north", "tropical", 64, "natural");
alpha = session.create("alpha", "sanctuary", "north", "tropical", 64, "natural");
helper.assertTrue(alpha.status().equals("reserved") && alpha.centerZ() < 0 && alpha.centerX() == 0,
"North placement and the explicitly chosen tropical climate must remain independent");
helper.assertTrue(alpha.centerZ() < nearestNorth.centerZ(), "Automatic placement must skip occupied chunks and continue farther north");
helper.assertTrue(alpha.equals(session.create("alpha", "sanctuary", "north", "tropical", 64, "natural"))
&& session.islands().size() == 2, "Repeating a matching request must not create another region or change its identity");
boolean conflict = false;
try { session.create("alpha", "sanctuary", "north", "arid", 64, "natural"); }
catch (IllegalArgumentException expected) { conflict = expected.getMessage().equals("id_conflict"); }
helper.assertTrue(conflict, "A conflicting duplicate island id must be refused");
verifyJournal();
phase = 1;
}
private void observeIsland(String id, Set<String> expectedSurfaceBiomes) {
ExpansionIsland island = session.island(id);
BlockPos landing = session.landing(id);
helper.assertTrue(island.contains(landing.getX(), landing.getZ()) && ExpansionRuntime.safeLanding(level, landing),
"READY must offer actual safe terrain inside " + id);
LevelChunk chunk = full(landing.getX() >> 4, landing.getZ() >> 4);
helper.assertTrue(Arrays.stream(chunk.getSections()).anyMatch(section -> !section.hasOnlyAir()), "READY cannot be only a registry entry: " + id);
// The 240 sample is the stored upper climate in a FULL chunk, independent of which safe ledge was found.
String climateBiome = chunk.getNoiseBiome(landing.getX() >> 2, 60, landing.getZ() >> 2).unwrapKey().orElseThrow().identifier().toString();
helper.assertTrue(expectedSurfaceBiomes.contains(climateBiome.substring("minecraft:".length())),
"The FULL chunk must contain its requested upper climate: " + id + " / " + climateBiome);
String landingBiome = level.getBiome(landing).unwrapKey().orElseThrow().identifier().toString();
samplePoints.put(id, landing);
observedIslands.add(Map.of("id", id, "parent", island.parent(), "climate", island.climate(),
"centre", List.of(island.centerX(), island.centerZ()), "diameter", island.diameter(),
"landing", coordinates(landing), "landing_biome", landingBiome, "upper_biome", climateBiome));
}
private void verifyMarker() {
helper.assertTrue(level.getBlockState(marker).is(Blocks.DIAMOND_BLOCK), "An expansion must preserve the parent player-edit witness");
}
private void verifyJournal() throws IOException {
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory());
var reloaded = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
helper.assertTrue(reloaded.islands().equals(session.islands()), "Re-reading the saved journal must preserve all region identities and states");
report.put("journal_reloaded_regions", reloaded.islands().size());
}
private void prepareRestartWitness() throws IOException {
BlockPos gold = samplePoints.get("beta").above(6);
while (!level.getBlockState(gold).isAir() && gold.getY() < level.getMaxY() - 1) gold = gold.above();
helper.assertTrue(gold.getY() < level.getMaxY() - 1 && level.getBlockState(gold).isAir(), "The restart witness needs empty space above beta");
helper.assertTrue(level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3), "Write the restart witness in the first process");
var snapshot = new LinkedHashMap<String, Object>();
snapshot.put("world_directory", worldDirectory(level));
snapshot.put("gold_witness", coordinates(gold));
snapshot.put("journal_sha256", sha256(Files.readString(journalFile(level))));
snapshot.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
snapshot.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
snapshot.put("blocked_sites", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, blockedSites).getOrThrow());
List<Map<String, Object>> samples = new ArrayList<>();
for (var island : session.islands()) samples.add(sample(helper, generator, island, samplePoints.get(island.id())));
snapshot.put("samples", samples);
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush the first process's world and marker before writing restart evidence");
snapshot.put("world_flushed_before_shutdown", true);
report.put("restart_snapshot", snapshot);
}
private void prepareStructureSearch() {
ExpansionIsland root = session.island("sanctuary");
int limit = (root.terrainRadius() + 15) / 16;
// STARTS only, centre outwards. Only an accepted structure's region is later requested at FULL.
for (int ring = 0; ring <= limit; ring++) for (int x = -ring; x <= ring; x++) for (int z = -ring; z <= ring; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) == ring && root.contains(x * 16 + 8, z * 16 + 8)) structureSearch.add(new ChunkPos(x, z));
}
report.put("structure_search_candidate_chunks", structureSearch.size());
report.put("native_structure_status", "search_in_progress");
}
private boolean observeNativeStructure() {
int end = Math.min(structureSearch.size(), structureIndex + 16);
while (structureIndex < end) {
ChunkPos position = structureSearch.get(structureIndex++);
var chunk = level.getChunkSource().getChunk(position.x(), position.z(), ChunkStatus.STRUCTURE_STARTS, true);
helper.assertTrue(chunk != null, "A structure-start request must complete");
for (StructureStart start : chunk.getAllStarts().values()) {
if (!start.isValid()) continue;
validStarts++;
String name = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getKey(start.getStructure()).toString();
if (!name.startsWith("minecraft:")) continue;
Map<String, Object> evidence = materializeAndFindLoot(start, name);
if (!evidence.isEmpty()) {
report.put("native_structure", evidence);
report.put("native_structure_observed", true);
report.put("native_structure_status", "natural_start_with_placed_vanilla_loot_observed");
report.put("native_structure_search_complete", structureIndex == structureSearch.size());
return true;
}
}
}
report.put("native_structure_starts_seen", validStarts);
report.put("structure_start_chunks_inspected", structureIndex);
if (structureIndex == structureSearch.size()) {
report.put("native_structure_search_complete", true);
report.put("native_structure_status", "not_observed_after_complete_scan");
report.put("native_structure_limitation", "No naturally accepted native structure with placed vanilla loot was observed after "
+ structureIndex + " root-island start chunks and " + validStarts + " valid starts. "
+ "This is missing engine evidence for structures, not a positive generation or loot result.");
if (OPTIONAL_STRUCTURE_WITNESS) return true;
}
helper.assertTrue(structureIndex < structureSearch.size(), "No naturally accepted native structure with placed vanilla loot was observed; "
+ validStarts + " valid starts. This is a missing engine proof, not a successful structures test.");
return false;
}
private Map<String, Object> materializeAndFindLoot(StructureStart start, String name) {
var bounds = start.getBoundingBox();
List<LevelChunk> chunks = new ArrayList<>();
for (int x = bounds.minX() >> 4; x <= bounds.maxX() >> 4; x++) {
for (int z = bounds.minZ() >> 4; z <= bounds.maxZ() >> 4; z++) chunks.add(full(x, z));
}
for (LevelChunk chunk : chunks) {
for (var entry : chunk.getBlockEntities().entrySet()) {
if (!(entry.getValue() instanceof RandomizableContainerBlockEntity container) || container.getLootTable() == null) continue;
BlockPos pos = entry.getKey();
if (start.getPieces().stream().noneMatch(piece -> piece.getBoundingBox().isInside(pos))) continue;
String loot = container.getLootTable().identifier().toString();
if (!loot.startsWith("minecraft:")) continue;
return Map.of("structure", name, "loot_table", loot, "container", coordinates(pos),
"placed_block", level.getBlockState(pos).toString(), "full_chunks", chunks.size(),
"mode", "natural candidate accepted by production ExpansionStructures, native decoration");
}
}
return Map.of();
}
private LevelChunk full(int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Expected real FULL chunk at " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private void writeReport() throws IOException {
writeDiagnostic(level, false, report);
}
}
private static Map<String, Object> sample(GameTestHelper helper, ExpansionChunkGenerator generator, ExpansionIsland island, BlockPos point) {
ServerLevel level = helper.getLevel();
LevelChunk chunk = full(helper, level, point.getX() >> 4, point.getZ() >> 4);
var random = generator.randomState(island);
var base = generator.getBaseColumn(point.getX(), point.getZ(), level, random);
StringBuilder storedBlocks = new StringBuilder(), baseBlocks = new StringBuilder(), biomes = new StringBuilder(), resolvedBiomes = new StringBuilder();
var resolver = generator.source().createUncachedResolver(random);
for (int y = level.getMinY(); y <= level.getMaxY(); y++) {
storedBlocks.append(chunk.getBlockState(new BlockPos(point.getX(), y, point.getZ()))).append('\n');
baseBlocks.append(base.getBlock(y)).append('\n');
if ((y & 3) == 0) {
biomes.append(chunk.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
resolvedBiomes.append(resolver.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
}
}
return Map.of("id", island.id(), "point", coordinates(point), "stored_column_sha256", sha256(storedBlocks.toString()),
"base_column_sha256", sha256(baseBlocks.toString()), "stored_biomes_sha256", sha256(biomes.toString()),
"resolved_biomes_sha256", sha256(resolvedBiomes.toString()), "base_height",
generator.getBaseHeight(point.getX(), point.getZ(), Heightmap.Types.WORLD_SURFACE_WG, level, random));
}
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Expected saved FULL chunk at " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private static String sha256(String text) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
}
private static Path journalFile(ServerLevel level) {
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory()).resolve(ExpansionJournal.FILE_NAME);
}
private static String worldDirectory(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString(); }
private static Path diagnostic(ServerLevel level, boolean reload) {
return Path.of("diagnostics", (Boolean.getBoolean("sanctuary.test.unified") ? "expansion-alpha13-" : "expansion-alpha12-1-") + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
}
private static void writeDiagnostic(ServerLevel level, boolean reload, Map<String, Object> report) throws IOException {
Path output = diagnostic(level, reload);
Files.createDirectories(output.getParent());
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
}
private static BlockPos position(JsonArray coordinates) { return new BlockPos(coordinates.get(0).getAsInt(), coordinates.get(1).getAsInt(), coordinates.get(2).getAsInt()); }
private static List<Integer> coordinates(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.ExactInterpolationChecks;
import fr.koka.sanctuary.worldgen.ExactInterpolationSampler;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Compares production against the frozen alpha.10 implementation; no chunks are created. */
final class PopulationDensityEquivalenceDiagnostics {
private record Pair(int players, long seed, DensitySampler reference, DensitySampler actual) {}
static void verify(GameTestHelper helper) throws Exception {
Map<String, Object> adversarial = ExactInterpolationChecks.verify();
var registries = helper.getLevel().registryAccess();
var noises = registries.lookupOrThrow(Registries.NOISE);
var densities = registries.lookupOrThrow(Registries.DENSITY_FUNCTION);
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
List<Map<String, Object>> reports = new ArrayList<>();
List<Pair> pairs = new ArrayList<>();
for (int players : new int[]{5, 10, 20, 100}) {
var key = SanctuaryMod.id("population_" + players);
var registered = densities.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, key)).value();
helper.assertTrue(registered instanceof PopulationIslandDensity, "Compare the real registered Population density");
var density = (PopulationIslandDensity) registered;
helper.assertTrue(ExactInterpolationSampler.supportsTerrain(density.terrain()),
"The shipped coordinate-only terrain must actually use the exact cache, not the fallback");
var reference = new Alpha10PopulationDensityReference(density.terrain(), density.distortion(), density.sculpt(),
density.detail(), density.underside(), density.riftWarp(), density.riftDetail(), density.capacity());
for (long seed : new long[]{0, 42, 8675309}) {
var state = RandomState.create(noises, seed,
settings.getOrThrow(ResourceKey.create(Registries.NOISE_SETTINGS, key)).value());
var pair = new Pair(players, seed, state.getSampler(reference), state.getSampler(density));
pairs.add(pair);
SamplerContext context = SamplerContext.builder().enableCaches().build();
long comparisons = 0, digest = 0xcbf29ce484222325L;
for (var volume : volumes(IslandCapacity.fromPlayers(players))) {
DensityBuffer expected = DensityBuffer.createUnpooled(volume.size());
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
pair.reference().sampleVolume(SamplerContext.EMPTY_UNCACHED, expected, volume);
pair.actual().sampleVolume(context, actual, volume);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
int index = volume.indexUnchecked(x, y, z);
int wx = volume.blockX(x), wy = volume.blockY(y), wz = volume.blockZ(z);
same(pair, expected.get(index), actual.get(index), wx, wy, wz, "bulk");
same(pair, expected.get(index), pair.actual().sampleValue(context, wx, wy, wz), wx, wy, wz, "scalar after bulk");
comparisons += 2;
digest = (digest ^ Float.floatToRawIntBits(expected.get(index))) * 0x100000001b3L;
}
}
Random order = new Random(seed ^ players);
int limit = density.capacity().terrainLimit();
for (int i = 0; i < 512; i++) {
int x = order.nextInt(limit * 2 + 33) - limit - 16;
int y = order.nextInt(400) - 8;
int z = order.nextInt(limit * 2 + 33) - limit - 16;
float expected = pair.reference().sampleValue(SamplerContext.EMPTY_UNCACHED, x, y, z);
same(pair, expected, pair.actual().sampleValue(SamplerContext.EMPTY_UNCACHED, x, y, z), x, y, z, "uncached context");
same(pair, expected, pair.actual().sampleValue(context, x, y, z), x, y, z, "context switch");
comparisons += 2;
}
reports.add(Map.of("players", players, "seed", seed, "bitwise_comparisons", comparisons,
"reference_density_digest", Long.toUnsignedString(digest, 16), "optimized_path_supported", true));
SanctuaryMod.LOGGER.info("Exact density equivalence: players {}, seed {}, {} bitwise comparisons", players, seed, comparisons);
}
}
long concurrentComparisons = 0;
try (var executor = Executors.newFixedThreadPool(3)) {
List<Callable<Long>> tasks = new ArrayList<>();
for (int worker = 0; worker < 3; worker++) {
int id = worker;
tasks.add(() -> {
long count = 0;
SamplerContext local = SamplerContext.builder().enableCaches().build();
// All workers share the same samplers and alternate worlds/seeds.
// Only their scratch buffers and sampler contexts are thread-local.
for (int round = 0; round < 3; round++) for (var pair : pairs) {
var volume = new DensityVolume(9, 17, 7, -13 + id, 89 + round, -7 - id);
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
pair.actual().sampleVolume(local, actual, volume);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
int wx = volume.blockX(x), wy = volume.blockY(y), wz = volume.blockZ(z);
same(pair, pair.reference().sampleValue(local, wx, wy, wz),
actual.get(volume.indexUnchecked(x, y, z)), wx, wy, wz, "concurrent bulk");
count++;
}
}
return count;
});
}
for (var result : executor.invokeAll(tasks)) concurrentComparisons += result.get();
}
Path report = Path.of("diagnostics", "population-alpha10-density-equivalence.json");
Files.createDirectories(report.getParent());
Files.writeString(report, new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"reference_commit", "a26b72f", "reference_source_sha256", "fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1",
"comparison", "Float.floatToRawIntBits; no tolerance", "profiles", reports, "adversarial", adversarial,
"concurrent_comparisons", concurrentComparisons, "concurrent_workers", 3, "passed", true,
"scope", "Real compiled scalar/bulk densities, 4 capacities x 3 seeds (5/20/100 compatibility and new10 using the same parameterized algorithm), signed coordinates, region/coast/height boundaries, context switches and concurrency; no block changes")) + "\n");
}
private static List<DensityVolume> volumes(IslandCapacity capacity) {
int limit = capacity.terrainLimit(), middle = (int) capacity.radius() / 2;
return List.of(
new DensityVolume(16, 384, 16, -8, 0, -8),
new DensityVolume(19, 11, 17, -263, 61, -259),
new DensityVolume(17, 17, 17, 249, 124, 249),
new DensityVolume(13, 21, 11, 761, 167, -9),
new DensityVolume(13, 21, 11, -775, 103, 503),
new DensityVolume(17, 49, 5, limit - 12, 96, -2),
new DensityVolume(17, 49, 5, -limit - 4, 96, -2),
new DensityVolume(12, 33, 12, middle - 7, 90, -middle / 2 - 5),
new DensityVolume(5, 13, 5, -2, -4, -2),
new DensityVolume(5, 13, 5, -2, 378, -2),
new DensityVolume(9, 13, 7, -257, 61, -9, 3, 5, 2));
}
private static void same(Pair pair, float expected, float actual, int x, int y, int z, String phase) {
if (Float.floatToRawIntBits(expected) != Float.floatToRawIntBits(actual))
throw new AssertionError("Density changed from alpha10 in " + phase + ": players=" + pair.players() + ", seed=" + pair.seed()
+ ", position=" + x + "," + y + "," + z + ", expected=" + Float.toHexString(expected) + ", actual=" + Float.toHexString(actual));
}
}
@@ -37,9 +37,11 @@ public final class PopulationDiagnostics {
static void verifyLegacyIsolation(GameTestHelper helper) {
var level = helper.getLevel();
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
var binding = binding(level);
var generator = binding.generator();
var capacity = IslandCapacity.forGenerator(generator);
helper.assertTrue(capacity != null && capacity.players() == Integer.getInteger("sanctuary.test.players", 20),
helper.assertTrue(capacity != null && capacity.players() == (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator unified
? unified.capacity().players() : Integer.getInteger("sanctuary.test.players", 10)),
"The engine must create the requested production world preset");
helper.assertFalse(HydrologyRuntime.enabled(generator) || NaturalHydrologyRuntime.enabled(generator)
|| LayeredHydrologyRuntime.enabled(generator) || WoodlandHydrologyRuntime.enabled(generator)
@@ -56,27 +58,37 @@ public final class PopulationDiagnostics {
helper.assertFalse(new PopulationDecorationsFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO),
"New cave decorations must refuse older saved worlds");
}
helper.assertTrue(generator.getBiomeSource() instanceof PopulationIslandBiomeSource,
"The biome palette must use the new versioned resource family");
helper.assertTrue(generator.getBiomeSource().possibleBiomes().size() == 10
&& generator.getBiomeSource().possibleBiomes().stream().allMatch(b -> b.getRegisteredName().startsWith("sanctuary:population_")),
"All ten layers of the ecology must remain available at each capacity");
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
&& generator.getBiomeSource().possibleBiomes().size() == 44,
"The unified generator must retain Population's ten layers and the 34 native expansion biomes");
helper.assertTrue(binding.random() != level.getChunkSource().randomState(),
"The unified starter must use its real terrain state, never the expansion host fallback");
} else {
helper.assertTrue(generator.getBiomeSource() instanceof PopulationIslandBiomeSource,
"The biome palette must use the new versioned resource family");
helper.assertTrue(generator.getBiomeSource().possibleBiomes().size() == 10
&& generator.getBiomeSource().possibleBiomes().stream().allMatch(b -> b.getRegisteredName().startsWith("sanctuary:population_")),
"All ten layers of the ecology must remain available at each capacity");
}
}
static void verifyDensity(GameTestHelper helper) throws IOException {
var level = helper.getLevel();
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
var binding = binding(level);
var generator = binding.generator();
var capacity = IslandCapacity.forGenerator(generator);
var density = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_" + capacity.players()))).value();
helper.assertTrue(density instanceof PopulationIslandDensity, "Inspect the actual compiled population density");
var island = (PopulationIslandDensity) density;
helper.assertTrue(island.capacity() == capacity, "Noise settings and density must agree on saved capacity");
var random = level.getChunkSource().randomState();
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), level.getSeed(), generator.generatorSettings().value());
var random = binding.random();
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), random.seed(), generator.generatorSettings().value());
int step = Math.max(8, (int) Math.ceil(capacity.terrainLimit() / 32.0));
int extent = (int) Math.ceil(capacity.terrainLimit() / (double) step) * step;
int solidColumns = 0, outerColumns = 0, emptyColumns = 0;
List<int[]> landCoordinates = new ArrayList<>();
Set<Integer> bottomHeights = new TreeSet<>();
StringBuilder map = new StringBuilder("x,z,bottom_y,top_y\n");
for (int x = -extent; x <= extent; x += step) for (int z = -extent; z <= extent; z += step) {
@@ -93,6 +105,7 @@ public final class PopulationDiagnostics {
}
if (top >= 0) {
solidColumns++;
landCoordinates.add(new int[]{x, z});
bottomHeights.add(bottom);
if (Math.hypot(x, z) > IslandShape.TERRAIN_LIMIT + 16) outerColumns++;
helper.assertTrue(Math.hypot(x, z) < capacity.terrainLimit(), "Terrain stays inside its finite declared boundary");
@@ -101,8 +114,20 @@ public final class PopulationDiagnostics {
}
helper.assertTrue(solidColumns > 100 && emptyColumns > 100 && bottomHeights.size() >= 4,
"The sampled island must contain natural masses, holes and a varied underside");
helper.assertTrue(capacity.players() == 5 ? outerColumns == 0 : outerColumns > 100,
"The larger capacities must actually contain land beyond the old island boundary");
int additionalColumns = 0;
if (capacity == IslandCapacity.TEN) {
var small = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_5"))).value();
for (int[] coordinate : landCoordinates) {
boolean presentInSmall = false;
for (int y = 8; y < 384 && !presentInSmall; y += 8)
presentInSmall = random.sampleBlockValueUncached(small, coordinate[0], y, coordinate[1]) > 0;
if (!presentInSmall) additionalColumns++;
}
helper.assertTrue(additionalColumns > 0,
"Large must add real land columns compared with Small on the same seed and sample grid");
} else helper.assertTrue(capacity.players() == 5 ? outerColumns == 0 : outerColumns > 100,
"The historical large capacities must contain land beyond the old island boundary");
Path directory = Path.of("diagnostics");
Files.createDirectories(directory);
String prefix = "population-" + capacity.players() + "-seed-" + level.getSeed();
@@ -110,14 +135,15 @@ public final class PopulationDiagnostics {
Files.writeString(directory.resolve(prefix + "-density.json"), json(Map.of("players", capacity.players(),
"seed", level.getSeed(), "step", step, "solid_columns", solidColumns, "empty_columns", emptyColumns,
"solid_columns_beyond_old_boundary", outerColumns, "underside_heights", bottomHeights,
"scope", "sparse actual density samples; no resource extrapolation", "passed", true)));
"scope", "sparse actual density samples; no resource extrapolation", "passed", true,
"additional_columns_vs_small", capacity == IslandCapacity.TEN ? additionalColumns : -1)));
SanctuaryMod.LOGGER.info("Capacity {} density seed {}: {} land columns, {} beyond the old envelope",
capacity.players(), level.getSeed(), solidColumns, outerColumns);
}
static void verifyExterior(GameTestHelper helper) {
var level = helper.getLevel();
var capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator());
var capacity = IslandCapacity.forGenerator(binding(level).generator());
int edge = Math.floorDiv(capacity.terrainLimit() + 48, 16) + 1;
for (int[] point : new int[][]{{edge,0},{-edge-1,0},{0,edge},{0,-edge-1},{256,0},{-257,0}}) {
var chunk = full(helper, level, point[0], point[1]);
@@ -153,6 +179,7 @@ public final class PopulationDiagnostics {
final GameTestHelper helper;
final ServerLevel level;
final NoiseBasedChunkGenerator generator;
final RandomState random;
final IslandCapacity capacity;
final List<PopulationHydrologyRuntime.RegionPlan> regions = new ArrayList<>();
final Map<Long, LevelChunk> chunks = new TreeMap<>();
@@ -175,7 +202,9 @@ public final class PopulationDiagnostics {
Context(GameTestHelper helper) {
this.helper = helper; level = helper.getLevel();
generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
var binding = binding(level);
generator = binding.generator();
random = binding.random();
capacity = IslandCapacity.forGenerator(generator);
}
@@ -192,7 +221,7 @@ public final class PopulationDiagnostics {
Set<Long> requested = new TreeSet<>();
for (var target : targets) {
SanctuaryMod.LOGGER.info("Capacity {}: inspecting hydrology in region {}", capacity.players(), target);
var region = PopulationHydrologyRuntime.regionPlan(generator, level.getChunkSource().randomState(), target);
var region = PopulationHydrologyRuntime.regionPlan(generator, random, target);
regions.add(region);
var plan = region.water();
checkOwnership(region);
@@ -242,7 +271,7 @@ public final class PopulationDiagnostics {
for (int dx = -144; dx <= 144; dx += 48) for (int dz = -144; dz <= 144; dz += 48) {
int x = ox+dx, z=oz+dz;
int top = generator.getBaseHeight(x,z,net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE,
level,level.getChunkSource().randomState());
level,random);
if (top > highest) { highest=top; bestX=x; bestZ=z; }
}
helper.assertTrue(highest > 48, "Every inspected region must contain real natural terrain");
@@ -253,7 +282,7 @@ public final class PopulationDiagnostics {
"ecology_patch_x",bestX,"ecology_patch_z",bestZ)));
}
helper.assertTrue(!expectedWater.isEmpty(), "The test must inspect retained production water");
if (capacity.players() > 5) helper.assertTrue(regions.stream().skip(1).anyMatch(r -> !r.water().cells().isEmpty()),
if (capacity.players() > 10) helper.assertTrue(regions.stream().skip(1).anyMatch(r -> !r.water().cells().isEmpty()),
"Hydrology must occur on newly available terrain, not only around the old central island");
SanctuaryMod.LOGGER.info("Capacity {}: preparing {} bounded FULL chunks",capacity.players(),requested.size());
for (long packed : requested) {
@@ -279,7 +308,7 @@ public final class PopulationDiagnostics {
"Hydrology must never be truncated at or written across region ownership: "+cell);
helper.assertTrue(ids.contains(cell.featureId()),"Each retained cell needs its complete owning feature");
for(int y=cell.bedY()-cell.sedimentDepth()-1;y<=cell.bedY();y++)
helper.assertTrue(level.getChunkSource().randomState().sampleBlockValueUncached(
helper.assertTrue(random.sampleBlockValueUncached(
generator.generatorSettings().value().noiseRouter().finalDensity(),cell.x(),y,cell.z())>0,
"Regional translation must retain the real natural support below sediment: "+cell);
}
@@ -373,7 +402,7 @@ public final class PopulationDiagnostics {
}
float natural(BlockPos p) {
return level.getChunkSource().randomState().sampleBlockValueUncached(
return random.sampleBlockValueUncached(
generator.generatorSettings().value().noiseRouter().finalDensity(), p.getX(), p.getY(), p.getZ());
}
@@ -413,7 +442,7 @@ public final class PopulationDiagnostics {
helper.assertTrue(natural(p) > 0 && actual.isCollisionShapeFullBlock(level, p),
"Every sediment layer and its two natural supports must remain full after generation and ticks: " + p + "=" + actual);
if (d >= cell.sedimentDepth()) continue;
BlockState expected = PopulationMaterials.sedimentBlock(cell, level.getSeed(), d);
BlockState expected = PopulationMaterials.sedimentBlock(cell, random.seed(), d);
boolean ore = actual.is(Blocks.COAL_ORE) || actual.is(Blocks.DEEPSLATE_COAL_ORE)
|| actual.is(Blocks.IRON_ORE) || actual.is(Blocks.DEEPSLATE_IRON_ORE)
|| actual.is(Blocks.COPPER_ORE) || actual.is(Blocks.DEEPSLATE_COPPER_ORE)
@@ -560,10 +589,15 @@ public final class PopulationDiagnostics {
Files.createDirectories(output.getParent());
var report=new LinkedHashMap<String,Object>();
report.put("players",capacity.players());report.put("seed",level.getSeed());
report.put("terrain_seed", random.seed());
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator unified) {
report.put("generation", 13); report.put("diameter", unified.initialDiameter());
report.put("rejected_decoration_writes", unified.rejectedDecorationWrites());
}
report.put("mod_version",FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString());
report.put("settings",generator.generatorSettings().getRegisteredName());
report.put("regions_at_initial_spawn",regionsAtInitialSpawn);
report.put("regions_cached_after_inspection",PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState()));
report.put("regions_cached_after_inspection",PopulationHydrologyRuntime.cachedRegionCount(random));
report.put("regions",regionReports);report.put("inspected_full_chunks",chunks.size());
report.put("retained_water_blocks",expectedWater.size());report.put("tested_springs",springs.size());
report.put("tested_spills",spills.size());report.put("fluid_ticks",elapsed);
@@ -582,6 +616,10 @@ public final class PopulationDiagnostics {
}
}
private static PopulationGenerationContext.Binding binding(ServerLevel level) {
return Objects.requireNonNull(PopulationGenerationContext.of(level), "This diagnostic requires Population or unified Sanctuary generation");
}
private static String json(Object value) {return new GsonBuilder().setPrettyPrinting().create().toJson(value)+"\n";}
private static BlockPos pos(PopulationHydrology.Position p) {return new BlockPos(p.x(),p.y(),p.z());}
private static BlockPos lavaPos(PopulationLavaDeposit.Position p) {return new BlockPos(p.x(),p.y(),p.z());}
@@ -1,12 +1,16 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.RegistryOps;
import net.minecraft.tags.WorldPresetTags;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.WorldDimensions;
import net.minecraft.world.level.levelgen.WorldGenSettings;
@@ -19,6 +23,8 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** Uses the real 26.3 saved-data writer and reader; never opens the user's saves. */
final class PopulationPersistenceDiagnostics {
@@ -28,14 +34,28 @@ final class PopulationPersistenceDiagnostics {
var presets = access.lookupOrThrow(Registries.WORLD_PRESET);
var standard = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
SanctuaryMod.id("sanctuary"))).value();
helper.assertTrue(IslandCapacity.forGenerator(standard.createWorldDimensions().overworld()) == IslandCapacity.TWENTY,
"The public Sanctuary preset defaults to 20 planned players");
helper.assertTrue(standard.createWorldDimensions().overworld() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified && unified.initialDiameter() == 724,
"The public Sanctuary preset defaults to the unified medium geometry");
var visible = presets.getOrThrow(WorldPresetTags.NORMAL).stream().map(holder -> holder.getRegisteredName())
.filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet());
helper.assertTrue(visible.equals(Set.of("sanctuary:sanctuary")),
"Only Sanctuary is public; saved historical presets remain registered");
helper.assertTrue(presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab")))
.value().createWorldDimensions().overworld() instanceof fr.koka.sanctuary.expansion.ExpansionChunkGenerator,
"The laboratory must use its own codec, without changing the existing presets");
var oldDensity = (PopulationIslandDensity) access.lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_20"))).value();
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
var serialized = PopulationIslandDensity.CODEC.codec().encodeStart(ops, oldDensity).getOrThrow().getAsJsonObject();
serialized.remove("players");
helper.assertTrue(PopulationIslandDensity.CODEC.codec().parse(ops, serialized).getOrThrow().capacity() == IslandCapacity.TWENTY,
"An omitted alpha10 density field must keep its historical default, independently of the new world selector");
List<Map<String, Object>> results = new ArrayList<>();
Path testRoot = Path.of("diagnostics", "population-persistence-seed-" + level.getSeed()).toAbsolutePath();
Files.createDirectories(testRoot);
LevelStorageSource storage = LevelStorageSource.createDefault(testRoot);
for (int players : new int[]{5, 20, 100}) {
String presetId = players == 20 ? "sanctuary" : "sanctuary_" + players;
for (int players : new int[]{5, 10, 20, 100}) {
String presetId = "sanctuary_" + players;
WorldDimensions dimensions = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
SanctuaryMod.id(presetId))).value().createWorldDimensions();
helper.assertTrue(IslandCapacity.forGenerator(dimensions.overworld()) == IslandCapacity.fromPlayers(players),
@@ -68,7 +88,8 @@ final class PopulationPersistenceDiagnostics {
"An existing alpha.9 settings reference must not acquire the new default capacity");
}
Files.writeString(testRoot.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"seed", level.getSeed(), "default_players", 20, "profiles", results,
"legacy_settings_preserved", "sanctuary:sanctuary_cavern", "passed", true)) + "\n");
"seed", level.getSeed(), "default_players", 10, "profiles", results,
"legacy_settings_preserved", "sanctuary:sanctuary_cavern", "visible_presets", visible,
"omitted_density_capacity_preserved", 20, "passed", true)) + "\n");
}
}
@@ -32,6 +32,12 @@ import net.minecraft.world.level.storage.LevelData;
public final class SanctuaryWorldGameTests {
public static LevelData.RespawnData initialSpawn;
@GameTest(maxTicks = 3000)
public void optimizedDensityPreservesSavedTerrain(GameTestHelper helper) throws Exception {
PopulationDensityEquivalenceDiagnostics.verify(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void populationChoiceSurvivesSavingTheWorld(GameTestHelper helper) throws java.io.IOException {
PopulationPersistenceDiagnostics.verify(helper);
@@ -0,0 +1,322 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.GenerationChunkHolder;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.util.StaticCache2D;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ImposterProtoChunk;
import net.minecraft.world.level.chunk.status.ChunkPyramid;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
/** Alpha.13 engine checks use the real starter binding and never reconfigure saved worlds. */
public final class UnifiedWorldGameTests {
private static final List<String> CAVES = List.of("minecraft:lush_caves", "minecraft:dripstone_caves", "minecraft:sulfur_caves");
private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST};
@GameTest(maxTicks = 3000)
public void retainedHydrologySurvivesNativeDecoration(GameTestHelper helper) throws IOException {
PopulationDiagnostics.start(helper);
}
@GameTest(maxTicks = 200)
public void sizeAndPublicChoiceSurviveDiskStorage(GameTestHelper helper) throws IOException {
UnifiedWorldSettingsDiagnostics.verify(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void legacyPipelinesRemainIsolated(GameTestHelper helper) {
PopulationDiagnostics.verifyLegacyIsolation(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void populationTerrainAndNaturalSpawnRemainPresent(GameTestHelper helper) throws IOException {
PopulationDiagnostics.verifyDensity(helper);
var level = helper.getLevel();
var binding = binding(level);
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384, "Unification preserves the complete vertical world extent");
var spawn = SanctuaryWorldGameTests.initialSpawn;
helper.assertTrue(spawn != null, "Capture the original natural spawn before the GameTest fixtures move it");
var at = spawn.pos();
helper.assertTrue(level.getBlockState(at.below()).isCollisionShapeFullBlock(level, at.below())
&& level.getBlockState(at).getCollisionShape(level, at).isEmpty()
&& level.getBlockState(at.above()).getCollisionShape(level, at.above()).isEmpty(),
"The unmodified starter spawn must stand on real usable terrain");
helper.assertTrue(natural(binding, at.below()) > 0, "The starter spawn never requires an added platform");
helper.succeed();
}
@GameTest(maxTicks = 200)
public void exteriorAndLowerMarginRemainVoid(GameTestHelper helper) {
PopulationDiagnostics.verifyExterior(helper);
PopulationDiagnostics.verifyLowerMargin(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void nativeCaveFeaturesExistOnNaturalLayers(GameTestHelper helper) throws IOException {
ServerLevel level = helper.getLevel();
var binding = binding(level);
var generator = unified(level);
List<Site> sites = caveSites(binding);
Map<Long, LevelChunk> chunks = new TreeMap<>();
for (Site site : sites) {
int cx = site.x() >> 4, cz = site.z() >> 4;
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
long key = ChunkPos.pack(cx + dx, cz + dz);
if (!chunks.containsKey(key)) chunks.put(key, full(helper, level, cx + dx, cz + dz));
}
}
helper.assertTrue(chunks.size() <= 27, "Native cave inspection is at most three independent 3x3 FULL patches");
Map<String, Map<String, Long>> counts = new TreeMap<>();
CAVES.forEach(name -> counts.put(name, new TreeMap<>()));
Map<String, Long> allBlocks = new TreeMap<>();
Map<String, Long> surfaceBiomes = new TreeMap<>();
int largestDripstoneColumn = 0;
var density = binding.generator().generatorSettings().value().noiseRouter().finalDensity();
for (LevelChunk chunk : chunks.values()) {
for (var section : chunk.getSections()) section.getStates().count((state, count) -> {
if (!state.isAir()) allBlocks.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), (long) count, Long::sum);
});
for (int dx = 0; dx < 16; dx++) for (int dz = 0; dz < 16; dz++) {
int x = chunk.getPos().getMinBlockX() + dx, z = chunk.getPos().getMinBlockZ() + dz;
int run = 0;
for (int y = 0; y < 256; y++) {
var pos = new BlockPos(x, y, z);
var state = chunk.getBlockState(pos);
String biome = chunk.getNoiseBiome(x >> 2, y >> 2, z >> 2).getRegisteredName();
var bucket = counts.get(biome);
if (bucket != null && !state.isAir()) bucket.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
// Large vanilla pillars add thick dripstone into original cave air. A painted
// floor or the old hand-placed support block cannot satisfy this witness.
if (state.is(Blocks.DRIPSTONE_BLOCK) && biome.equals("minecraft:dripstone_caves")
&& binding.random().sampleBlockValueUncached(density, x, y, z) <= 0) run++;
else run = 0;
largestDripstoneColumn = Math.max(largestDripstoneColumn, run);
}
int top = chunk.getHeight(net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE, dx, dz);
if (top > 190) surfaceBiomes.merge(chunk.getNoiseBiome(x >> 2, top >> 2, z >> 2).getRegisteredName(), 1L, Long::sum);
}
}
var report = report(level);
report.put("sites", sites); report.put("inspected_full_chunks", chunks.size());
report.put("native_cave_blocks", counts); report.put("all_observed_blocks", allBlocks);
report.put("surface_biomes", surfaceBiomes); report.put("largest_natural_air_dripstone_column", largestDripstoneColumn);
report.put("rejected_decoration_writes", generator.rejectedDecorationWrites());
Map<String, Object> atmospheres = new TreeMap<>();
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
for (String name : CAVES) {
var biome = level.registryAccess().lookupOrThrow(Registries.BIOME)
.getValue(net.minecraft.resources.Identifier.parse(name));
var encoded = Biome.DIRECT_CODEC.encodeStart(ops, biome).getOrThrow().getAsJsonObject();
atmospheres.put(name, encoded.get("attributes"));
helper.assertTrue(encoded.get("attributes").toString().contains("music.overworld." + name.substring("minecraft:".length())),
"The native cave's music must remain present in its loaded biome attributes: " + name);
if (name.equals("minecraft:sulfur_caves")) helper.assertTrue(encoded.get("attributes").toString().contains("minecraft:visual/fog_color")
&& encoded.get("attributes").toString().contains("minecraft:sulfur_cube"),
"The sulfur cave retains its distinct fog and native creature table");
}
report.put("native_attributes", atmospheres);
report.put("scope", "Up to 27 FULL chunks selected from real cave density; counts are observed blocks, not island-wide resource estimates or client screenshots");
boolean passed = false;
Set<String> encountered = new TreeSet<>(); sites.forEach(site -> encountered.add(site.biome()));
Set<String> verified = new TreeSet<>();
try {
helper.assertTrue(!sites.isEmpty(), "The demonstration world must provide at least one real native cave site");
if (encountered.contains("minecraft:lush_caves")) {
helper.assertTrue(count(counts, "minecraft:lush_caves", "minecraft:moss_block") > 0
&& count(counts, "minecraft:lush_caves", "minecraft:cave_vines")
+ count(counts, "minecraft:lush_caves", "minecraft:cave_vines_plant") > 0,
"Actual native lush caverns must have moss and harvestable hanging vines");
verified.add("minecraft:lush_caves");
}
if (encountered.contains("minecraft:dripstone_caves")) {
helper.assertTrue(count(counts, "minecraft:dripstone_caves", "minecraft:pointed_dripstone") > 0 && largestDripstoneColumn >= 5,
"Actual native dripstone caverns must include points and a substantial pillar in natural cave air");
verified.add("minecraft:dripstone_caves");
}
if (encountered.contains("minecraft:sulfur_caves")) {
helper.assertTrue(count(counts, "minecraft:sulfur_caves", "minecraft:sulfur") > 0
&& count(counts, "minecraft:sulfur_caves", "minecraft:sulfur_spike") > 0,
"Actual native sulfur caverns must include native rock and spikes");
verified.add("minecraft:sulfur_caves");
}
helper.assertTrue(surfaceBiomes.keySet().stream().anyMatch(name -> name.startsWith("sanctuary:population_")),
"The starter surface must retain its Population biome family above these lower cave patches");
passed = true; helper.succeed();
} finally {
report.put("encountered_native_types", encountered); report.put("verified_native_types", verified);
report.put("unverified_native_types", CAVES.stream().filter(name -> !verified.contains(name)).toList());
report.put("all_three_types_demonstrated_here", verified.containsAll(CAVES));
report.put("validation_contract", "Every encountered type must pass its full block evidence; the release also requires cumulative evidence for all three types across its fresh demonstration worlds");
report.put("passed", passed); write(level, "native-caves", report);
}
}
@GameTest(maxTicks = 200)
public void decorationGuardRejectsChangesToHydrology(GameTestHelper helper) throws IOException {
var level = helper.getLevel(); var binding = binding(level); var generator = unified(level);
var plan = PopulationHydrologyRuntime.regionPlan(binding.generator(), binding.random(), new IslandCapacity.Region(0, 0)).water();
LinkedHashSet<BlockPos> witnesses = new LinkedHashSet<>();
int shells = 0;
for (var cell : plan.cells()) {
if (!cell.hasWater()) continue;
witnesses.add(new BlockPos(cell.x(), cell.waterY(), cell.z()));
witnesses.add(new BlockPos(cell.x(), cell.bedY() - cell.sedimentDepth() - 1, cell.z()));
for (Direction side : SIDES) {
var wall = new BlockPos(cell.x(), cell.waterY(), cell.z()).relative(side);
var next = plan.cellAt(wall.getX(), wall.getY(), wall.getZ());
if ((next == null || !next.hasWater()) && natural(binding, wall) > 0) { witnesses.add(wall); shells++; }
}
if (shells >= 2 && witnesses.size() >= 12) break;
}
int boundaryWitnesses = 0;
for (var cell : plan.cells()) {
if (!cell.hasWater()) continue;
for (Direction side : SIDES) {
var water = new BlockPos(cell.x(), cell.waterY(), cell.z());
var wall = water.relative(side);
var next = plan.cellAt(wall.getX(), wall.getY(), wall.getZ());
if (ChunkPos.pack(water) != ChunkPos.pack(wall) && (next == null || !next.hasWater()) && natural(binding, wall) > 0) {
witnesses.add(wall); boundaryWitnesses++;
}
}
if (boundaryWitnesses >= 4) break;
}
for (var spring : plan.springs()) { witnesses.add(position(spring.source())); witnesses.add(position(spring.outlet())); }
for (var spill : plan.spills()) { witnesses.add(position(spill.source())); witnesses.add(position(spill.outlet())); }
helper.assertTrue(!witnesses.isEmpty() && shells > 0, "Inspect real source, bed and natural wall witnesses");
Map<Long, SanctuaryDecorationProtection> masks = new HashMap<>();
int compared = 0;
for (var witness : witnesses) {
var mask = masks.computeIfAbsent(ChunkPos.pack(witness), ignored -> SanctuaryDecorationProtection.create(binding, witness.getX() >> 4, witness.getZ() >> 4));
helper.assertTrue(mask.protects(witness) && generator.protectsDecoration(witness), "The published decoration mask protects the complete water geometry: " + witness);
helper.assertTrue(PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), witness, 1, 1, 1),
"The mask must agree with the hydrology protection oracle including one-block clearance at natural walls");
compared++;
}
var witness = witnesses.getFirst();
LevelChunk chunk = full(helper, level, witness.getX() >> 4, witness.getZ() >> 4);
// FULL holders deliberately expose a read-only ImposterProtoChunk to generation.
// Use a test-local writable view; leave the world's real holder and saved status alone.
var writable = new ImposterProtoChunk(chunk, true);
GenerationChunkHolder testHolder = new GenerationChunkHolder(chunk.getPos()) {
@Override protected void addSaveDependency(java.util.concurrent.CompletableFuture<?> dependency) {}
@Override public int getTicketLevel() { return 0; }
@Override public int getQueueLevel() { return 0; }
@Override public ChunkAccess getChunkIfPresentUnchecked(ChunkStatus status) { return writable; }
};
var holders = StaticCache2D.<GenerationChunkHolder>create(chunk.getPos().x(), chunk.getPos().z(), 0,
(x, z) -> testHolder);
var worldgen = new WorldGenRegion(level, holders, ChunkPyramid.GENERATION_PYRAMID.getStepTo(ChunkStatus.FEATURES), chunk);
var before = chunk.getBlockState(witness);
long rejected = generator.rejectedDecorationWrites();
try {
helper.assertFalse(worldgen.setBlock(witness, Blocks.DIAMOND_BLOCK.defaultBlockState(), 2, 0),
"A real WorldGenRegion write must be denied on protected retained water");
helper.assertTrue(chunk.getBlockState(witness).equals(before) && generator.rejectedDecorationWrites() == rejected + 1,
"The native write path must preserve the original block and record the rejection");
} finally { if (!chunk.getBlockState(witness).equals(before)) chunk.setBlockState(witness, before, 0); }
BlockPos free = new BlockPos(chunk.getPos().getMinBlockX() + 8, 383, chunk.getPos().getMinBlockZ() + 8);
var freeBefore = chunk.getBlockState(free);
helper.assertFalse(generator.protectsDecoration(free), "A distant air witness must be outside the hydrology mask");
try {
helper.assertTrue(worldgen.setBlock(free, Blocks.DIAMOND_BLOCK.defaultBlockState(), 2, 0)
&& chunk.getBlockState(free).is(Blocks.DIAMOND_BLOCK),
"Decoration outside protected volumes must remain writable");
} finally { chunk.setBlockState(free, freeBefore, 0); }
write(level, "decoration-guard", Map.of("generation", 13, "seed", level.getSeed(),
"diameter", generator.initialDiameter(), "compared_plan_points", compared,
"natural_wall_witnesses", shells, "walls_owned_by_adjacent_chunk", boundaryWitnesses, "actual_write_rejected", true, "unprotected_write_allowed", true, "passed", true));
helper.succeed();
}
private record Site(String biome, int x, int floorY, int roofY, int z, int score) {}
private static List<Site> caveSites(PopulationGenerationContext.Binding binding) {
var capacity = IslandCapacity.forGenerator(binding.generator());
var resolver = binding.generator().getBiomeSource().createUncachedResolver(binding.random());
var density = binding.generator().generatorSettings().value().noiseRouter().finalDensity();
Map<String, List<Site>> candidates = new TreeMap<>(); CAVES.forEach(name -> candidates.put(name, new ArrayList<>()));
int radius = (capacity.terrainLimit() + 15) / 16;
for (int cx = -radius; cx <= radius; cx++) for (int cz = -radius; cz <= radius; cz++) {
int x = cx * 16 + 8, z = cz * 16 + 8;
if (Math.hypot(x, z) >= capacity.radius() - 24) continue;
for (int y = 164; y >= 40; y--) {
String biome = resolver.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2).getRegisteredName();
var bucket = candidates.get(biome); if (bucket == null) continue;
if (sample(binding, density, x, y, z) <= 0 || sample(binding, density, x, y + 1, z) > 0
|| sample(binding, density, x, y + 2, z) > 0 || sample(binding, density, x, y - 1, z) <= 0) continue;
int roof = y + 3;
while (roof < Math.min(256, y + 96) && sample(binding, density, x, roof, z) <= 0) roof++;
if (roof >= Math.min(256, y + 96) || roof - y < 10 || sample(binding, density, x, roof + 1, z) <= 0) continue;
int score = Math.min(48, roof - y);
for (int dx : new int[]{-4, 0, 4}) for (int dz : new int[]{-4, 0, 4})
if (sample(binding, density, x + dx, y, z + dz) > 0 && sample(binding, density, x + dx, roof, z + dz) > 0) score += 8;
bucket.add(new Site(biome, x, y, roof, z, score));
break;
}
}
List<Site> sites = new ArrayList<>();
for (String cave : CAVES) {
var ordered = candidates.get(cave).stream().sorted(Comparator.comparingInt(Site::score).reversed()
.thenComparingDouble(s -> Math.hypot(s.x(), s.z())).thenComparingInt(Site::x).thenComparingInt(Site::z)).toList();
for (Site candidate : ordered.stream().limit(32).toList()) {
var base = new BlockPos(candidate.x(), candidate.floorY() + 1, candidate.z());
if (!PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), base, 8,
candidate.roofY() - candidate.floorY(), 3)) { sites.add(candidate); break; }
}
}
return List.copyOf(sites);
}
private static float sample(PopulationGenerationContext.Binding binding, DensityFunction density, int x, int y, int z) {
return binding.random().sampleBlockValueUncached(density, x, y, z);
}
private static float natural(PopulationGenerationContext.Binding binding, BlockPos pos) {
return sample(binding, binding.generator().generatorSettings().value().noiseRouter().finalDensity(), pos.getX(), pos.getY(), pos.getZ());
}
private static long count(Map<String, Map<String, Long>> counts, String biome, String block) { return counts.get(biome).getOrDefault(block, 0L); }
private static BlockPos position(PopulationHydrology.Position p) { return new BlockPos(p.x(), p.y(), p.z()); }
private static PopulationGenerationContext.Binding binding(ServerLevel level) { return Objects.requireNonNull(PopulationGenerationContext.of(level)); }
private static SanctuaryChunkGenerator unified(ServerLevel level) { return (SanctuaryChunkGenerator) level.getChunkSource().getGenerator(); }
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Inspect only real fully generated chunks"); return (LevelChunk) chunk;
}
private static Map<String, Object> report(ServerLevel level) {
var report = new LinkedHashMap<String, Object>();
report.put("generation", 13); report.put("seed", level.getSeed()); report.put("diameter", unified(level).initialDiameter());
report.put("mod_version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString());
return report;
}
private static void write(ServerLevel level, String kind, Object report) throws IOException {
Path target = Path.of("diagnostics", "unified-" + unified(level).initialDiameter() + "-seed-" + level.getSeed() + "-" + kind + ".json");
Files.createDirectories(target.getParent());
Files.writeString(target, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
SanctuaryMod.LOGGER.info("Unified {} seed {} wrote {}", unified(level).initialDiameter(), level.getSeed(), target);
}
}
@@ -0,0 +1,87 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonPrimitive;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.WorldPresetTags;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.WorldGenSettings;
import net.minecraft.world.level.levelgen.WorldOptions;
import net.minecraft.world.level.storage.LevelStorageSource;
/** Storage contract shared by the public preset and its three client-selected sizes. */
final class UnifiedWorldSettingsDiagnostics {
static void verify(GameTestHelper helper) throws IOException {
var access = helper.getLevel().registryAccess();
var presets = access.lookupOrThrow(Registries.WORLD_PRESET);
var publicPreset = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
SanctuaryMod.id("sanctuary"))).value();
var standard = publicPreset.createWorldDimensions();
helper.assertTrue(standard.overworld() instanceof SanctuaryChunkGenerator,
"The sole Sanctuary world type must use the unified generator");
var generator = (SanctuaryChunkGenerator) standard.overworld();
helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks");
for (var tag : List.of(WorldPresetTags.NORMAL, WorldPresetTags.EXTENDED)) {
var visible = presets.getOrThrow(tag).stream().map(holder -> holder.getRegisteredName())
.filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet());
helper.assertTrue(visible.equals(Set.of("sanctuary:sanctuary")),
"Only one Sanctuary choice is present in the normal and extended world-type lists");
}
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
var serialized = SanctuaryChunkGenerator.CODEC.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
serialized.remove("initial_diameter");
helper.assertTrue(SanctuaryChunkGenerator.CODEC.codec().parse(ops, serialized).getOrThrow().initialDiameter() == 724,
"An omitted new-generation size defaults to Medium");
for (int rejected : new int[]{0, 511, 513, 723, 725, 768, 1023, 1025, 2048}) {
helper.assertTrue(SanctuaryChunkGenerator.DIAMETER_CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(rejected)).error().isPresent(),
"Only the three supported initial diameters are accepted");
}
Path directory = Path.of("diagnostics", "unified-world-settings-seed-" + helper.getLevel().getSeed()).toAbsolutePath();
Files.createDirectories(directory);
var storage = LevelStorageSource.createDefault(directory);
List<Map<String, Object>> results = new ArrayList<>();
for (int diameter : new int[]{512, 724, 1024}) {
var chosen = generator.withInitialDiameter(diameter);
helper.assertTrue(generator.initialDiameter() == 724 && chosen.source() != generator.source(),
"Customizing a new world must copy the generator and its mutable biome source");
var dimensions = standard.replaceOverworldGenerator(access, chosen);
for (var dimension : List.of(LevelStem.NETHER, LevelStem.END)) {
helper.assertTrue(dimensions.get(dimension).equals(standard.get(dimension)),
"Customizing the island preserves the other dimensions");
}
var original = new WorldGenSettings(new WorldOptions(helper.getLevel().getSeed(), false, true), dimensions);
String name = "diameter-" + diameter;
try (var save = storage.createAccess(name)) {
LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath(name), original);
var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow();
helper.assertTrue(restored.dimensions().overworld() instanceof SanctuaryChunkGenerator reloaded
&& reloaded.initialDiameter() == diameter,
"The selected diameter must survive real world-settings storage");
helper.assertTrue(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow()
.equals(WorldGenSettings.CODEC.encodeStart(ops, original).getOrThrow()),
"Seed, generation options and all dimensions survive the disk round trip");
results.add(Map.of("diameter", diameter, "disk_round_trip", true));
}
}
Files.writeString(directory.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"seed", helper.getLevel().getSeed(), "default_diameter", 724,
"public_presets", List.of("sanctuary:sanctuary"), "sizes", results,
"invalid_diameters_rejected", true, "copied_biome_source", true, "passed", true)) + "\n");
}
}
@@ -0,0 +1,114 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.IslandCapacity;
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
/** Opt-in, repeatable server generation workload; it does not measure client rendering or FPS. */
public final class WorldgenPerformanceGameTests {
@GameTest(maxTicks = 3000)
public void spawnAreaAndExploration(GameTestHelper helper) throws Exception {
ServerLevel level = helper.getLevel();
var binding = fr.koka.sanctuary.worldgen.PopulationGenerationContext.of(level);
var capacity = IslandCapacity.forGenerator(binding.generator());
var spawn = SanctuaryWorldGameTests.initialSpawn.pos();
int cx = spawn.getX() >> 4, cz = spawn.getZ() >> 4;
var report = new LinkedHashMap<String, Object>();
report.put("version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow()
.getMetadata().getVersion().getFriendlyString());
report.put("players", capacity.players()); report.put("seed", level.getSeed());
var host = level.getChunkSource().getGenerator();
String profile = "players-" + capacity.players();
if (host instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified) {
report.put("generation", 13); report.put("initial_diameter", unified.initialDiameter());
profile = "unified-" + unified.initialDiameter();
}
report.put("spawn", List.of(spawn.getX(), spawn.getY(), spawn.getZ()));
report.put("java", System.getProperty("java.version"));
report.put("processors", Runtime.getRuntime().availableProcessors());
report.put("max_heap_bytes", Runtime.getRuntime().maxMemory());
List<Map<String, Object>> rings = new ArrayList<>();
List<Long> chunkTimes = new ArrayList<>();
int count = 0, land = 0;
long areaStart = System.nanoTime();
// Centre outwards, 17x17 FULL chunks: a fixed eight-chunk radius around natural spawn.
for (int radius = 0; radius <= 8; radius++) {
long start = System.nanoTime();
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
long before = System.nanoTime();
var chunk = full(helper, level, cx + x, cz + z);
chunkTimes.add(System.nanoTime() - before);
count++;
if (java.util.Arrays.stream(chunk.getSections()).anyMatch(s -> !s.hasOnlyAir())) land++;
}
long elapsed = System.nanoTime() - start;
rings.add(Map.of("radius", radius, "elapsed_ms", millis(elapsed), "cumulative_chunks", count));
SanctuaryMod.LOGGER.info("Generation benchmark {} seed {} ring {}: {} FULL chunks, {} ms",
capacity.players(), level.getSeed(), radius, count, millis(elapsed));
}
report.put("spawn_area_ms", millis(System.nanoTime() - areaStart));
report.put("spawn_area_full_chunks", count); report.put("spawn_area_nonempty_chunks", land);
report.put("rings", rings); report.put("spawn_area_chunk_latency_ms", latency(chunkTimes));
// Continue east through a regional boundary, with a five-chunk-wide corridor.
// This exposes stalls during exploration instead of timing only the initial region.
List<Map<String, Object>> steps = new ArrayList<>();
chunkTimes.clear();
long exploreStart = System.nanoTime();
for (int x = 9; x <= 24; x++) {
long start = System.nanoTime();
for (int z = -2; z <= 2; z++) {
long before = System.nanoTime();
full(helper, level, cx + x, cz + z);
chunkTimes.add(System.nanoTime() - before);
}
long elapsed = System.nanoTime() - start;
steps.add(Map.of("chunk_x", cx + x, "elapsed_ms", millis(elapsed)));
SanctuaryMod.LOGGER.info("Generation benchmark {} seed {} exploration column {}: {} ms",
capacity.players(), level.getSeed(), cx + x, millis(elapsed));
}
report.put("exploration_ms", millis(System.nanoTime() - exploreStart));
report.put("exploration_full_chunks", 80); report.put("exploration_steps", steps);
report.put("exploration_chunk_latency_ms", latency(chunkTimes));
report.put("regions_cached", PopulationHydrologyRuntime.cachedRegionCount(binding.random()));
report.put("scope", "Fresh development world; 289 FULL chunks around natural spawn, then 80 eastward FULL chunks. "
+ "Synchronous server requests include dependent generation up to FULL, not guaranteed ticking readiness, client GPU, network, FPS or player load. "
+ "World creation before the test is timed separately by Minecraft's startup log.");
helper.assertTrue(count == 289 && land > 100, "The benchmark must generate actual playable terrain");
report.put("passed", true);
Path output = Path.of("diagnostics", "generation-performance-" + profile + "-seed-" + level.getSeed() + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
helper.succeed();
}
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Benchmark requires completed chunk " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private static double millis(long nanos) { return nanos / 1_000_000.0; }
private static Map<String, Double> latency(List<Long> times) {
var sorted = times.stream().sorted().toList();
return Map.of("median", millis(sorted.get(sorted.size() / 2)),
"p95", millis(sorted.get((int) Math.ceil(sorted.size() * .95) - 1)),
"max", millis(sorted.getLast()));
}
}
@@ -17,6 +17,18 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Test-only: run the real Sanctuary datapack instead of the framework's flat fixture. */
@Mixin(GameTestServer.class)
public abstract class SanctuaryGameTestServerMixin {
// The framework otherwise runs thousands of empty ticks per second, exhausting an async
// generation test's deadline before its IO/workers can complete. This is test-only pacing.
@Inject(method = "waitUntilNextTick", at = @At("RETURN"))
private void sanctuary$paceExpansionWorkers(CallbackInfo ci) {
if (Boolean.getBoolean("sanctuary.test.expansion")) {
long until = System.nanoTime() + 20_000_000L;
long remaining;
// Worker completion unparks the server; keep the pacing deadline despite those signals.
while ((remaining = until - System.nanoTime()) > 0) java.util.concurrent.locks.LockSupport.parkNanos(remaining);
}
}
@ModifyArg(method = "<clinit>", at = @At(value = "INVOKE", target =
"Lnet/minecraft/world/level/levelgen/WorldOptions;<init>(JZZ)V"), index = 0)
private static long sanctuary$testSeed(long original) {
@@ -32,21 +44,39 @@ public abstract class SanctuaryGameTestServerMixin {
@Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target =
"Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;"))
private static ResourceKey<WorldPreset> sanctuary$selectActualPreset() {
int players = Integer.getInteger("sanctuary.test.players", 20);
if (Boolean.getBoolean("sanctuary.test.unified")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary"));
if (Boolean.getBoolean("sanctuary.test.expansion")) return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab"));
int players = Integer.getInteger("sanctuary.test.players", 10);
String preset = switch (players) {
case 5 -> "sanctuary_5";
case 20 -> "sanctuary";
case 10 -> "sanctuary_10";
case 20 -> "sanctuary_20";
case 100 -> "sanctuary_100";
default -> throw new IllegalArgumentException("Test capacity must be 5, 20 or 100");
default -> throw new IllegalArgumentException("Test geometry must be 5, 10, 20 or 100");
};
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(preset));
}
@Redirect(method = "lambda$create$1", at = @At(value = "INVOKE", target =
"Lnet/minecraft/world/level/levelgen/presets/WorldPreset;createWorldDimensions()Lnet/minecraft/world/level/levelgen/WorldDimensions;"))
private static net.minecraft.world.level.levelgen.WorldDimensions sanctuary$customizeSize(WorldPreset preset,
net.minecraft.world.level.LevelSettings settings, net.minecraft.server.WorldLoader.DataLoadContext context) {
var dimensions = preset.createWorldDimensions();
if (Boolean.getBoolean("sanctuary.test.unified")) {
var generator = (fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator) dimensions.overworld();
return dimensions.replaceOverworldGenerator(context.datapackWorldRegistries(),
generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724)));
}
return dimensions;
}
@Inject(method = "startTests", at = @At("HEAD"))
private void sanctuary$captureInitialSpawn(ServerLevel level, CallbackInfo ci) {
// The framework moves its own spawn to a random test grid after this point.
SanctuaryWorldGameTests.initialSpawn = level.getRespawnData();
fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn =
fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState());
if (Boolean.getBoolean("sanctuary.test.expansion")) return;
var binding = fr.koka.sanctuary.worldgen.PopulationGenerationContext.of(level);
fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn = binding == null ? 0 :
fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(binding.random());
}
}
@@ -0,0 +1,186 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.core.Holder;
import net.minecraft.resources.Identifier;
import net.minecraft.util.Interval;
import net.minecraft.util.RandomSource;
import net.minecraft.util.context.ContextKey;
import net.minecraft.util.context.ContextMap;
import net.minecraft.world.level.levelgen.densityfunction.*;
import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.UnaryFunction;
import net.minecraft.world.level.levelgen.synth.Noise;
import net.minecraft.world.level.levelgen.synth.NormalNoise;
/** Adversarial checks against the actual vanilla scalar implementation, requiring no chunks. */
public final class ExactInterpolationChecks {
private static final ContextKey<Float> SHIFT = ContextKey.vanilla("sanctuary_test_interpolation_shift");
private static final ContextKey<float[]> MUTABLE = ContextKey.vanilla("sanctuary_test_mutable_interpolation_shift");
private static final float[] NO_MUTATION = {0};
private static final DensityFunction.CompileContext COMPILE = new DensityFunction.CompileContext() {
@Override public Noise createNoiseSampler(Holder<NormalNoise> noise) { throw new AssertionError("No noise needed"); }
@Override public RandomSource createRandom(Identifier id) { throw new AssertionError("No random needed"); }
@Override public RandomSource createEndIslandRandom() { throw new AssertionError("No End needed"); }
};
private ExactInterpolationChecks() {}
public static Map<String, Object> verify() throws Exception {
long checked = 0;
for (int[] sizes : new int[][]{{8, 4}, {3, 5}, {1, 1}}) {
var input = new Probe(31);
DensityFunction expression = expression(input, sizes[0], sizes[1]);
var reference = expression.compileSampler(COMPILE);
var cached = cached(input, sizes[0], sizes[1]);
SamplerContext first = context(0), second = context(0.0625f);
var points = new ArrayList<int[]>();
for (int x = -13; x <= 13; x++) for (int y = -7; y <= 11; y++) for (int z = -9; z <= 9; z++)
points.add(new int[]{x, y, z});
Collections.shuffle(points, new Random(73));
for (var context : List.of(first, second, first)) for (var p : points) {
same(reference.sampleValue(context, p[0], p[1], p[2]), cached.sampleValue(context, p[0], p[1], p[2]), "scalar/context");
checked++;
}
var volume = new DensityVolume(13, 17, 11, -19, -9, -17, 3, 2, 5);
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
cached.sampleVolume(first, actual, volume);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
same(reference.sampleValue(first, volume.blockX(x), volume.blockY(y), volume.blockZ(z)),
actual.get(volume.indexUnchecked(x, y, z)), "non-unit volume");
checked++;
}
}
// A scalar and a bulk input intentionally differ in Probe. This catches accidental
// replacement of the aligned scalar branch with one of the cached lattice corners.
var counted = new Probe(91);
var cached = cached(counted, 8, 4);
var stable = context(0);
for (int repeat = 0; repeat < 2; repeat++)
for (int x = 1; x < 8; x++) for (int y = 1; y < 4; y++) for (int z = 1; z < 8; z++) cached.sampleValue(stable, x, y, z);
require(counted.volumeCalls.get() == 1, "A reused cell must sample its eight corners only once");
int alignedBefore = counted.scalarCalls.get();
cached.sampleValue(stable, 0, 0, 0);
require(counted.scalarCalls.get() == alignedBefore + 1, "Aligned nodes retain their separate scalar input path");
var reference = expression(counted, 8, 4).compileSampler(COMPILE);
// More distinct cells than the bounded cache holds; collisions and replacement must
// remain invisible to both forward and reverse traversals.
for (int direction : new int[]{1, -1}) for (int index = 0; index < 9000; index++) {
int i = direction > 0 ? index : 8999 - index;
int x = (i % 113 - 56) * 8 + 1, y = ((i / 113) % 97 - 48) * 4 + 1, z = (i % 67 - 33) * 8 + 3;
same(reference.sampleValue(stable, x, y, z), cached.sampleValue(stable, x, y, z), "cache replacement");
checked++;
}
var other = cached(new Probe(999), 8, 4);
var otherReference = expression(new Probe(999), 8, 4).compileSampler(COMPILE);
for (int i = 0; i < 128; i++) {
same(reference.sampleValue(stable, i - 67, 21, -3), cached.sampleValue(stable, i - 67, 21, -3), "sampler identity A");
same(otherReference.sampleValue(stable, i - 67, 21, -3), other.sampleValue(stable, i - 67, 21, -3), "sampler identity B");
checked += 2;
}
try (var executor = Executors.newFixedThreadPool(3)) {
List<Callable<Integer>> tasks = new ArrayList<>();
for (int worker = 0; worker < 3; worker++) {
final int id = worker;
tasks.add(() -> {
var local = context(id * 0.03125f);
for (int i = 0; i < 3000; i++) {
int x = (i * 17 + id) % 251 - 125, y = i % 384, z = (i * 37 - id) % 257 - 128;
same(reference.sampleValue(local, x, y, z), cached.sampleValue(local, x, y, z), "concurrent shared sampler");
}
return 3000;
});
}
for (var result : executor.invokeAll(tasks)) checked += result.get();
}
var plain = new ProbeFunction(new Probe(42));
require(ExactInterpolationSampler.compileTerrain(plain, COMPILE) == plain.probe,
"Unrecognized expressions must preserve their original sampler");
require(!ExactInterpolationSampler.supportsTerrain(expression(new Probe(42), 8, 4)),
"An opaque/contextual input must not be cached merely because its outer expression matches");
float[] payload = {0};
var mutable = SamplerContext.builder().setUserFields(ContextMap.builder().set(MUTABLE, payload).build()).build();
var fallback = ExactInterpolationSampler.compileTerrain(expression(new Probe(42), 8, 4), COMPILE);
float before = fallback.sampleValue(mutable, 1, 2, 3);
payload[0] = 0.125f;
float after = fallback.sampleValue(mutable, 1, 2, 3);
require(Float.floatToRawIntBits(before) != Float.floatToRawIntBits(after),
"The fallback must observe mutable context payloads even when context identity stays the same");
var nestedInput = new Probe(817);
var nested = cached(nestedInput, 8, 4);
var nestedReference = expression(new Probe(817), 8, 4).compileSampler(COMPILE);
var outerInput = new Probe(83);
outerInput.nested = nested;
var outer = cached(outerInput, 8, 4);
var outerReference = expression(outerInput, 8, 4).compileSampler(COMPILE);
for (int i = 0; i < 128; i++) {
int x = i - 65;
same(outerReference.sampleValue(stable, x, 23, -7), outer.sampleValue(stable, x, 23, -7), "nested outer request");
same(nestedReference.sampleValue(stable, x, 23, -7), nested.sampleValue(stable, x, 23, -7), "nested cache identity");
checked += 2;
}
return Map.of("bitwise_comparisons", checked, "repeated_cell_corner_batches", 1,
"concurrent_workers", 3, "aligned_scalar_path_preserved", true, "passed", true);
}
private static DensityFunction expression(Probe probe, int xz, int y) {
return new UnaryFunction(UnaryFunction.Type.SQUEEZE, new InterpolatedFunction(new ProbeFunction(probe), xz, y));
}
private static DensitySampler cached(Probe probe, int xz, int y) {
return new UnaryFunction.SqueezeSampler(new ExactInterpolationSampler(probe, xz, y));
}
private static SamplerContext context(float shift) {
return SamplerContext.builder().setUserFields(ContextMap.builder().set(SHIFT, shift).build()).build();
}
private record ProbeFunction(Probe probe) implements DensityFunction {
@Override public DensitySampler compileSampler(CompileContext context) { return probe; }
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) { return this; }
@Override public Interval range() { return Interval.of(-2, 2); }
@Override public int domainAxes() { return ALL_AXES; }
@Override public MapCodec<ProbeFunction> codec() { return MapCodec.unit(this); }
}
private static final class Probe implements DensitySampler {
final int seed;
final AtomicInteger scalarCalls = new AtomicInteger(), volumeCalls = new AtomicInteger();
DensitySampler nested;
Probe(int seed) { this.seed = seed; }
private float value(SamplerContext context, int x, int y, int z) {
int hash = (x * 73428767 ^ y * 912931 ^ z * 4382893 ^ seed);
hash ^= hash >>> 15;
float[] mutable = context.getFieldOrDefault(MUTABLE, NO_MUTATION);
return Float.intBitsToFloat(0x3e000000 | (hash & 0x007fffff)) - 0.19f
+ context.getFieldOrDefault(SHIFT, 0f) + mutable[0];
}
@Override public float sampleValue(SamplerContext context, int x, int y, int z) {
scalarCalls.incrementAndGet();
return value(context, x, y, z) + 0.03125f;
}
@Override public void sampleVolume(SamplerContext context, DensityBuffer buffer, DensityVolume volume) {
volumeCalls.incrementAndGet();
if (nested != null) nested.sampleValue(context, volume.minBlockX() + 1, volume.minBlockY() + 1, volume.minBlockZ() + 1);
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++)
buffer.set(volume.indexUnchecked(x, y, z), value(context, volume.blockX(x), volume.blockY(y), volume.blockZ(z)));
}
}
private static void same(float expected, float actual, String label) {
if (Float.floatToRawIntBits(expected) != Float.floatToRawIntBits(actual))
throw new AssertionError(label + ": expected " + Float.toHexString(expected) + ", actual " + Float.toHexString(actual));
}
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
}
@@ -6,7 +6,7 @@
"environment": "*",
"license": "GPL-3.0-or-later",
"entrypoints": {
"fabric-gametest": ["fr.koka.sanctuary.gametest.SanctuaryWorldGameTests"],
"fabric-gametest": ["${gametest_entrypoint}"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -1,5 +1,8 @@
package fr.koka.sanctuary;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.SanctuaryBiomeSource;
import fr.koka.sanctuary.worldgen.CavernIslandBiomeSource;
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
import fr.koka.sanctuary.worldgen.PopulationIslandBiomeSource;
@@ -40,7 +43,14 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionChunkGenerator.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionBiomeSource.CODEC);
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
fr.koka.sanctuary.expansion.ExpansionRuntime.register();
fr.koka.sanctuary.expansion.ExpansionCommands.register();
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v13"), SanctuaryChunkGenerator.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v13"), SanctuaryBiomeSource.CODEC);
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("population_island"), PopulationIslandDensity.CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("population_island"), PopulationIslandBiomeSource.CODEC);
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_groves"), PopulationGrovesFeature.CODEC);
@@ -0,0 +1,104 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.CycleButton;
import net.minecraft.client.gui.components.MultiLineTextWidget;
import net.minecraft.client.gui.layouts.HeaderAndFooterLayout;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationContext;
import net.minecraft.network.chat.CommonComponents;
import net.minecraft.network.chat.Component;
/** Edits only the pending new world's island size. Cancel never changes its dimensions. */
public final class SanctuaryWorldOptionsScreen extends Screen {
private final CreateWorldScreen parent;
private final SanctuaryChunkGenerator generator;
private final HeaderAndFooterLayout layout = new HeaderAndFooterLayout(this);
private IslandSize selectedSize;
private MultiLineTextWidget diameterHint;
public SanctuaryWorldOptionsScreen(CreateWorldScreen parent, WorldCreationContext context) {
super(Component.translatable("sanctuary.world_options.title"));
this.parent = parent;
if (!(context.selectedDimensions().overworld() instanceof SanctuaryChunkGenerator sanctuary)) {
throw new IllegalArgumentException("Sanctuary options require the unified generator");
}
generator = sanctuary;
selectedSize = IslandSize.fromDiameter(generator.initialDiameter());
}
@Override
protected void init() {
layout.removeChildren();
layout.addTitleHeader(title, font);
LinearLayout contents = LinearLayout.vertical().spacing(12);
contents.defaultCellSetting().alignHorizontallyCenter();
contents.addChild(new MultiLineTextWidget(Component.translatable("sanctuary.world_options.description"), font)
.setMaxWidth(310).setCentered(true));
CycleButton<IslandSize> sizeButton = CycleButton.builder(IslandSize::label, selectedSize)
.withValues(IslandSize.values())
.create(0, 0, 240, 20, Component.translatable("sanctuary.world_options.size"), (button, size) -> {
selectedSize = size;
diameterHint.setMessage(diameterHint());
layout.arrangeElements();
});
contents.addChild(sizeButton);
diameterHint = contents.addChild(new MultiLineTextWidget(diameterHint(), font)
.setMaxWidth(310).setCentered(true));
layout.addToContents(contents);
LinearLayout actions = LinearLayout.horizontal().spacing(8);
actions.addChild(Button.builder(CommonComponents.GUI_DONE, button -> applyAndClose()).width(150).build());
actions.addChild(Button.builder(CommonComponents.GUI_CANCEL, button -> onClose()).width(150).build());
layout.addToFooter(actions);
layout.visitWidgets(this::addRenderableWidget);
repositionElements();
}
private Component diameterHint() {
return Component.translatable("sanctuary.world_options.diameter", selectedSize.diameter);
}
private void applyAndClose() {
parent.getUiState().updateDimensions((registries, dimensions) -> dimensions.replaceOverworldGenerator(
registries, generator.withInitialDiameter(selectedSize.diameter)));
onClose();
}
@Override
protected void repositionElements() {
layout.arrangeElements();
}
@Override
public void onClose() {
minecraft.gui.setScreen(parent);
}
private enum IslandSize {
SMALL("small", 512), MEDIUM("medium", 724), LARGE("large", 1024);
private final String id;
private final int diameter;
IslandSize(String id, int diameter) {
this.id = id;
this.diameter = diameter;
}
Component label() {
return Component.translatable("sanctuary.world_options.size." + id);
}
static IslandSize fromDiameter(int diameter) {
for (IslandSize size : values()) {
if (size.diameter == diameter) return size;
}
throw new IllegalArgumentException("Unsupported Sanctuary island diameter: " + diameter);
}
}
}
@@ -0,0 +1,114 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.mixin.ExpansionChunkMapAccessor;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ChunkMap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
/** Conservative admission for fresh regions only; inspecting a position never requests generation. */
public final class ExpansionAdmission {
private static final int MAX_CHUNKS = 4_000;
private static final int READ_BATCH = 64;
private static final long DEADLINE_NANOS = TimeUnit.SECONDS.toNanos(30);
private ExpansionAdmission() { }
public static void verify(ServerLevel level, ExpansionIsland island) throws IOException {
verify(level, island, searchDeadline());
}
/** A caller searching several positions must share this deadline across all admission attempts. */
public static long searchDeadline() {
return System.nanoTime() + DEADLINE_NANOS;
}
public static void verify(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
checkDeadline(deadlineNanos);
List<ChunkPos> positions = positions(level, island);
ChunkMap map = level.getChunkSource().chunkMap;
verifyHolders(map, positions, deadlineNanos);
for (int start = 0; start < positions.size(); start += READ_BATCH) {
checkDeadline(deadlineNanos);
int end = Math.min(positions.size(), start + READ_BATCH);
List<CompletableFuture<Optional<CompoundTag>>> reads = new ArrayList<>();
for (int i = start; i < end; i++) reads.add(map.read(positions.get(i)));
ChunkPos occupied = null;
try {
for (int i = 0; i < reads.size(); i++) {
long remaining = deadlineNanos - System.nanoTime();
if (remaining <= 0) throw new TimeoutException("Admission deadline expired");
if (reads.get(i).get(remaining, TimeUnit.NANOSECONDS).isPresent()) {
occupied = positions.get(start + i);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Expansion admission interrupted", e);
} catch (TimeoutException e) {
throw new IOException("search_timeout", e);
} catch (ExecutionException e) {
throw new IOException("Expansion could not prove that every reserved chunk is absent", e);
}
// Drain every submitted read before skipping this occupied region, so a failed read
// is never swallowed as an ordinary collision and no next attempt overlaps the batch.
checkDeadline(deadlineNanos);
if (occupied != null) throw new OccupiedException("Expansion intersects an existing saved chunk: " + occupied);
}
verifyHolders(map, positions, deadlineNanos);
// Caller must commit and publish on this same server turn, without yielding or processing ticks.
}
/** Recheck active requests immediately before commit, after read-only terrain prospecting. */
public static void verifyHolders(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
checkDeadline(deadlineNanos);
verifyHolders(level.getChunkSource().chunkMap, positions(level, island), deadlineNanos);
}
private static List<ChunkPos> positions(ServerLevel level, ExpansionIsland island) throws IOException {
if (!level.getServer().isSameThread()) throw new IOException("Expansion admission must run on the server thread");
if (island.origin()) throw new IOException("The existing Sanctuary region cannot be admitted again");
int radius = island.reservedRadius();
int minX = Math.floorDiv(island.centerX() - radius, 16), maxX = Math.floorDiv(island.centerX() + radius, 16);
int minZ = Math.floorDiv(island.centerZ() - radius, 16), maxZ = Math.floorDiv(island.centerZ() + radius, 16);
if ((long) (maxX - minX + 1) * (maxZ - minZ + 1) > MAX_CHUNKS) throw new IOException("Expansion admission exceeds 4000 chunks");
ChunkMap map = level.getChunkSource().chunkMap;
if (!(map instanceof ExpansionChunkMapAccessor)) throw new IOException("Expansion chunk safety accessor is unavailable");
List<ChunkPos> positions = new ArrayList<>();
for (int z = minZ; z <= maxZ; z++) for (int x = minX; x <= maxX; x++) positions.add(new ChunkPos(x, z));
return positions;
}
private static void verifyHolders(ChunkMap map, List<ChunkPos> positions, long deadlineNanos) throws IOException {
for (ChunkPos position : positions) {
checkDeadline(deadlineNanos);
rejectHolder(map, position);
}
}
public static void checkDeadline(long deadlineNanos) throws IOException {
if (deadlineNanos - System.nanoTime() <= 0) throw new IOException("search_timeout");
}
private static void rejectHolder(ChunkMap map, ChunkPos position) throws IOException {
long key = position.pack();
if (map.getUpdatingChunkIfPresent(key) != null || ((ExpansionChunkMapAccessor) map).sanctuary$pendingUnloads().containsKey(key)) {
throw new OccupiedException("Expansion intersects a loaded or unloading chunk: " + position);
}
}
/** Only proven occupancy may move the search farther along the requested direction. */
public static final class OccupiedException extends IOException {
public OccupiedException(String message) {
super(message);
}
}
}
@@ -0,0 +1,138 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import org.jspecify.annotations.Nullable;
/** All future holders are known up front; only the immutable region snapshot changes on activation. */
public class ExpansionBiomeSource extends BiomeSource {
public static final List<String> REQUIRED_BIOMES = Stream.of(
"the_void", "forest", "birch_forest", "flower_forest", "plains", "meadow",
"taiga", "snowy_taiga", "snowy_plains", "grove", "ice_spikes", "frozen_peaks",
"old_growth_spruce_taiga", "dark_forest", "swamp", "pale_garden", "jungle",
"sparse_jungle", "bamboo_jungle", "mangrove_swamp", "savanna", "savanna_plateau",
"desert", "badlands", "eroded_badlands", "wooded_badlands", "windswept_hills",
"windswept_gravelly_hills", "stony_peaks", "dappled_forest", "cherry_grove",
"lush_caves", "dripstone_caves", "sulfur_caves"
).map(path -> "minecraft:" + path).toList();
public static final MapCodec<ExpansionBiomeSource> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.biomes)
).apply(i, ExpansionBiomeSource::new));
private final List<Holder<Biome>> biomes;
private final Map<String, Holder<Biome>> byName;
private volatile List<ExpansionIsland> islands = List.of();
public ExpansionBiomeSource(List<Holder<Biome>> biomes) {
this.biomes = List.copyOf(biomes);
var names = new HashMap<String, Holder<Biome>>();
for (Holder<Biome> biome : biomes) {
String name = biome.unwrapKey().orElseThrow(() -> new IllegalArgumentException("Expansion biomes must be registered holders"))
.identifier().toString();
if (names.put(name, biome) != null) throw new IllegalArgumentException("Duplicate expansion biome: " + name);
}
for (String required : REQUIRED_BIOMES) {
if (!names.containsKey(required)) throw new IllegalArgumentException("Missing expansion biome: " + required);
}
this.byName = Map.copyOf(names);
}
public void setIslands(List<ExpansionIsland> islands) { this.islands = List.copyOf(islands); }
public List<ExpansionIsland> islands() { return islands; }
public @Nullable ExpansionIsland islandAt(int x, int z) {
return islandAt(islands, x, z);
}
private static @Nullable ExpansionIsland islandAt(List<ExpansionIsland> snapshot, int x, int z) {
for (ExpansionIsland island : snapshot) if (island.contains(x, z)) return island;
return null;
}
@Override protected MapCodec<? extends ExpansionBiomeSource> codec() { return CODEC; }
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() { return biomes.stream(); }
@Override
public BiomeResolver createResolver(Climate.Sampler sampler) {
// General-purpose resolvers may outlive an activation. Resolve against the current snapshot.
return (quartX, quartY, quartZ) -> resolve(islands, quartX * 4, quartY * 4, quartZ * 4);
}
@Override
public BiomeResolver createResolverForChunk(Climate.Sampler sampler, int minQuartX, int minQuartY,
int minQuartZ, int quartSizeX, int quartSizeY, int quartSizeZ) {
List<ExpansionIsland> snapshot = islands;
return (quartX, quartY, quartZ) -> resolve(snapshot, quartX * 4, quartY * 4, quartZ * 4);
}
protected Holder<Biome> resolve(List<ExpansionIsland> snapshot, int x, int y, int z) {
ExpansionIsland island = islandAt(snapshot, x, z);
if (island == null || y < 0 || y >= 384) return biome("the_void");
double scale = Math.clamp(island.diameter() / 3.0, 24.0, 144.0);
double localX = (x - island.centerX()) / scale, localZ = (z - island.centerZ()) / scale;
double variation = noise(island.seed(), localX, localZ);
double moisture = noise(island.seed() ^ 0xA12C11A7EL, localX * 0.8, localZ * 0.8);
double elevation = y - variation * 14 - moisture * 8;
// Small pockets preserve the surface profile on exposed lower ledges.
if (y < 168 && elevation < 162) {
if ((island.relief().equals("volcano") && variation > -0.4)
|| (variation > 0.22 && variation < 0.46 && moisture < -0.18)) return biome("sulfur_caves");
if (moisture > 0.14 && variation > -0.38) return biome("lush_caves");
if (moisture < -0.18 && variation < 0.2) return biome("dripstone_caves");
}
String selected = switch (island.climate()) {
case "temperate" -> Math.abs(variation) < 0.13 ? "plains"
: variation < -0.33 ? "birch_forest" : moisture > 0.44 ? "flower_forest" : "forest";
case "boreal" -> elevation > 244 ? "snowy_taiga" : variation < -0.2 ? "grove" : "taiga";
case "glacial" -> elevation > 246 ? "frozen_peaks" : variation > 0.35 ? "ice_spikes" : "snowy_plains";
case "cold_humid" -> elevation > 250 ? "grove" : variation < -0.16 ? "taiga" : "old_growth_spruce_taiga";
case "humid" -> moisture > 0.4 && variation < 0 ? "pale_garden"
: elevation < 222 && variation < 0.14 ? "swamp" : "dark_forest";
case "tropical" -> elevation < 218 && moisture > 0.15 ? "mangrove_swamp"
: variation > 0.3 ? "bamboo_jungle" : variation < -0.34 ? "sparse_jungle" : "jungle";
case "warm" -> elevation > 244 ? "savanna_plateau" : moisture > 0.46 ? "sparse_jungle" : "savanna";
case "arid" -> variation < -0.2 ? "desert" : variation > 0.4 ? "eroded_badlands"
: moisture > 0.36 ? "wooded_badlands" : "badlands";
case "dry" -> elevation > 250 ? "stony_peaks" : variation > 0.28 ? "windswept_hills"
: moisture < -0.38 ? "windswept_gravelly_hills" : "plains";
case "cold_dry" -> elevation > 250 ? "frozen_peaks" : variation > 0.12 ? "snowy_plains" : "windswept_gravelly_hills";
case "autumn" -> Math.abs(variation) < 0.12 ? "meadow" : variation < -0.52 ? "birch_forest" : "dappled_forest";
case "cherry" -> elevation > 258 ? "grove" : variation < -0.22 ? "meadow" : "cherry_grove";
default -> throw new IllegalStateException("Unvalidated expansion climate: " + island.climate());
};
return biome(selected);
}
private Holder<Biome> biome(String path) { return byName.get("minecraft:" + path); }
private static double noise(long seed, double x, double z) {
int floorX = (int) Math.floor(x), floorZ = (int) Math.floor(z);
double tx = fade(x - floorX), tz = fade(z - floorZ);
double north = mix(lattice(seed, floorX, floorZ), lattice(seed, floorX + 1, floorZ), tx);
double south = mix(lattice(seed, floorX, floorZ + 1), lattice(seed, floorX + 1, floorZ + 1), tx);
return mix(north, south, tz);
}
private static double lattice(long seed, int x, int z) {
long value = seed ^ (x * 0x9E3779B97F4A7C15L) ^ (z * 0xC2B2AE3D27D4EB4FL);
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
value ^= value >>> 31;
return (value >>> 11) * 0x1.0p-52 - 1.0;
}
private static double fade(double value) { return value * value * (3 - 2 * value); }
private static double mix(double start, double end, double amount) { return start + (end - start) * amount; }
}
@@ -0,0 +1,155 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
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.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.NoiseRouter;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
/** Laboratory only. Each immutable island has its own vanilla noise generator and seeded RandomState.
* The host ChunkMap's empty fallback RandomState is deliberately never used for terrain. */
public class ExpansionChunkGenerator extends ChunkGenerator {
public static final MapCodec<ExpansionChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
ExpansionBiomeSource.CODEC.fieldOf("biome_source").forGetter(ExpansionChunkGenerator::source),
Codec.intRange(512, 724).optionalFieldOf("initial_diameter", 512).forGetter(ExpansionChunkGenerator::initialDiameter)
).apply(i, ExpansionChunkGenerator::new));
private final ExpansionBiomeSource source;
private final int initialDiameter;
protected volatile RegistryAccess registries;
private final Map<ExpansionIsland, Context> contexts = new ConcurrentHashMap<>();
protected record Context(NoiseBasedChunkGenerator generator, RandomState random) { public Context {} }
public ExpansionChunkGenerator(ExpansionBiomeSource source, int initialDiameter) {
this(source, initialDiameter, false);
}
protected ExpansionChunkGenerator(ExpansionBiomeSource source, int initialDiameter, boolean unified) {
super(source);
if (initialDiameter != 512 && initialDiameter != 724 && !(unified && initialDiameter == 1024))
throw new IllegalArgumentException("Unsupported Sanctuary diameter");
this.source = source;
this.initialDiameter = initialDiameter;
}
public ExpansionBiomeSource source() { return source; }
public int initialDiameter() { return initialDiameter; }
@Override protected MapCodec<? extends ExpansionChunkGenerator> codec() { return CODEC; }
public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
registries = level.registryAccess();
source.setIslands(islands);
}
public void setIslands(List<ExpansionIsland> islands) { source.setIslands(islands); }
public void discardUnpublishedContext(ExpansionIsland island) {
var key = island.ready();
if (source.islands().stream().noneMatch(saved -> saved.ready().equals(key))) contexts.remove(key);
}
public Holder<Biome> structureBiome(Holder<Biome> biome) { return biome; }
public ExpansionIsland islandAt(int x, int z) { return source.islandAt(x, z); }
public ExpansionIsland islandForChunk(int x, int z) {
for (ExpansionIsland island : source.islands()) if (island.ownsChunk(x, z)) return island;
return null;
}
protected Context context(ExpansionIsland island) {
if (registries == null) throw new IllegalStateException("Expansion generator used before world attachment");
return contexts.computeIfAbsent(island.ready(), ignored -> {
String baseKey = island.origin() && island.diameter() == 724 ? "population_10" : "population_5";
var settingsRegistry = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
var base = settingsRegistry.getValue(Identifier.fromNamespaceAndPath("sanctuary", baseKey));
var vanilla = settingsRegistry.getValue(Identifier.withDefaultNamespace("overworld"));
if (base == null || vanilla == null) throw new IllegalStateException("Missing laboratory noise settings");
var router = base.noiseRouter();
var density = new ExpansionDensity(router.finalDensity(), island);
var translated = new NoiseRouter(router.temperature(), router.vegetation(), router.continents(), router.erosion(),
router.depth(), router.ridges(), router.chunkSurfaceLevel(), density);
var settings = new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), Blocks.AIR.defaultBlockState(),
translated, vanilla.materialRule(), List.of(), -64, false, Optional.empty(), base.useLegacyRandomSource(), base.debugFunctions());
var delegate = new NoiseBasedChunkGenerator(source, Holder.direct(settings));
var random = RandomState.create(registries.lookupOrThrow(Registries.NOISE), island.seed(), settings);
return new Context(delegate, random);
});
}
protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) { ExpansionTerrain.apply(island, chunk); }
public String journalDirectory() { return "sanctuary-expansion-v1"; }
public int rootGeneration() { return ExpansionIsland.GENERATION; }
public RandomState randomState(ExpansionIsland island) { return context(island).random(); }
public NoiseBasedChunkGenerator delegate(ExpansionIsland island) { return context(island).generator(); }
@Override public CompletableFuture<ChunkAccess> createBiomes(RandomState ignored, Blender blender, StructureManager structures, ChunkAccess chunk) {
var island = islandForChunk(chunk.getPos().x(), chunk.getPos().z());
if (island == null) return super.createBiomes(ignored, blender, structures, chunk);
var context = context(island);
return context.generator().createBiomes(context.random(), blender, structures, chunk);
}
@Override public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender, RandomState ignored,
StructureManager structures, BiomeManager manager, WorldGenRegion region, Set<Holder<Biome>> biomes) {
var island = islandForChunk(chunk.getPos().x(), chunk.getPos().z());
if (island == null) return CompletableFuture.completedFuture(chunk);
var context = context(island);
return context.generator().buildTerrain(chunk, blender, context.random(), structures, manager, region, biomes).thenApply(generated -> {
afterTerrain(island, generated);
return generated;
});
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager structures,
ChunkAccess chunk, StructureTemplateManager templates, ResourceKey<Level> dimension) {
ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension);
}
@Override public void spawnOriginalMobs(WorldGenRegion region) {
var pos = region.getCenter();
var island = islandForChunk(pos.x(), pos.z());
if (island != null) context(island).generator().spawnOriginalMobs(region);
}
@Override public int getGenDepth() { return 384; }
@Override public int getMinY() { return 0; }
@Override public int getSeaLevel() { return -64; }
@Override public int getSpawnHeight(LevelHeightAccessor level) { return 220; }
@Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor level, RandomState ignored) {
var island = islandAt(x, z);
if (island == null) return getMinY();
var context = context(island);
return context.generator().getBaseHeight(x, z, type, level, context.random());
}
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor level, RandomState ignored) {
var island = islandAt(x, z);
if (island != null) {
var context = context(island);
return context.generator().getBaseColumn(x, z, level, context.random());
}
BlockState[] air = new BlockState[384];
java.util.Arrays.fill(air, Blocks.AIR.defaultBlockState());
return new NoiseColumn(0, air);
}
@Override public void addDebugScreenInfo(List<String> text, RandomState ignored, BlockPos pos, SamplerContext context) {
var island = islandAt(pos.getX(), pos.getZ());
String prefix = "Sanctuary gen " + rootGeneration() + ": ";
text.add(island == null ? prefix + "void" : prefix + island.id() + " / " + island.climate() + " / " + island.relief());
}
}
@@ -0,0 +1,142 @@
package fr.koka.sanctuary.expansion;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import java.util.List;
import java.util.Set;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.SharedSuggestionProvider;
import net.minecraft.network.chat.Component;
/** OP tools for the experimental world, also usable from the dedicated server console. */
public final class ExpansionCommands {
private ExpansionCommands() {}
private static final String PREFIX = "sanctuary.expansion.";
public static void register() {
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) -> {
var expansion = Commands.literal("expansion").executes(ExpansionCommands::help)
.then(Commands.literal("help").executes(ExpansionCommands::help))
.then(Commands.literal("list").executes(c -> run(c, () -> {
var session = ExpansionRuntime.session(c.getSource().getLevel());
for (var island : session.islands()) c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "entry", island.id(), island.parent(),
label("direction", island.direction()), label("climate", island.climate()), island.diameter(), label("relief", island.relief()), island.centerX(), island.centerZ(),
Component.translatable(PREFIX + "state." + island.status())), false);
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "progress", session.progress()), false);
return session.islands().size();
})))
.then(Commands.literal("visit").then(islandArgument().executes(c -> run(c, () -> {
var player = c.getSource().getPlayerOrException();
if (player.isPassenger()) throw new IllegalArgumentException("dismount_first");
var landing = ExpansionRuntime.session(c.getSource().getLevel()).landing(word(c, "island"));
boolean moved = player.teleportTo(c.getSource().getLevel(), landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
if (!moved) throw new IllegalArgumentException("teleport_failed");
player.fallDistance = 0;
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "visited", word(c, "island")), false);
return Command.SINGLE_SUCCESS;
}))))
.then(Commands.literal("resume").then(islandArgument().executes(c -> run(c, () -> {
ExpansionRuntime.session(c.getSource().getLevel()).resume(word(c, "island"));
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "resumed", word(c, "island")), false);
return Command.SINGLE_SUCCESS;
}))))
.then(createTree("create", false)).then(createTree("preview", true))
.then(Commands.literal("quick").then(Commands.argument("direction", StringArgumentType.word())
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
.executes(c -> quick(c, null, 128, "natural"))
.then(Commands.argument("climate", StringArgumentType.word())
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
.executes(c -> quick(c, word(c, "climate"), 128, "natural"))
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 512))
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512"), b))
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), "natural"))
.then(Commands.argument("relief", StringArgumentType.word())
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief")))
.then(distanceArgument().executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"),
word(c, "relief"), IntegerArgumentType.getInteger(c, "distance")))))))));
dispatcher.register(Commands.literal("sanctuary").requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)).then(expansion));
});
}
private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, String> islandArgument() {
return Commands.argument("island", StringArgumentType.word()).suggests((c, b) -> {
try { return SharedSuggestionProvider.suggest(ExpansionRuntime.session(c.getSource().getLevel()).islands().stream().map(ExpansionIsland::id), b); }
catch (IllegalArgumentException ignored) { return b.buildFuture(); }
});
}
private static LiteralArgumentBuilder<CommandSourceStack> createTree(String name, boolean preview) {
return Commands.literal(name).then(Commands.argument("id", StringArgumentType.word())
.then(Commands.argument("parent", StringArgumentType.word())
.suggests((c, b) -> {
try { return SharedSuggestionProvider.suggest(ExpansionRuntime.session(c.getSource().getLevel()).islands().stream().map(ExpansionIsland::id), b); }
catch (IllegalArgumentException ignored) { return b.buildFuture(); }
})
.then(Commands.argument("direction", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
.then(Commands.argument("climate", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 512))
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512"), b))
.then(Commands.argument("relief", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
.executes(c -> create(c, preview, 0))
.then(distanceArgument().executes(c -> create(c, preview, IntegerArgumentType.getInteger(c, "distance"))))))))));
}
private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, Integer> distanceArgument() {
return Commands.argument("distance", IntegerArgumentType.integer(0, 100_000))
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("1024", "2048", "4096"), b));
}
private static int create(CommandContext<CommandSourceStack> c, boolean preview, int minimumDistance) {
return run(c, () -> {
var session = ExpansionRuntime.session(c.getSource().getLevel());
var island = preview ? session.candidate(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance)
: session.create(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance);
describe(c, island, preview);
return Command.SINGLE_SUCCESS;
});
}
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief) {
return quick(c, climate, diameter, relief, 0);
}
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief, int minimumDistance) {
return run(c, () -> {
var session = ExpansionRuntime.session(c.getSource().getLevel());
int next = session.islands().size();
while (true) {
String possible = "island_" + next;
if (session.islands().stream().noneMatch(i -> i.id().equals(possible))) break;
next++;
}
String direction = word(c, "direction");
if (!ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
var island = session.create("island_" + next, "sanctuary", direction,
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, diameter, relief, minimumDistance);
describe(c, island, false);
return Command.SINGLE_SUCCESS;
});
}
private static void describe(CommandContext<CommandSourceStack> c, ExpansionIsland island, boolean preview) {
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + (preview ? "preview" : "created"),
island.id(), island.centerX(), island.centerZ(), label("climate", island.climate()), island.diameter(), label("relief", island.relief())), false);
}
private static int help(CommandContext<CommandSourceStack> context) {
context.getSource().sendSuccess(() -> Component.translatable(PREFIX + "help"), false);
return Command.SINGLE_SUCCESS;
}
private static String word(CommandContext<CommandSourceStack> context, String key) { return StringArgumentType.getString(context, key); }
private static Component label(String category, String id) { return Component.translatable(PREFIX + category + "." + id); }
@FunctionalInterface private interface Action { int run() throws Exception; }
private static int run(CommandContext<CommandSourceStack> context, Action action) {
try { return action.run(); }
catch (Exception exception) {
String reason = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage();
Component detail = reason.matches("[a-z_]+") ? Component.translatable(PREFIX + "error." + reason) : Component.literal(reason);
context.getSource().sendFailure(Component.translatable(PREFIX + "error", detail));
return 0;
}
}
}
@@ -0,0 +1,76 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.Arrays;
import net.minecraft.util.Interval;
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Versioned alpha.12 wrapper around a complete historical density, sampled in its own island frame. */
public record ExpansionDensity(DensityFunction source, ExpansionIsland island) implements DensityFunction {
public static final MapCodec<ExpansionDensity> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
DensityFunction.CODEC.fieldOf("source").forGetter(ExpansionDensity::source),
ExpansionIsland.CODEC.fieldOf("island").forGetter(ExpansionDensity::island)
).apply(i, ExpansionDensity::new));
// One bounded numeric/descriptor cache per worker. It holds no sampler, context or world.
private static final ThreadLocal<ColumnCache> COLUMNS = ThreadLocal.withInitial(ColumnCache::new);
@Override public DensitySampler compileSampler(CompileContext context) {
DensitySampler original = source.compileSampler(context);
return new DensitySampler() {
@Override public float sampleValue(SamplerContext sample, int x, int y, int z) {
if (y <= 0 || y >= 384 || !island.contains(x, z)) return -1.0F;
ExpansionRelief.Column column = COLUMNS.get().get(island, x, z);
double sy = ExpansionRelief.sourceY(island, column, y);
if (sy <= 0 || sy >= 384) return -1.0F;
int sx = ExpansionRelief.sourceX(island, x), sz = ExpansionRelief.sourceZ(island, z);
int lowerY = (int) Math.floor(sy);
float low = original.sampleValue(sample, sx, lowerY, sz);
double fraction = sy - lowerY;
// Below the root's upper transition this is precisely the old single sample.
// Fractional Y elsewhere is interpolated, avoiding one-block terraces from rounding.
float density = fraction == 0.0 ? low : (float) (low
+ (original.sampleValue(sample, sx, lowerY + 1, sz) - low) * fraction);
return ExpansionRelief.sculpt(island, column, y, Math.clamp(density, -1.0F, 1.0F));
}
@Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) {
DensitySampler.sampleVolumeNaive(sample, buffer, volume, this);
}
};
}
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) {
return new ExpansionDensity(rule.rewrite(source), island);
}
@Override public Interval range() { return Interval.of(-1.0F, 1.0F); }
@Override public int domainAxes() { return ALL_AXES; }
@Override public MapCodec<ExpansionDensity> codec() { return CODEC; }
private static final class ColumnCache {
private static final int SIZE = 256;
private ExpansionIsland selected;
private final int[] xs = new int[SIZE], zs = new int[SIZE];
private final ExpansionRelief.Column[] columns = new ExpansionRelief.Column[SIZE];
ExpansionRelief.Column get(ExpansionIsland island, int x, int z) {
if (selected != island) {
selected = island;
Arrays.fill(columns, null);
}
int hash = x * 0x9e3779b9 ^ Integer.rotateLeft(z * 0x85ebca6b, 13);
int slot = (hash ^ (hash >>> 16)) & (SIZE - 1);
if (columns[slot] == null || xs[slot] != x || zs[slot] != z) {
columns[slot] = ExpansionRelief.column(island, x, z);
xs[slot] = x; zs[slot] = z;
}
return columns[slot];
}
}
}
@@ -0,0 +1,78 @@
package fr.koka.sanctuary.expansion;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
/** Immutable alpha.12 region contract. Directions place islands; climate remains an explicit choice. */
public record ExpansionIsland(String id, String parent, String direction, String climate,
int diameter, int centerX, int centerZ, long seed, String relief, String status, int generation) {
public static final int GENERATION = 12;
public static final List<String> DIRECTIONS = List.of("north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest");
public static final List<String> CLIMATES = List.of("temperate", "boreal", "glacial", "cold_humid", "humid", "tropical", "warm", "arid", "dry", "cold_dry", "autumn", "cherry");
public static final List<String> RELIEFS = List.of("natural", "peaks", "canyon", "volcano");
public static final List<Integer> DIAMETERS = List.of(64, 128, 256, 512);
public static final int DECORATION_MARGIN = 32;
public static final int GENERATION_HALO = 176;
public static final Codec<ExpansionIsland> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.STRING.fieldOf("id").forGetter(ExpansionIsland::id),
Codec.STRING.fieldOf("parent").forGetter(ExpansionIsland::parent),
Codec.STRING.fieldOf("direction").forGetter(ExpansionIsland::direction),
Codec.STRING.fieldOf("climate").forGetter(ExpansionIsland::climate),
Codec.INT.fieldOf("diameter").forGetter(ExpansionIsland::diameter),
Codec.INT.fieldOf("center_x").forGetter(ExpansionIsland::centerX),
Codec.INT.fieldOf("center_z").forGetter(ExpansionIsland::centerZ),
Codec.LONG.fieldOf("seed").forGetter(ExpansionIsland::seed),
Codec.STRING.fieldOf("relief").forGetter(ExpansionIsland::relief),
Codec.STRING.fieldOf("status").forGetter(ExpansionIsland::status),
Codec.INT.fieldOf("generation").forGetter(ExpansionIsland::generation)
).apply(i, ExpansionIsland::new));
public ExpansionIsland {
if (!id.matches("[a-z][a-z0-9_]{0,31}") || !parent.matches("[a-z][a-z0-9_]{0,31}")) throw new IllegalArgumentException("Invalid island identifier");
if (!DIRECTIONS.contains(direction) || !CLIMATES.contains(climate) || !RELIEFS.contains(relief)) throw new IllegalArgumentException("Unknown island profile");
if (!(DIAMETERS.contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || generation == 13 && diameter == 1024)))) throw new IllegalArgumentException("Unsupported nominal diameter");
if (Math.abs((long) centerX) > 100_000 || Math.abs((long) centerZ) > 100_000 || (centerX & 15) != 0 || (centerZ & 15) != 0) throw new IllegalArgumentException("Invalid island centre");
if (!List.of("reserved", "ready").contains(status) || (generation != GENERATION && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
}
public static ExpansionIsland origin(int diameter, long seed) {
return origin(diameter, seed, GENERATION);
}
public static ExpansionIsland origin(int diameter, long seed, int generation) {
return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", generation);
}
public boolean origin() { return id.equals("sanctuary"); }
public double referenceDiameter() { return origin() && diameter == 724 ? Math.sqrt(2) * 512 : 512; }
public double horizontalScale() { return origin() ? 1.0 : diameter / referenceDiameter(); }
public double radius() { return diameter / 2.0; }
public int terrainRadius() { if (generation == 13 && origin()) return diameter == 724 ? 395 : diameter / 2 + 32; return origin() && diameter == 724 ? 395 : (int) Math.ceil(radius() * 1.125); }
public int reservedRadius() { return terrainRadius() + DECORATION_MARGIN + GENERATION_HALO; }
public boolean contains(int x, int z) { return Math.hypot((double) x - centerX, (double) z - centerZ) < terrainRadius(); }
public boolean ownsChunk(int chunkX, int chunkZ) {
int x = Math.clamp(centerX, chunkX * 16, chunkX * 16 + 15);
int z = Math.clamp(centerZ, chunkZ * 16, chunkZ * 16 + 15);
return Math.hypot((double) x - centerX, (double) z - centerZ) <= terrainRadius() + DECORATION_MARGIN;
}
public boolean conflicts(ExpansionIsland other) {
return Math.abs((long) centerX - other.centerX) <= reservedRadius() + other.reservedRadius()
&& Math.abs((long) centerZ - other.centerZ) <= reservedRadius() + other.reservedRadius();
}
public ExpansionIsland ready() { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, "ready", generation); }
public static String directionalClimate(String direction) {
return switch (direction) {
case "north" -> "boreal"; case "northeast" -> "cold_humid"; case "east" -> "humid";
case "southeast" -> "tropical"; case "south" -> "warm"; case "southwest" -> "arid";
case "west" -> "dry"; case "northwest" -> "cold_dry";
default -> throw new IllegalArgumentException("Unknown direction");
};
}
public static long deriveSeed(long seed, String id) {
long h = seed ^ 0xA12E7A510L;
for (int c : id.toCharArray()) h = (h ^ c) * 0x100000001b3L;
h = (h ^ (h >>> 30)) * 0xbf58476d1ce4e5b9L;
h = (h ^ (h >>> 27)) * 0x94d049bb133111ebL;
return h ^ (h >>> 31);
}
}
@@ -0,0 +1,266 @@
package fr.koka.sanctuary.expansion;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import java.io.IOException;
import java.io.StringReader;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** A failed or missing existing journal is never replaced with a fresh expansion history. */
public final class ExpansionJournal {
public static final String FILE_NAME = "expansions.json";
public static final int MAX_ISLANDS = 64;
private static final int MAX_BYTES = 131_072;
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private static final Set<String> JOURNAL_FIELDS = Set.of("schema", "world_seed", "root_diameter", "islands");
private static final Set<String> ISLAND_FIELDS = Set.of("id", "parent", "direction", "climate", "diameter", "center_x", "center_z", "seed", "relief", "status", "generation");
private final Path directory;
private final Path file;
private final long seed;
private final int rootDiameter;
private final int rootGeneration;
private final DirectorySync directorySync;
private List<ExpansionIsland> islands;
private boolean failed;
public ExpansionJournal(Path directory, long seed, int rootDiameter) throws IOException {
this(directory, seed, rootDiameter, ExpansionIsland.GENERATION);
}
public ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration) throws IOException {
this(directory, seed, rootDiameter, rootGeneration,
directorySyncFor(directory.getFileSystem().getSeparator(), ExpansionJournal::forceDirectory));
}
ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration, DirectorySync directorySync) throws IOException {
if (rootGeneration != 12 && rootGeneration != 13) throw new IOException("Unsupported root generation");
if (rootDiameter != 512 && rootDiameter != 724 && !(rootGeneration == 13 && rootDiameter == 1024))
throw new IOException("Unsupported expansion root diameter");
this.rootGeneration = rootGeneration;
this.directorySync = java.util.Objects.requireNonNull(directorySync);
this.directory = directory.toAbsolutePath().normalize();
this.file = this.directory.resolve(FILE_NAME);
this.seed = seed;
this.rootDiameter = rootDiameter;
if (Files.exists(this.directory, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(this.directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Expansion journal path is not a directory");
this.islands = read();
} else {
// Creating the directory is the bootstrap marker. An interrupted bootstrap requires repair.
Files.createDirectories(this.directory.getParent());
Files.createDirectory(this.directory);
this.islands = List.of(ExpansionIsland.origin(rootDiameter, seed, rootGeneration));
persist(this.islands);
}
}
public synchronized List<ExpansionIsland> islands() { return islands; }
/** False after uncertain persistence or external damage; only a successful reload can recover. */
public synchronized boolean healthy() { return !failed; }
public synchronized void commit(List<ExpansionIsland> proposed) throws IOException {
if (failed) throw new IOException("Expansion journal failed earlier; reload and inspect it before another expansion");
List<ExpansionIsland> next = validate(proposed);
if (next.size() < islands.size()) throw new IOException("Existing expansion regions cannot be removed");
for (int i = 0; i < islands.size(); i++) {
ExpansionIsland previous = islands.get(i), replacement = next.get(i);
if (!previous.equals(replacement) && !(previous.status().equals("reserved") && previous.ready().equals(replacement))) {
throw new IOException("Existing expansion region cannot be changed: " + previous.id());
}
}
for (int i = islands.size(); i < next.size(); i++) {
if (!next.get(i).status().equals("reserved")) throw new IOException("New expansion region must first be reserved");
}
// Detect another writer, a removed file or external damage before overwriting any history.
try {
if (!read().equals(islands)) throw new IOException("Expansion journal changed outside this instance");
} catch (IOException e) {
failed = true;
throw e;
}
if (next.equals(islands)) return;
persist(next);
islands = next;
}
private void persist(List<ExpansionIsland> next) throws IOException {
Path temporary = null;
try {
byte[] bytes = (JSON.toJson(encode(next)) + "\n").getBytes(StandardCharsets.UTF_8);
temporary = Files.createTempFile(directory, ".expansions-", ".tmp");
try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
// No fallback to a non-atomic replacement: failure must remain visible to the caller.
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
temporary = null;
directorySync.force(directory);
if (!read().equals(next)) throw new IOException("Expansion journal verification disagrees with the committed plan");
} catch (IOException | RuntimeException e) {
failed = true;
if (e instanceof IOException io) throw io;
throw new IOException("Could not commit expansion journal", e);
} finally {
if (temporary != null) {
try { Files.deleteIfExists(temporary); } catch (IOException ignored) { /* The original failure remains authoritative. */ }
}
}
}
@FunctionalInterface
interface DirectorySync { void force(Path directory) throws IOException; }
static DirectorySync directorySyncFor(String separator, DirectorySync supportedSync) {
// Windows FileChannel cannot open directory handles. Only this additional
// directory flush is omitted: file force, atomic replacement and read-back remain mandatory.
return "\\".equals(separator) ? directory -> {} : supportedSync;
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
}
private List<ExpansionIsland> read() throws IOException {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Existing expansion directory has no regular journal file");
if (Files.size(file) > MAX_BYTES) throw new IOException("Expansion journal exceeds its size limit");
try (JsonReader reader = new JsonReader(new StringReader(Files.readString(file, StandardCharsets.UTF_8)))) {
reader.setStrictness(Strictness.STRICT);
JsonObject root = readJson(reader, 0).getAsJsonObject();
if (reader.peek() != JsonToken.END_DOCUMENT) throw new IOException("Trailing data in expansion journal");
requireFields(root, JOURNAL_FIELDS);
if (integer(root, "schema") != 1 || number(root, "world_seed").longValueExact() != seed || integer(root, "root_diameter") != rootDiameter) {
throw new IOException("Expansion journal schema, world seed or root diameter does not match");
}
JsonArray entries = root.getAsJsonArray("islands");
List<ExpansionIsland> decoded = new ArrayList<>();
for (JsonElement element : entries) {
JsonObject entry = element.getAsJsonObject();
requireFields(entry, ISLAND_FIELDS);
decoded.add(new ExpansionIsland(string(entry, "id"), string(entry, "parent"), string(entry, "direction"), string(entry, "climate"),
integer(entry, "diameter"), integer(entry, "center_x"), integer(entry, "center_z"), number(entry, "seed").longValueExact(),
string(entry, "relief"), string(entry, "status"), integer(entry, "generation")));
}
return validate(decoded);
} catch (RuntimeException e) {
throw new IOException("Invalid expansion journal", e);
}
}
private List<ExpansionIsland> validate(List<ExpansionIsland> proposed) throws IOException {
if (proposed == null || proposed.isEmpty() || proposed.size() > MAX_ISLANDS) throw new IOException("Expansion journal must contain 1 to 64 regions");
List<ExpansionIsland> checked;
try { checked = List.copyOf(proposed); } catch (RuntimeException e) { throw new IOException("Invalid expansion entries", e); }
if (!checked.getFirst().equals(ExpansionIsland.origin(rootDiameter, seed, rootGeneration))) throw new IOException("Sanctuary root region is immutable");
Map<String, ExpansionIsland> previous = new HashMap<>();
previous.put("sanctuary", checked.getFirst());
for (int i = 1; i < checked.size(); i++) {
ExpansionIsland island = checked.get(i);
if (previous.containsKey(island.id())) throw new IOException("Duplicate expansion identifier: " + island.id());
ExpansionIsland parent = previous.get(island.parent());
if (parent == null || !parent.status().equals("ready")) throw new IOException("Expansion parent must be an earlier ready region: " + island.parent());
if (island.seed() != ExpansionIsland.deriveSeed(seed, island.id())) throw new IOException("Expansion seed differs from its saved identity: " + island.id());
long dx = (long) island.centerX() - parent.centerX(), dz = (long) island.centerZ() - parent.centerZ();
if (!followsDirection(island.direction(), dx, dz)) throw new IOException("Expansion position does not follow its direction: " + island.id());
for (ExpansionIsland other : previous.values()) if (island.conflicts(other)) throw new IOException("Expansion reservations overlap: " + island.id() + " / " + other.id());
previous.put(island.id(), island);
}
return checked;
}
private static boolean followsDirection(String direction, long dx, long dz) {
return switch (direction) {
case "north" -> dx == 0 && dz < 0;
case "northeast" -> dx > 0 && dz == -dx;
case "east" -> dx > 0 && dz == 0;
case "southeast" -> dx > 0 && dz == dx;
case "south" -> dx == 0 && dz > 0;
case "southwest" -> dx < 0 && dz == -dx;
case "west" -> dx < 0 && dz == 0;
case "northwest" -> dx < 0 && dz == dx;
default -> false;
};
}
private JsonObject encode(List<ExpansionIsland> entries) {
JsonObject root = new JsonObject();
root.addProperty("schema", 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", rootDiameter);
JsonArray array = new JsonArray();
for (ExpansionIsland island : entries) {
JsonObject entry = new JsonObject();
entry.addProperty("id", island.id()); entry.addProperty("parent", island.parent());
entry.addProperty("direction", island.direction()); entry.addProperty("climate", island.climate());
entry.addProperty("diameter", island.diameter()); entry.addProperty("center_x", island.centerX()); entry.addProperty("center_z", island.centerZ());
entry.addProperty("seed", island.seed()); entry.addProperty("relief", island.relief()); entry.addProperty("status", island.status());
entry.addProperty("generation", island.generation()); array.add(entry);
}
root.add("islands", array);
return root;
}
private static JsonElement readJson(JsonReader reader, int depth) throws IOException {
if (depth > 4) throw new IOException("Expansion journal nesting exceeds its schema");
return switch (reader.peek()) {
case BEGIN_OBJECT -> {
reader.beginObject(); JsonObject object = new JsonObject();
while (reader.hasNext()) {
String name = reader.nextName();
if (object.has(name)) throw new IOException("Duplicate JSON field: " + name);
if (object.size() >= 16) throw new IOException("Too many journal fields");
object.add(name, readJson(reader, depth + 1));
}
reader.endObject(); yield object;
}
case BEGIN_ARRAY -> {
reader.beginArray(); JsonArray array = new JsonArray();
while (reader.hasNext()) {
if (array.size() >= MAX_ISLANDS) throw new IOException("Too many expansion regions");
array.add(readJson(reader, depth + 1));
}
reader.endArray(); yield array;
}
case STRING -> new JsonPrimitive(reader.nextString());
case NUMBER -> new JsonPrimitive(new BigDecimal(reader.nextString()));
case BOOLEAN -> new JsonPrimitive(reader.nextBoolean());
case NULL -> { reader.nextNull(); yield JsonNull.INSTANCE; }
default -> throw new IOException("Unexpected expansion journal token");
};
}
private static void requireFields(JsonObject value, Set<String> fields) throws IOException {
if (!value.keySet().equals(fields)) throw new IOException("Missing or unknown expansion journal fields");
}
private static String string(JsonObject value, String name) throws IOException {
JsonPrimitive primitive = value.getAsJsonPrimitive(name);
if (!primitive.isString()) throw new IOException("Expected string field: " + name);
return primitive.getAsString();
}
private static BigDecimal number(JsonObject value, String name) throws IOException {
JsonPrimitive primitive = value.getAsJsonPrimitive(name);
if (!primitive.isNumber()) throw new IOException("Expected numeric field: " + name);
return primitive.getAsBigDecimal();
}
private static int integer(JsonObject value, String name) throws IOException { return number(value, name).intValueExact(); }
}
@@ -0,0 +1,56 @@
package fr.koka.sanctuary.expansion;
import java.util.ArrayList;
import java.util.List;
/** Pure placement along a chosen ray; no chunk is inspected or generated here. */
public final class ExpansionPlacement {
public static final int MAX_ATTEMPTS = 32;
public static final int MAX_DISTANCE = 100_000;
private static final int[] DX = {0, 1, 1, 1, 0, -1, -1, -1};
private static final int[] DZ = {-1, -1, 0, 1, 1, 1, 0, -1};
private ExpansionPlacement() { }
public static List<ExpansionIsland> candidates(long worldSeed, List<ExpansionIsland> islands,
String id, String parentId, String direction, String climate, int diameter, String relief,
int minimumDistance) {
if (id == null || parentId == null || !id.matches("[a-z][a-z0-9_]{0,31}") || !parentId.matches("[a-z][a-z0-9_]{0,31}"))
throw new IllegalArgumentException("invalid_id");
if (id.equals("sanctuary")) throw new IllegalArgumentException("reserved_id");
if (direction == null || !ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
if (climate == null || !ExpansionIsland.CLIMATES.contains(climate)) throw new IllegalArgumentException("invalid_climate");
if (!ExpansionIsland.DIAMETERS.contains(diameter)) throw new IllegalArgumentException("invalid_diameter");
if (relief == null || !ExpansionIsland.RELIEFS.contains(relief)) throw new IllegalArgumentException("invalid_relief");
validateDistance(minimumDistance);
var parent = islands.stream().filter(i -> i.id().equals(parentId)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("unknown_island"));
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
var sample = new ExpansionIsland(id, parentId, direction, climate, diameter, 0, 0,
ExpansionIsland.deriveSeed(worldSeed, id), relief, "reserved", ExpansionIsland.GENERATION);
int step = ((parent.reservedRadius() + sample.reservedRadius() + 63) / 16) * 16;
boolean diagonal = DX[ordinal] != 0 && DZ[ordinal] != 0;
int requestedComponent = (int) Math.ceil(minimumDistance / (diagonal ? Math.sqrt(2) : 1.0) / 16) * 16;
int start = Math.max(step, requestedComponent);
List<ExpansionIsland> candidates = new ArrayList<>();
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
long component = (long) start + (long) step * attempt;
long x = parent.centerX() + DX[ordinal] * component;
long z = parent.centerZ() + DZ[ordinal] * component;
if (Math.abs(x) > MAX_DISTANCE || Math.abs(z) > MAX_DISTANCE) break;
var candidate = new ExpansionIsland(id, parentId, direction, climate, diameter, (int) x, (int) z,
sample.seed(), relief, "reserved", ExpansionIsland.GENERATION);
if (islands.stream().noneMatch(candidate::conflicts)) candidates.add(candidate);
}
return List.copyOf(candidates);
}
public static void validateDistance(int minimumDistance) {
if (minimumDistance < 0 || minimumDistance > MAX_DISTANCE) throw new IllegalArgumentException("invalid_distance");
}
public static double distanceFromParent(ExpansionIsland island, ExpansionIsland parent) {
return Math.hypot((double) island.centerX() - parent.centerX(), (double) island.centerZ() - parent.centerZ());
}
}
@@ -0,0 +1,136 @@
package fr.koka.sanctuary.expansion;
/** Pure alpha.12 coordinate deformation. Existing generators and the lower root island are unchanged. */
public final class ExpansionRelief {
public static final double VERTICAL_ANCHOR = 170.0;
public static final double UPPER_START = 160.0;
public static final double UPPER_TRANSITION = 144.0;
public static final double CRATER_FLOOR = 188.0;
/** Weights describe geography only; no block, liquid or structure is placed here. */
public record Column(double uplift, double canyonWeight, double craterWeight) {}
private ExpansionRelief() {}
public static double horizontalScale(ExpansionIsland island) {
// The public 724 is rounded; the original population_10 frame is already sqrt(2)*512.
return island.origin() ? 1.0 : island.horizontalScale();
}
public static double verticalScale(ExpansionIsland island) {
return island.origin() ? 1.0 : Math.max(0.4, Math.sqrt(island.horizontalScale()));
}
public static int sourceX(ExpansionIsland island, int x) {
return (int) Math.round(((double) x - island.centerX()) / horizontalScale(island));
}
public static int sourceZ(ExpansionIsland island, int z) {
return (int) Math.round(((double) z - island.centerZ()) / horizontalScale(island));
}
/** Reference Y before upper deformation; the inverse projects a source altitude into the island. */
public static double referenceY(ExpansionIsland island, double y) {
return VERTICAL_ANCHOR + (y - VERTICAL_ANCHOR) / verticalScale(island);
}
public static double worldY(ExpansionIsland island, double referenceY) {
return VERTICAL_ANCHOR + (referenceY - VERTICAL_ANCHOR) * verticalScale(island);
}
public static double sourceY(ExpansionIsland island, Column column, double y) {
double reference = referenceY(island, y);
// |uplift| <= 48 and max smoothstep slope = 1.5/144, so this map is strictly
// increasing. The whole source field, including its upper envelope, moves together.
return reference - column.uplift() * smoothstep((reference - UPPER_START) / UPPER_TRANSITION);
}
/** Additional cuts leave the source void empty, and never produce an unbounded positive density. */
public static float sculpt(ExpansionIsland island, Column column, double y, float source) {
if (source <= 0) return source;
double reference = referenceY(island, y);
double canyon = 2.1 * column.canyonWeight() * smoothstep((reference - 120.0) / 34.0);
double crater = 2.1 * column.craterWeight() * smoothstep((reference - CRATER_FLOOR) / 14.0);
return (float) Math.clamp(source - Math.max(canyon, crater), -1.0, 1.0);
}
/** True only in the inner volcanic bowl; the material pass may use this without guessing from blocks. */
public static boolean inCrater(ExpansionIsland island, int x, int z) {
return island.relief().equals("volcano") && column(island, x, z).craterWeight() >= 0.95;
}
public static int craterFloorY(ExpansionIsland island) {
return (int) Math.floor(worldY(island, CRATER_FLOOR));
}
public static Column column(ExpansionIsland island, int x, int z) {
double dx = (double) x - island.centerX(), dz = (double) z - island.centerZ();
double radius = island.radius();
double radial = Math.hypot(dx, dz) / radius;
double edge = 1.0 - smoothstep((radial - 0.70) / 0.30);
if (edge <= 0) return new Column(0, 0, 0);
long seed = island.seed();
double wavelength = Math.max(16.0, radius * 0.40);
double broad = noise(seed, dx / wavelength, dz / wavelength);
double detail = noise(seed ^ 0xA12DE7A1L, dx / (wavelength * 0.43), dz / (wavelength * 0.43));
double uplift;
double canyon = 0.0, crater = 0.0;
switch (island.relief()) {
case "natural" -> {
double ridge = Math.pow(1.0 - Math.abs(broad), 4.0);
uplift = Math.clamp(12.0 * broad + 5.0 * detail + 10.0 * Math.max(0.0, ridge - 0.5), -20.0, 20.0);
}
case "peaks" -> {
double ridge = Math.pow(1.0 - Math.abs(broad), 3.0);
uplift = Math.clamp(10.0 * detail + 44.0 * ridge, -16.0, 48.0);
}
case "canyon" -> {
uplift = 8.0 * detail + 16.0 * Math.max(0.0, broad);
double angle = unit(seed ^ 0xCA1201L) * Math.PI * 2.0;
double along = dx * Math.cos(angle) + dz * Math.sin(angle);
double sideways = -dx * Math.sin(angle) + dz * Math.cos(angle);
double phase = unit(seed ^ 0xCA1202L) * Math.PI * 2.0;
double curve = radius * 0.085 * (Math.sin(along / (radius * 0.36) + phase) - Math.sin(phase));
double halfWidth = Math.clamp(radius * 0.035, 2.25, 12.0);
canyon = (1.0 - smoothstep((Math.abs(sideways - curve) - halfWidth) / (halfWidth * 0.75)))
* (1.0 - smoothstep((Math.abs(along) / radius - 0.62) / 0.20));
}
case "volcano" -> {
// A raised annulus surrounds a depressed centre; this profile changes the
// silhouette and cuts an actual crater, independently of its later palette.
double coneRadius = radial + 0.018 * detail;
double cone = Math.pow(Math.max(0.0, 1.0 - coneRadius / 0.68), 1.35);
double hollow = 1.0 - smoothstep((radial - 0.08) / 0.15);
uplift = Math.clamp(48.0 * cone * (1.0 - 0.90 * hollow) + 3.0 * detail, -6.0, 48.0);
crater = 1.0 - smoothstep((radial - 0.10) / 0.09);
}
default -> throw new IllegalArgumentException("Unknown relief " + island.relief());
}
return new Column(uplift * edge, canyon * edge, crater * edge);
}
/** Smooth coherent value noise: no mutable RNG, global map, chunk boundaries or order dependence. */
private static double noise(long seed, double x, double z) {
long ix = (long) Math.floor(x), iz = (long) Math.floor(z);
double tx = smoothstep(x - ix), tz = smoothstep(z - iz);
double a = lattice(seed, ix, iz), b = lattice(seed, ix + 1, iz);
double c = lattice(seed, ix, iz + 1), d = lattice(seed, ix + 1, iz + 1);
return (a + (b - a) * tx) + ((c + (d - c) * tx) - (a + (b - a) * tx)) * tz;
}
private static double lattice(long seed, long x, long z) {
return unit(seed ^ x * 0x9e3779b97f4a7c15L ^ z * 0x632be59bd9b4e019L) * 2.0 - 1.0;
}
private static double unit(long value) {
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
}
private static double smoothstep(double value) {
double t = Math.clamp(value, 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
}
@@ -0,0 +1,270 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.SanctuaryMod;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource;
/** Server-owned append-only admission and bounded preparation queue. No existing chunk is regenerated. */
public final class ExpansionRuntime {
private static final Map<ServerLevel, Session> SESSIONS = new HashMap<>();
private static final TicketType PREPARATION = new TicketType(0L, 2 | 8);
private ExpansionRuntime() {}
public static void register() {
Registry.register(BuiltInRegistries.TICKET_TYPE, SanctuaryMod.id("expansion_preparation"), PREPARATION);
ServerLevelEvents.LOAD.register((server, level) -> {
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)) return;
if (level.dimension() != Level.OVERWORLD) throw new IllegalStateException("The expansion laboratory is Overworld only");
try {
var directory = server.getWorldPath(LevelResource.ROOT).resolve("data/" + generator.journalDirectory());
if (server.getWorldData().overworldData().isInitialized()
&& !java.nio.file.Files.isDirectory(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS))
throw new IOException("Initialized laboratory world is missing its expansion journal directory");
var journal = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
generator.initialize(level, journal.islands());
var session = new Session(level, generator, journal);
SESSIONS.put(level, session);
for (var island : journal.islands()) if (island.status().equals("reserved")) session.enqueue(island);
} catch (IOException e) {
throw new IllegalStateException("Cannot safely open the Sanctuary expansion journal", e);
}
});
ServerTickEvents.END_LEVEL_TICK.register(level -> {
Session session = SESSIONS.get(level);
if (session != null) session.tick();
});
ServerLevelEvents.UNLOAD.register((server, level) -> {
Session session = SESSIONS.remove(level);
if (session != null) session.releaseTicket();
});
ServerLifecycleEvents.SERVER_STOPPED.register(server -> SESSIONS.clear());
}
public static Session session(ServerLevel level) {
Session session = SESSIONS.get(level);
if (session == null) throw new IllegalArgumentException("laboratory_required");
return session;
}
public static final class Session {
private final ServerLevel level;
private final ExpansionChunkGenerator generator;
private final ExpansionJournal journal;
private final ArrayDeque<Job> jobs = new ArrayDeque<>();
private String failure = "";
private boolean journalUnavailableNotified;
private Session(ServerLevel level, ExpansionChunkGenerator generator, ExpansionJournal journal) {
this.level = level; this.generator = generator; this.journal = journal;
}
public List<ExpansionIsland> islands() { return journal.islands(); }
public ExpansionIsland island(String id) {
return islands().stream().filter(i -> i.id().equals(id)).findFirst().orElseThrow(() -> new IllegalArgumentException("unknown_island"));
}
public String progress() {
Job job = jobs.peek();
return job == null ? (failure.isEmpty() ? "idle" : failure) : job.island.id() + ": " + job.index + "/" + job.chunks.size();
}
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief) {
return candidate(id, parentId, direction, climate, diameter, relief, 0);
}
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief,
int minimumDistance) {
return ExpansionPlacement.candidates(level.getSeed(), islands(), id, parentId, direction, climate, diameter, relief, minimumDistance)
.stream().findFirst().orElseThrow(() -> new IllegalArgumentException("no_free_space"));
}
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief) throws IOException {
return create(id, parent, direction, climate, diameter, relief, 0);
}
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief,
int minimumDistance) throws IOException {
long deadline = ExpansionAdmission.searchDeadline();
requireHealthyJournal();
ExpansionPlacement.validateDistance(minimumDistance);
var existing = islands().stream().filter(i -> i.id().equals(id)).findFirst();
if (existing.isPresent()) {
var value = existing.get();
if (!value.parent().equals(parent) || !value.direction().equals(direction) || !value.climate().equals(climate)
|| value.diameter() != diameter || !value.relief().equals(relief)
|| (minimumDistance > 0 && ExpansionPlacement.distanceFromParent(value, island(parent)) < minimumDistance))
throw new IllegalArgumentException("id_conflict");
return value;
}
if (islands().size() >= 64) throw new IllegalArgumentException("island_limit");
var candidates = ExpansionPlacement.candidates(level.getSeed(), islands(), id, parent, direction, climate, diameter, relief, minimumDistance);
for (var value : candidates) {
ExpansionAdmission.checkDeadline(deadline);
try {
// Existing void is preserved: only proven occupation advances along the same ray.
ExpansionAdmission.verify(level, value, deadline);
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
var next = new ArrayList<>(islands());
next.add(value);
ExpansionAdmission.verifyHolders(level, value, deadline);
commitJournal(next); // Durability and re-read come before worldgen sees the island.
generator.setIslands(journal.islands());
failure = "";
enqueue(value);
return value;
} catch (ExpansionAdmission.OccupiedException occupied) {
// IO failures, timeouts and unsuitable terrain stop this request without a new draw.
} finally {
generator.discardUnpublishedContext(value);
}
}
ExpansionAdmission.checkDeadline(deadline);
throw new IllegalArgumentException("no_free_space");
}
public void resume(String id) {
requireHealthyJournal();
var island = island(id);
if (!island.status().equals("reserved")) return;
failure = "";
enqueue(island);
}
private void enqueue(ExpansionIsland island) {
if (jobs.stream().noneMatch(j -> j.island.id().equals(island.id()))) jobs.add(new Job(island));
}
private void requireHealthyJournal() {
if (!journal.healthy()) {
stopForJournalFailure(null);
throw new IllegalStateException("journal_unavailable");
}
}
private void commitJournal(List<ExpansionIsland> next) throws IOException {
try {
journal.commit(next);
} catch (IOException exception) {
if (!journal.healthy()) {
stopForJournalFailure(exception);
throw new IllegalStateException("journal_unavailable", exception);
}
throw exception;
}
}
private void stopForJournalFailure(Throwable cause) {
releaseTicket();
jobs.clear(); // Saved reservations remain intact and are enqueued only after a verified reload.
failure = "journal_unavailable: reload required";
if (journalUnavailableNotified) return;
journalUnavailableNotified = true;
SanctuaryMod.LOGGER.error("Expansion journal unavailable; all preparation stopped and reload is required", cause);
for (var player : level.players()) if (player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER))
player.sendSystemMessage(Component.translatable("sanctuary.expansion.journal_unavailable"));
}
/** Base-terrain prospecting is read-only: no chunk requests or writes. */
public BlockPos probe(ExpansionIsland island) {
var delegate = generator.delegate(island);
var random = generator.randomState(island);
int stride = Math.max(4, island.diameter() / 32);
int limit = Math.max(stride, (int) (island.radius() * 0.75));
for (int r = 0; r <= limit; r += stride) for (int x = -r; x <= r; x += stride) for (int z = -r; z <= r; z += stride) {
if (Math.max(Math.abs(x), Math.abs(z)) != r) continue;
int wx = island.centerX() + x, wz = island.centerZ() + z;
int y = delegate.getBaseHeight(wx, wz, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, level, random);
if (y > 24 && y < 365) return new BlockPos(wx, y, wz);
}
return null;
}
public BlockPos landing(String id) {
var island = island(id);
if (!island.status().equals("ready")) throw new IllegalArgumentException("not_ready");
int stride = Math.max(4, island.diameter() / 32);
int limit = (int) (island.radius() * 0.85);
for (int r = 0; r <= limit; r += stride) for (int x = -r; x <= r; x += stride) for (int z = -r; z <= r; z += stride) {
if (Math.max(Math.abs(x), Math.abs(z)) != r) continue;
int wx = island.centerX() + x, wz = island.centerZ() + z;
// Only this already-open island is loaded; no block is changed for teleportation.
level.getChunk(wx >> 4, wz >> 4);
int top = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, wx, wz);
for (int y = top; y > 24; y--) {
var pos = new BlockPos(wx, y, wz);
if (safeLanding(level, pos)) return pos;
}
}
throw new IllegalArgumentException("no_landing");
}
private void releaseTicket() {
Job job = jobs.peek();
if (job != null && job.ticket != null) {
level.getChunkSource().removeTicketWithRadius(PREPARATION, job.ticket, 0);
job.ticket = null;
}
}
private void tick() {
if (!journal.healthy()) {
stopForJournalFailure(null);
return;
}
Job job = jobs.peek();
if (job == null) return;
try {
if (job.pending != null) {
if (!job.pending.isDone()) return;
job.pending.join();
if (level.getChunkSource().getChunkNow(job.ticket.x(), job.ticket.z()) == null) throw new IllegalStateException("Chunk did not reach FULL");
releaseTicket();
job.pending = null;
job.index++;
}
if (job.index == job.chunks.size()) {
var next = islands().stream().map(i -> i.id().equals(job.island.id()) ? i.ready() : i).toList();
commitJournal(next);
generator.setIslands(journal.islands());
jobs.remove();
SanctuaryMod.LOGGER.info("Expansion {} ready: {} FULL chunks", job.island.id(), job.index);
for (var player : level.players()) if (player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER))
player.sendSystemMessage(Component.translatable("sanctuary.expansion.ready", job.island.id(), job.index, job.island.id()));
return;
}
job.ticket = job.chunks.get(job.index);
job.pending = level.getChunkSource().addTicketAndLoadWithRadius(PREPARATION, job.ticket, 0);
} catch (Exception exception) {
if (!journal.healthy()) {
stopForJournalFailure(exception);
return;
}
releaseTicket();
failure = job.island.id() + ": " + exception.getMessage();
jobs.remove();
SanctuaryMod.LOGGER.error("Expansion preparation stopped; reservation retained for resume: {}", job.island.id(), exception);
}
}
}
private static final class Job {
final ExpansionIsland island;
final List<ChunkPos> chunks = new ArrayList<>();
int index;
ChunkPos ticket;
CompletableFuture<?> pending;
Job(ExpansionIsland island) {
this.island = island;
int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
for (int x = (island.centerX() - radius) >> 4; x <= (island.centerX() + radius) >> 4; x++)
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++) chunks.add(new ChunkPos(x, z));
}
}
public static boolean safeLanding(ServerLevel level, BlockPos feet) {
var floor = level.getBlockState(feet.below());
return floor.isCollisionShapeFullBlock(level, feet.below())
&& floor.getFluidState().isEmpty()
&& !floor.is(net.minecraft.tags.BlockTags.LEAVES)
&& !floor.is(net.minecraft.world.level.block.Blocks.MAGMA_BLOCK)
&& !floor.is(net.minecraft.world.level.block.Blocks.CACTUS)
&& level.getBlockState(feet).isAir() && level.getBlockState(feet.above()).isAir();
}
}
@@ -0,0 +1,191 @@
package fr.koka.sanctuary.expansion;
import fr.koka.sanctuary.SanctuaryMod;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.Holder;
import net.minecraft.core.BlockPos;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
/** Plan native starts before terrain and references; decoration and structure persistence remain vanilla. */
public final class ExpansionStructures {
private static final boolean DIAGNOSTICS = Boolean.getBoolean("sanctuary.test.expansion")
|| Boolean.getBoolean("sanctuary.expansion.structureDiagnostics");
private static final int CELL_CHUNKS = 8;
private static final List<String> VILLAGES = List.of("village_plains", "village_desert", "village_savanna", "village_snowy", "village_taiga");
private static final List<String> LANDMARKS = List.of("desert_pyramid", "jungle_pyramid", "swamp_hut");
private static final List<String> CAMPS = List.of(
"forest", "birch_forest", "old_growth_birch_forest", "old_growth_pine_taiga", "old_growth_spruce_taiga",
"pale_garden", "savanna", "dappled_forest", "snowy_taiga", "cherry_grove", "swamp", "sparse_jungle",
"meadow", "wooded_badlands", "flower_forest", "windswept_forest", "taiga", "bamboo_jungle"
);
private ExpansionStructures() {}
public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries,
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> level) {
if (!manager.shouldGenerateStructures()) return;
int blockX = chunk.getPos().getMiddleBlockX(), blockZ = chunk.getPos().getMiddleBlockZ();
ExpansionIsland island = generator.islandAt(blockX, blockZ);
if (island == null) return;
long roll = candidateRoll(island, chunk.getPos().x(), chunk.getPos().z());
if (roll == 0 || Long.remainderUnsigned(roll, 100) >= 72) return;
RandomState randomState = generator.randomState(island);
int surfaceY = generator.getBaseHeight(blockX, blockZ, Heightmap.Types.WORLD_SURFACE_WG, chunk, randomState) - 1;
if (surfaceY < 48) {
diagnostic(island, chunk, "no-surface", "height=" + surfaceY);
return;
}
var sampler = randomState.createClimateSampler(SamplerContext.builder().enableCaches().build());
Holder<Biome> biome = generator.structureBiome(generator.getBiomeSource().createResolver(sampler)
.getNoiseBiome(blockX >> 2, surfaceY >> 2, blockZ >> 2));
var registry = registries.lookupOrThrow(Registries.STRUCTURE);
List<Holder<Structure>> villages = new ArrayList<>();
// Villages remain optional and are never squeezed into the small formats.
if (island.diameter() >= 256 && Long.remainderUnsigned(roll >>> 9, 100) < 36) {
for (String name : VILLAGES) addMatching(villages, registry.get(Identifier.fromNamespaceAndPath("minecraft", name)).orElse(null), biome);
}
List<Holder<Structure>> landmarks = new ArrayList<>();
for (String name : LANDMARKS) addMatching(landmarks, registry.get(Identifier.fromNamespaceAndPath("minecraft", name)).orElse(null), biome);
for (String suffix : CAMPS) addMatching(landmarks,
registry.get(Identifier.fromNamespaceAndPath("minecraft", "abandoned_camp_" + suffix)).orElse(null), biome);
if (villages.isEmpty() && landmarks.isEmpty()) {
diagnostic(island, chunk, "no-compatible-structure", "biome=" + biome.unwrapKey().orElseThrow().identifier() + " height=" + surfaceY);
return;
}
// An eligible but unsupported village must not suppress a smaller compatible camp.
// Both groups keep their original biome and foundation checks; one accepted start ends the site.
for (List<Holder<Structure>> candidates : List.of(villages, landmarks)) {
if (candidates.isEmpty()) continue;
int selectedIndex = (int) Long.remainderUnsigned(roll >>> 19, candidates.size());
for (int attempt = 0; attempt < candidates.size(); attempt++) {
Holder<Structure> selected = candidates.get((selectedIndex + attempt) % candidates.size());
Structure structure = selected.value();
StructureStart previous = manager.getStartForStructure(structure, chunk);
if (previous != null && previous.isValid()) return;
String name = selected.unwrapKey().orElseThrow().identifier().toString();
StructureStart start = structure.generate(selected, level, registries, generator, generator.getBiomeSource(),
sampler, randomState, templates, island.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(),
chunk, candidateBiome -> structure.biomes().contains(generator.structureBiome(candidateBiome)));
if (!start.isValid()) {
diagnostic(island, chunk, "native-rejected", name + " biome=" + biome.unwrapKey().orElseThrow().identifier() + " height=" + surfaceY);
continue;
}
boolean temple = name.equals("minecraft:desert_pyramid") || name.equals("minecraft:jungle_pyramid");
String rejection = supportRejection(generator, island, chunk, randomState, start, temple);
if (rejection != null) {
diagnostic(island, chunk, "support-rejected", name + " " + rejection + " bounds=" + start.getBoundingBox());
continue;
}
manager.setStartForStructure(structure, start, chunk);
diagnostic(island, chunk, "accepted", name + " pieces=" + start.getPieces().size() + " bounds=" + start.getBoundingBox());
return;
}
}
}
private static void addMatching(List<Holder<Structure>> candidates, Holder<Structure> structure, Holder<Biome> biome) {
if (structure != null && structure.value().biomes().contains(biome)) candidates.add(structure);
}
/** One possible start in each 128-block square, salted by the persisted island seed. */
private static long candidateRoll(ExpansionIsland island, int chunkX, int chunkZ) {
int localX = chunkX - Math.floorDiv(island.centerX(), 16), localZ = chunkZ - Math.floorDiv(island.centerZ(), 16);
int cellX = Math.floorDiv(localX, CELL_CHUNKS), cellZ = Math.floorDiv(localZ, CELL_CHUNKS);
long roll = mix(island.seed() ^ 0xA1257A0C7L ^ (cellX * 0x9E3779B97F4A7C15L) ^ (cellZ * 0xC2B2AE3D27D4EB4FL));
if (Math.floorMod(localX, CELL_CHUNKS) != (int) Long.remainderUnsigned(roll >>> 3, CELL_CHUNKS)
|| Math.floorMod(localZ, CELL_CHUNKS) != (int) Long.remainderUnsigned(roll >>> 12, CELL_CHUNKS)) return 0;
return roll;
}
private static String supportRejection(ExpansionChunkGenerator generator, ExpansionIsland island, ChunkAccess chunk,
RandomState randomState, StructureStart start, boolean temple) {
// Native beard adaptation already expands this box by 12 blocks when applicable.
BoundingBox total = start.getBoundingBox();
if (island.origin() && generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified) {
var binding = unified.starterContext();
int centreX = (total.minX() + total.maxX()) / 2, centreZ = (total.minZ() + total.maxZ()) / 2;
int radius = Math.max(total.getXSpan(), total.getZSpan()) / 2 + 3;
if (fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.protects(binding.generator(), binding.random(),
new BlockPos(centreX, total.minY(), centreZ), radius, total.getYSpan() + 16, 26)) return "protected-hydrology";
}
if (!inside(island, total.minX(), total.minZ(), 4) || !inside(island, total.maxX(), total.minZ(), 4)
|| !inside(island, total.minX(), total.maxZ(), 4) || !inside(island, total.maxX(), total.maxZ(), 4)) return "outside-island";
if (total.minY() < chunk.getMinY() || total.maxY() > chunk.getMaxY() - 4) return "outside-height";
Map<Long, Ground> columns = new HashMap<>();
int depth = temple ? 24 : 12;
int allMin = Integer.MAX_VALUE, allMax = Integer.MIN_VALUE;
for (StructurePiece piece : start.getPieces()) {
BoundingBox box = piece.getBoundingBox();
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
for (int z = box.minZ() - 1; z <= box.maxZ() + 1; z++) {
for (int x = box.minX() - 1; x <= box.maxX() + 1; x++) {
if (!inside(island, x, z, 4)) return "footprint-outside-island at " + x + "," + z;
long key = ((long) x << 32) ^ (z & 0xffffffffL);
Ground ground = columns.get(key);
if (ground == null) {
ground = ground(generator, chunk, randomState, x, z);
columns.put(key, ground);
}
if (ground.top() < 48 || ground.thickness() < depth)
return "foundation at " + x + "," + z + " top=" + ground.top() + " thickness=" + ground.thickness() + " required=" + depth;
low = Math.min(low, ground.top());
high = Math.max(high, ground.top());
if (high - low > (temple ? 4 : 8)) return "piece-slope=" + (high - low) + " low=" + low + " high=" + high;
}
}
allMin = Math.min(allMin, low);
allMax = Math.max(allMax, high);
if (allMax - allMin > 20) return "site-slope=" + (allMax - allMin);
}
return null;
}
private static void diagnostic(ExpansionIsland island, ChunkAccess chunk, String reason, String details) {
if (DIAGNOSTICS) SanctuaryMod.LOGGER.info("Expansion structure {} seed {} chunk {},{} {}: {}",
island.id(), island.seed(), chunk.getPos().x(), chunk.getPos().z(), reason, details);
}
private static Ground ground(ExpansionChunkGenerator generator, ChunkAccess chunk, RandomState randomState, int x, int z) {
NoiseColumn column = generator.getBaseColumn(x, z, chunk, randomState);
int y = chunk.getMaxY();
while (y >= chunk.getMinY() && column.getBlock(y).isAir()) y--;
int top = y;
// A wet, unsupported or hollow foundation is rejected before any start is saved.
while (y >= chunk.getMinY() && top - y < 24 && column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty()) y--;
return new Ground(top, top - y);
}
private static boolean inside(ExpansionIsland island, int x, int z, int margin) {
return Math.hypot((double) x - island.centerX(), (double) z - island.centerZ()) < island.terrainRadius() - margin;
}
private static long mix(long value) {
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return value ^ (value >>> 31);
}
private record Ground(int top, int thickness) {}
}
@@ -0,0 +1,127 @@
package fr.koka.sanctuary.expansion;
import java.util.EnumSet;
import net.minecraft.core.BlockPos;
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;
/** Alpha.12 material pass on fresh terrain only. It never builds a floor, dam or extra island mass. */
public final class ExpansionTerrain {
private ExpansionTerrain() {}
/** Called once after the delegate finishes terrain and before decoration. No adjacent chunk is read. */
public static void apply(ExpansionIsland island, ChunkAccess chunk) {
boolean volcanic = island.relief().equals("volcano");
boolean wet = !volcanic && (island.origin() || island.climate().equals("humid")
|| island.climate().equals("tropical") || island.climate().equals("cold_humid"));
if (!volcanic && !wet) return;
// Find against the original stone/deepslate before the volcanic palette replaces it.
// This one seed-owned column is the only fluid candidate for the entire island.
BlockPos pocket = retainedPocket(island, chunk, volcanic);
if (volcanic) volcanicPalette(island, chunk);
if (pocket == null) return;
chunk.setBlockState(pocket, (volcanic ? Blocks.LAVA : Blocks.WATER).defaultBlockState(), 0);
chunk.markPosForPostProcessing(pocket);
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
Heightmap.Types.OCEAN_FLOOR_WG));
}
/** A diagnostic can inspect this column; it is a candidate, never a promise of fluid. Y is unspecified. */
public static BlockPos pocketCandidate(ExpansionIsland island) {
long seed = island.seed() ^ 0xA12F101DL;
int dx, dz;
if (island.relief().equals("volcano")) {
// Even a 64-block island keeps (+/-2,+/-2) in the inner bowl. The maximum
// offset is eleven, keeping both the candidate and its walls inside a chunk.
int maximum = Math.clamp((int) Math.floor(island.radius() * 0.045), 2, 11);
dx = (2 + (int) Math.floor(unit(seed) * (maximum - 1))) * (unit(seed + 1) < 0.5 ? -1 : 1);
dz = (2 + (int) Math.floor(unit(seed + 2) * (maximum - 1))) * (unit(seed + 3) < 0.5 ? -1 : 1);
} else {
dx = (int) Math.round(island.radius() * (0.16 + 0.18 * unit(seed))) * (unit(seed + 1) < 0.5 ? -1 : 1);
dz = (int) Math.round(island.radius() * (0.16 + 0.18 * unit(seed + 2))) * (unit(seed + 3) < 0.5 ? -1 : 1);
}
int x = insideChunk(island.centerX() + dx), z = insideChunk(island.centerZ() + dz);
return new BlockPos(x, 0, z);
}
private static BlockPos retainedPocket(ExpansionIsland island, ChunkAccess chunk, boolean volcanic) {
BlockPos column = pocketCandidate(island);
int x = column.getX(), z = column.getZ();
if ((x >> 4) != chunk.getPos().x() || (z >> 4) != chunk.getPos().z()
|| !island.contains(x, z) || (volcanic && !ExpansionRelief.inCrater(island, x, z))) return null;
int bottom = Math.max(chunk.getMinY() + 1, (int) Math.ceil(ExpansionRelief.worldY(island, volcanic ? 176.0 : 160.0)));
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (int y = chunk.getMinY() + chunk.getHeight() - 2; y >= bottom; y--) {
position.set(x, y, z);
BlockState current = chunk.getBlockState(position);
if (current.isAir()) continue;
// Stop at the first real surface. Do not drill through soil, vegetation,
// overhangs or an earlier fluid to reach an otherwise eligible cave below.
if (!naturalRock(current) || !chunk.getBlockState(position.above()).isAir()) return null;
BlockPos candidate = position.immutable();
if (sealedSupport(chunk, candidate.below(), volcanic)
&& sealedSupport(chunk, candidate.north(), volcanic)
&& sealedSupport(chunk, candidate.south(), volcanic)
&& sealedSupport(chunk, candidate.west(), volcanic)
&& sealedSupport(chunk, candidate.east(), volcanic)) return candidate;
return null;
}
return null;
}
private static boolean sealedSupport(ChunkAccess chunk, BlockPos position, boolean volcanic) {
BlockState support = chunk.getBlockState(position);
// Full collision alone also accepts sand or ice, whose later falling/melting
// would breach a pocket. Restrict all five walls to stable natural rock.
boolean rock = naturalRock(support) || support.is(Blocks.ANDESITE) || support.is(Blocks.DIORITE)
|| support.is(Blocks.GRANITE) || support.is(Blocks.TUFF) || support.is(Blocks.CALCITE)
|| support.is(Blocks.BASALT) || support.is(Blocks.SMOOTH_BASALT) || support.is(Blocks.BLACKSTONE)
|| support.is(Blocks.OBSIDIAN) || support.is(Blocks.MAGMA_BLOCK);
return rock && support.getFluidState().isEmpty() && (!volcanic || !support.ignitedByLava())
&& support.isCollisionShapeFullBlock(chunk, position);
}
private static void volcanicPalette(ExpansionIsland island, ChunkAccess chunk) {
int minX = chunk.getPos().getMinBlockX(), minZ = chunk.getPos().getMinBlockZ();
int minY = Math.max(chunk.getMinY(), (int) Math.floor(ExpansionRelief.worldY(island, 160.0)));
int maxY = Math.min(chunk.getMinY() + chunk.getHeight() - 1,
(int) Math.ceil(ExpansionRelief.worldY(island, 330.0)));
int patchSize = Math.clamp(island.diameter() / 64, 2, 8);
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) {
double radial = Math.hypot((double) x - island.centerX(), (double) z - island.centerZ()) / island.radius();
if (radial >= 0.62 || !island.contains(x, z)) continue;
long column = island.seed() ^ Math.floorDiv(x - island.centerX(), patchSize) * 0x9e3779b97f4a7c15L
^ Math.floorDiv(z - island.centerZ(), patchSize) * 0x632be59bd9b4e019L;
for (int y = minY; y <= maxY; y++) {
position.set(x, y, z);
if (!naturalRock(chunk.getBlockState(position))) continue;
double pick = unit(column ^ Math.floorDiv(y, 4) * 0x94d049bb133111ebL);
boolean hot = radial < 0.28 && ExpansionRelief.referenceY(island, y) <= 230.0;
BlockState replacement = hot && pick < 0.055 ? Blocks.OBSIDIAN.defaultBlockState()
: hot && pick < 0.14 ? Blocks.MAGMA_BLOCK.defaultBlockState()
: pick < 0.44 ? Blocks.BLACKSTONE.defaultBlockState() : Blocks.BASALT.defaultBlockState();
chunk.setBlockState(position, replacement, 0);
}
}
}
private static boolean naturalRock(BlockState state) {
return state.is(Blocks.STONE) || state.is(Blocks.DEEPSLATE);
}
private static int insideChunk(int coordinate) {
return (coordinate & ~15) + Math.clamp(coordinate & 15, 1, 14);
}
private static double unit(long value) {
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
}
}
@@ -0,0 +1,18 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** The laboratory keeps the source island's cavities; vanilla carvers must not remove approved foundations. */
@Mixin(NoiseBasedChunkGenerator.class)
public abstract class ExpansionCarverMixin {
@Inject(method = "generateCarvers", at = @At("HEAD"), cancellable = true)
private void sanctuary$keepExpansionCavities(CallbackInfo callback) {
var generator = (NoiseBasedChunkGenerator) (Object) this;
if (generator.getBiomeSource() instanceof ExpansionBiomeSource) callback.cancel();
}
}
@@ -0,0 +1,14 @@
package fr.koka.sanctuary.mixin;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import net.minecraft.server.level.ChunkHolder;
import net.minecraft.server.level.ChunkMap;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
/** Admission must also reject chunks removed from the active map but not saved yet. */
@Mixin(ChunkMap.class)
public interface ExpansionChunkMapAccessor {
@Accessor("pendingUnloads")
Long2ObjectLinkedOpenHashMap<ChunkHolder> sanctuary$pendingUnloads();
}
@@ -15,6 +15,11 @@ public abstract class InitialSpawnMixin {
@Inject(method = "setInitialSpawn", at = @At("RETURN"))
private static void sanctuary$initialSpawn(ServerLevel level, ServerLevelData data, boolean bonusChest,
boolean debugWorld, LevelLoadListener listener, CallbackInfo ci) {
if (level.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.expansion.ExpansionChunkGenerator) {
var pos = fr.koka.sanctuary.expansion.ExpansionRuntime.session(level).landing("sanctuary");
level.setRespawnData(net.minecraft.world.level.storage.LevelData.RespawnData.of(net.minecraft.world.level.Level.OVERWORLD, pos, 0, 0));
return;
}
SanctuarySpawn.initialize(level);
}
}
@@ -0,0 +1,30 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
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;
/** Only new alpha.13 decoration writes are filtered, never player edits or saved chunk loading. */
@Mixin(WorldGenRegion.class)
public abstract class UnifiedDecorationProtectionMixin {
@Inject(method = "setBlock", at = @At("HEAD"), cancellable = true)
private void sanctuary$keepHydrology(BlockPos pos, BlockState state, int flags, int recursion,
CallbackInfoReturnable<Boolean> callback) {
var region = (WorldGenRegion) (Object) this;
if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)) return;
// Vanilla rejects writes outside its region before reading the destination chunk.
if (!region.isWithinWriteZone(pos)) return;
// The dedicated shoreline feature still grows cane on its naturally valid dry banks.
if (state.is(Blocks.SUGAR_CANE)) return;
if (generator.protectsDecoration(pos) && !region.getBlockState(pos).equals(state)) {
generator.rejectedDecorationWrite();
callback.setReturnValue(false);
}
}
}
@@ -0,0 +1,50 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.SanctuaryWorldOptionsScreen;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.util.Map;
import java.util.Optional;
import net.minecraft.client.gui.screens.worldselection.PresetEditor;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.levelgen.presets.WorldPreset;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Supplies the existing vanilla Customize button without replacing its editor registry. */
@Mixin(WorldCreationUiState.class)
public abstract class SanctuaryWorldOptionsMixin {
@Unique
private static final ResourceKey<WorldPreset> SANCTUARY_PRESET = ResourceKey.create(
Registries.WORLD_PRESET, Identifier.fromNamespaceAndPath("sanctuary", "sanctuary"));
@Unique
private static final PresetEditor SANCTUARY_EDITOR = SanctuaryWorldOptionsScreen::new;
@Inject(method = "getPresetEditor", at = @At("HEAD"), cancellable = true)
private void sanctuary$worldOptions(CallbackInfoReturnable<PresetEditor> callback) {
if (sanctuary$hasEditableGenerator()) callback.setReturnValue(SANCTUARY_EDITOR);
}
// Vanilla preserves custom dimensions on registry refresh only when this direct lookup finds an editor.
@Redirect(method = "updatePresetLists", at = @At(value = "INVOKE",
target = "Ljava/util/Map;get(Ljava/lang/Object;)Ljava/lang/Object;"))
private Object sanctuary$preserveCustomSize(Map<?, ?> editors, Object key) {
if (Optional.of(SANCTUARY_PRESET).equals(key) && sanctuary$hasEditableGenerator()) return SANCTUARY_EDITOR;
return editors.get(key);
}
@Unique
private boolean sanctuary$hasEditableGenerator() {
WorldCreationUiState state = (WorldCreationUiState) (Object) this;
var preset = state.getWorldType().preset();
// Recreating an older save may retain the same public preset key with a legacy generator.
return preset != null && preset.is(SANCTUARY_PRESET)
&& state.getSettings().selectedDimensions().overworld() instanceof SanctuaryChunkGenerator;
}
}
@@ -0,0 +1,152 @@
package fr.koka.sanctuary.worldgen;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import net.minecraft.util.Mth;
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.densityfunction.generator.ConstantFunction;
import net.minecraft.world.level.levelgen.densityfunction.generator.GradientFunction;
import net.minecraft.world.level.levelgen.densityfunction.generator.NoiseFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.ClampFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.LerpFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.SliceFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.UnaryFunction;
import net.minecraft.world.level.levelgen.synth.BlendedNoise;
/** Reuses lattice corners without changing 26.3-pre-2's scalar interpolation arithmetic.
* Vanilla's bulk interpolation changes operation order and accumulates Y increments, so it
* cannot replace scalar sampling in a generator whose existing worlds must remain exact. */
public final class ExactInterpolationSampler implements DensitySampler {
private static final int CACHE_SIZE = 4096;
// One numeric cache per worker, not one cache per world. Both identities are weak:
// neither a sampler's captured noise graph nor a context's world fields are retained.
private static final ThreadLocal<CornerCache> CORNERS = ThreadLocal.withInitial(CornerCache::new);
private final DensitySampler input;
private final int cellSizeXz;
private final int cellSizeY;
ExactInterpolationSampler(DensitySampler input, int cellSizeXz, int cellSizeY) {
this.input = input;
this.cellSizeXz = cellSizeXz;
this.cellSizeY = cellSizeY;
}
/** Only the known Population terrain expression is optimized. Other data-pack expressions
* retain their own sampler, and the squeeze operation remains the actual vanilla class. */
public static DensitySampler compileTerrain(DensityFunction terrain, DensityFunction.CompileContext context) {
if (supportsTerrain(terrain)) {
var interpolated = (InterpolatedFunction) ((UnaryFunction) terrain).input();
return new UnaryFunction.SqueezeSampler(new ExactInterpolationSampler(
interpolated.input().compileSampler(context), interpolated.cellSizeXz(), interpolated.cellSizeY()));
}
return terrain.compileSampler(context);
}
/** Also used by the engine equivalence check to prove the shipped graphs take this path. */
public static boolean supportsTerrain(DensityFunction terrain) {
return terrain instanceof UnaryFunction unary && unary.type() == UnaryFunction.Type.SQUEEZE
&& unary.input() instanceof InterpolatedFunction interpolated && coordinateOnly(interpolated.input(), 0);
}
private static boolean coordinateOnly(DensityFunction function, int depth) {
if (depth > 64) return false;
function = DfRewriteRule.INLINE_REFERENCE.rewrite(function);
if (function instanceof ConstantFunction || function instanceof GradientFunction || function instanceof BlendedNoise) return true;
if (function instanceof NoiseFunction noise) return coordinateOnly(noise.shiftX(), depth + 1)
&& coordinateOnly(noise.shiftY(), depth + 1) && coordinateOnly(noise.shiftZ(), depth + 1);
if (function instanceof BinaryFunction binary) return coordinateOnly(binary.left(), depth + 1)
&& coordinateOnly(binary.right(), depth + 1);
if (function instanceof LerpFunction lerp) return coordinateOnly(lerp.alpha(), depth + 1)
&& coordinateOnly(lerp.first(), depth + 1) && coordinateOnly(lerp.second(), depth + 1);
if (function instanceof UnaryFunction unary) return coordinateOnly(unary.input(), depth + 1);
if (function instanceof ClampFunction clamp) return coordinateOnly(clamp.input(), depth + 1);
if (function instanceof SliceFunction slice) return coordinateOnly(slice.input(), depth + 1);
// Contextual blending, prepared caches and third-party density functions are deliberately
// opaque. Identity of a SamplerContext does not prove that its payload cannot mutate.
return false;
}
@Override public float sampleValue(SamplerContext context, int x, int y, int z) {
int offsetX = Math.floorMod(x, cellSizeXz);
int offsetY = Math.floorMod(y, cellSizeY);
int offsetZ = Math.floorMod(z, cellSizeXz);
// The vanilla aligned path is scalar; a lattice value from sampleVolume is not
// assumed to have the same bits as this separate input.sampleValue path.
if (offsetX == 0 && offsetY == 0 && offsetZ == 0) return input.sampleValue(context, x, y, z);
int originX = x - offsetX, originY = y - offsetY, originZ = z - offsetZ;
CornerCache cache = CORNERS.get();
cache.select(this, context);
int slot = cache.slot(originX, originY, originZ);
if (!cache.contains(slot, originX, originY, originZ)) {
DensityVolume lattice = new DensityVolume(2, 2, 2, originX, originY, originZ,
cellSizeXz, cellSizeY, cellSizeXz);
try (var buffer = context.acquireBuffer(lattice)) {
// Keep the precise 2x2x2 request used by InterpolatedFunction.sampleValue:
// batching a larger lattice can change the input noise's rounding too.
input.sampleVolume(context, buffer, lattice);
// A nested input sampler could itself use this thread's cache. Restore our
// identity after it returns, before publishing our corners into the cache.
cache.select(this, context);
int first = slot * 8;
for (int i = 0; i < 8; i++) cache.values[first + i] = buffer.get(i);
cache.store(slot, originX, originY, originZ);
}
}
int first = slot * 8;
// DensityVolume packs Y fastest, then X, then Z. lerp3 expects the corners in
// X, Y, Z order; retain vanilla's float divisions, not reciprocal multiplication.
return Mth.lerp3((float) offsetX / cellSizeXz, (float) offsetY / cellSizeY, (float) offsetZ / cellSizeXz,
cache.values[first], cache.values[first + 2], cache.values[first + 1], cache.values[first + 3],
cache.values[first + 4], cache.values[first + 6], cache.values[first + 5], cache.values[first + 7]);
}
@Override public void sampleVolume(SamplerContext context, DensityBuffer buffer, DensityVolume volume) {
DensitySampler.sampleVolumeNaive(context, buffer, volume, this);
}
/** Direct-mapped and bounded: 192 KiB of primitive arrays per participating thread.
* A collision only recomputes a cell; it never substitutes a different cell's corners. */
private static final class CornerCache {
private WeakReference<ExactInterpolationSampler> sampler = new WeakReference<>(null);
private WeakReference<SamplerContext> context = new WeakReference<>(null);
private final int[] xs = new int[CACHE_SIZE], ys = new int[CACHE_SIZE], zs = new int[CACHE_SIZE];
private final int[] generations = new int[CACHE_SIZE];
private final float[] values = new float[CACHE_SIZE * 8];
private int generation;
void select(ExactInterpolationSampler selected, SamplerContext selectedContext) {
if (sampler.get() == selected && context.get() == selectedContext) return;
sampler = new WeakReference<>(selected);
context = new WeakReference<>(selectedContext);
if (++generation == 0) {
Arrays.fill(generations, 0);
generation = 1;
}
}
int slot(int x, int y, int z) {
int hash = x * 0x9e3779b9 ^ Integer.rotateLeft(y * 0x85ebca6b, 11)
^ Integer.rotateLeft(z * 0xc2b2ae35, 22);
hash = (hash ^ (hash >>> 16)) * 0x7feb352d;
return (hash ^ (hash >>> 15)) & (CACHE_SIZE - 1);
}
boolean contains(int slot, int x, int y, int z) {
return generations[slot] == generation && xs[slot] == x && ys[slot] == y && zs[slot] == z;
}
void store(int slot, int x, int y, int z) {
xs[slot] = x; ys[slot] = y; zs[slot] = z;
generations[slot] = generation;
}
}
}
@@ -5,14 +5,14 @@ import com.mojang.serialization.DataResult;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
/** Versioned alpha.10 world geometry. Capacity describes area, not a login limit. */
/** Saved geometry references. Small/large use FIVE/TEN; TWENTY/HUNDRED remain loadable. */
public enum IslandCapacity {
FIVE(5, 256), TWENTY(20, 512), HUNDRED(100, 256 * Math.sqrt(20));
FIVE(5, 256), TEN(10, 256 * Math.sqrt(2)), TWENTY(20, 512), HUNDRED(100, 256 * Math.sqrt(20));
public static final int REGION_SIZE = 512;
public static final Codec<IslandCapacity> CODEC = Codec.INT.comapFlatMap(players -> {
for (var capacity : values()) if (capacity.players == players) return DataResult.success(capacity);
return DataResult.error(() -> "Island capacity must be 5, 20 or 100");
return DataResult.error(() -> "Unknown saved island size; supported references are 5, 10, 20 and 100");
}, IslandCapacity::players);
private final int players;
private final double radius;
@@ -22,13 +22,14 @@ public enum IslandCapacity {
public int terrainLimit() { return (int) Math.ceil(radius + 32); }
public static IslandCapacity fromPlayers(int players) {
for (var capacity : values()) if (capacity.players == players) return capacity;
throw new IllegalArgumentException("Island capacity must be 5, 20 or 100");
throw new IllegalArgumentException("Unknown saved island size; supported references are 5, 10, 20 and 100");
}
public static IslandCapacity forGenerator(ChunkGenerator generator) {
if (!(generator instanceof NoiseBasedChunkGenerator noise)) return null;
var settings = noise.generatorSettings();
if (settings.is(SanctuarySpawn.POPULATION_5_SETTINGS)) return FIVE;
if (settings.is(SanctuarySpawn.POPULATION_20_SETTINGS)) return TWENTY;
if (settings.is(SanctuarySpawn.POPULATION_5_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_5"))) return FIVE;
if (settings.is(SanctuarySpawn.POPULATION_10_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_10"))) return TEN;
if (settings.is(SanctuarySpawn.POPULATION_20_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_20"))) return TWENTY;
if (settings.is(SanctuarySpawn.POPULATION_100_SETTINGS)) return HUNDRED;
return null;
}
@@ -76,8 +76,10 @@ public final class PopulationDecorationsFeature implements Feature {
}
@Override public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
var state = level.getLevel().getChunkSource().randomState();
var binding = PopulationGenerationContext.of(generator, level.getLevel());
if (binding == null) return false;
var noise = binding.generator();
var state = binding.random();
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
int cx = origin.getX() >> 4, cz = origin.getZ() >> 4;
@@ -0,0 +1,22 @@
package fr.koka.sanctuary.worldgen;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import org.jspecify.annotations.Nullable;
/** Resolves the actual starter noise state; an expansion host's fallback state has no terrain. */
public final class PopulationGenerationContext {
public record Binding(NoiseBasedChunkGenerator generator, RandomState random) {}
private PopulationGenerationContext() {}
public static @Nullable Binding of(ServerLevel level) {
return of(level.getChunkSource().getGenerator(), level);
}
public static @Nullable Binding of(ChunkGenerator generator, ServerLevel level) {
if (generator instanceof SanctuaryChunkGenerator unified) return unified.starterContext();
if (generator instanceof NoiseBasedChunkGenerator noise && PopulationHydrologyRuntime.enabled(noise))
return new Binding(noise, level.getChunkSource().randomState());
return null;
}
}
@@ -53,8 +53,8 @@ public final class PopulationGrovesFeature implements Feature {
@Override public MapCodec<PopulationGrovesFeature> codec() { return CODEC; }
public static Optional<RemarkableTree> remarkablePlan(ServerLevel level) {
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty();
return remarkablePlan(noise, level.getChunkSource().randomState());
var binding = PopulationGenerationContext.of(level);
return binding == null ? Optional.empty() : remarkablePlan(binding.generator(), binding.random());
}
public static Optional<RemarkableTree> remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) {
@@ -87,10 +87,12 @@ public final class PopulationGrovesFeature implements Feature {
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
var binding = PopulationGenerationContext.of(generator, level.getLevel());
if (binding == null) return false;
var noise = binding.generator();
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
if (!PopulationHydrologyRuntime.capacity(noise).areaExists(minX >> 4, minZ >> 4)) return false;
var state = level.getLevel().getChunkSource().randomState();
var state = binding.random();
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
Protection protection = (base, radius, height, rootDepth) ->
@@ -10,55 +10,55 @@ 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;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
/** Alpha.10 plans only regions touched by generation or a requested protection volume. */
public final class PopulationHydrologyRuntime {
public record RegionPlan(IslandCapacity.Region region, PopulationHydrology.Plan water, PopulationLavaDeposit.Plan lava) {}
private static final Map<RandomState, Map<IslandCapacity.Region, RegionPlan>> WORLDS =
private static final Map<RandomState, PopulationPlanCache<IslandCapacity.Region, RegionPlan>> WORLDS =
Collections.synchronizedMap(new WeakHashMap<>());
private PopulationHydrologyRuntime() {}
public static boolean enabled(NoiseBasedChunkGenerator generator) { return IslandCapacity.forGenerator(generator) != null; }
public static IslandCapacity capacity(NoiseBasedChunkGenerator generator) {
return Objects.requireNonNull(IslandCapacity.forGenerator(generator), "Population generation is not enabled");
}
private static Map<IslandCapacity.Region, RegionPlan> regions(RandomState state) {
synchronized (WORLDS) { return WORLDS.computeIfAbsent(state, ignored -> new LinkedHashMap<>()); }
private static PopulationPlanCache<IslandCapacity.Region, RegionPlan> regions(RandomState state) {
synchronized (WORLDS) { return WORLDS.computeIfAbsent(state, ignored -> new PopulationPlanCache<>()); }
}
public static int cachedRegionCount(RandomState state) {
Map<IslandCapacity.Region, RegionPlan> regions;
PopulationPlanCache<IslandCapacity.Region, RegionPlan> regions;
synchronized (WORLDS) { regions = WORLDS.get(state); }
if (regions == null) return 0;
synchronized (regions) { return regions.size(); }
return regions.size();
}
public static RegionPlan regionPlan(NoiseBasedChunkGenerator generator, RandomState state, IslandCapacity.Region region) {
var capacity = capacity(generator);
var regions = regions(state);
// One build per world at a time bounds the temporary sign-mask memory. No recursive
// neighbouring plan is needed: each accepted assembly owns its complete protected extent.
synchronized (regions) {
RegionPlan existing = regions.get(region);
if (existing != null) return existing;
// Only cache misses wait for the bounded planner. Completed regions stay readable
// while another region is being computed; no neighbouring plan is built recursively.
return regions.getOrCreate(region, () -> {
long start = System.nanoTime();
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
var sampler = state.getSampler(density);
var memo = new SignMemo((x, y, z) -> {
long wx = (long) x + region.originX(), wz = (long) z + region.originZ();
if (Math.hypot((double) wx, wz) >= capacity.terrainLimit()) return -1;
return state.sampleBlockValueUncached(density, (int) wx, y, (int) wz);
return sampler.sampleValue(SamplerContext.EMPTY_UNCACHED, (int) wx, y, (int) wz);
});
long seed = PopulationIslandDensity.regionSeed(state.seed(), region);
var water = PopulationHydrology.create(seed, memo).admitted();
var lava = PopulationLavaDeposit.create(seed, memo, water).admitted();
var result = new RegionPlan(region, water.translated(region.originX(), region.originZ()),
lava.translated(region.originX(), region.originZ()));
regions.put(region, result);
SanctuaryMod.LOGGER.info("Population {} seed {} region {},{}: {} features, {} water columns, {} lava sources, {} density probes, {} ms",
capacity.players(), state.seed(), region.x(), region.z(), water.features().size(), water.cells().size(),
lava.lavaCells().size(), memo.samples, (System.nanoTime() - start) / 1_000_000);
return result;
}
});
}
public static RegionPlan regionPlan(ServerLevel level, IslandCapacity.Region region) {
return regionPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), level.getChunkSource().randomState(), region);
var binding = Objects.requireNonNull(PopulationGenerationContext.of(level), "Population world required");
return regionPlan(binding.generator(), binding.random(), region);
}
public static List<RegionPlan> plansIntersecting(NoiseBasedChunkGenerator generator, RandomState state,
int minX, int minZ, int maxX, int maxZ) {
@@ -23,7 +23,7 @@ public record PopulationIslandDensity(DensityFunction terrain, DensityFunction d
).apply(instance, PopulationIslandDensity::new));
@Override public DensitySampler compileSampler(CompileContext context) {
var raw = terrain.compileSampler(context); var edge = distortion.compileSampler(context);
var raw = ExactInterpolationSampler.compileTerrain(terrain, context); var edge = distortion.compileSampler(context);
var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context);
var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context);
var fractureDetail = riftDetail.compileSampler(context);
@@ -0,0 +1,25 @@
package fr.koka.sanctuary.worldgen;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/** Completed plans remain readable while one new region uses temporary planning memory. */
final class PopulationPlanCache<K, V> {
private final ConcurrentHashMap<K, V> completed = new ConcurrentHashMap<>();
V getOrCreate(K key, Supplier<V> create) {
V value = completed.get(key);
if (value != null) return value;
synchronized (this) {
value = completed.get(key);
if (value == null) {
value = Objects.requireNonNull(create.get());
completed.put(key, value);
}
return value;
}
}
int size() { return completed.size(); }
}
@@ -20,8 +20,10 @@ public final class PopulationShoreSugarCaneFeature implements Feature {
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
var state = level.getLevel().getChunkSource().randomState();
var binding = PopulationGenerationContext.of(generator, level.getLevel());
if (binding == null) return false;
var noise = binding.generator();
var state = binding.random();
var cells = PopulationHydrologyRuntime.cellsInChunk(noise, state, origin.getX() >> 4, origin.getZ() >> 4);
var cane = Blocks.SUGAR_CANE.defaultBlockState();
boolean placed = false;
@@ -26,8 +26,9 @@ public final class PopulationSpringOutlets {
/** Called before vanilla consumes the queue; a fully processed/reloaded chunk has no work. */
public static Pending capturePending(ServerLevel level, LevelChunk chunk) {
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|| !PopulationHydrologyRuntime.enabled(noise)) return Pending.EMPTY;
var binding = PopulationGenerationContext.of(level);
if (binding == null) return Pending.EMPTY;
var noise = binding.generator();
if (!PopulationHydrologyRuntime.capacity(noise).areaExists(chunk.getPos().x(), chunk.getPos().z())) return Pending.EMPTY;
boolean queued = false;
for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) {
@@ -35,7 +36,7 @@ public final class PopulationSpringOutlets {
break;
}
if (!queued) return Pending.EMPTY;
var regional = PopulationHydrologyRuntime.regionPlan(noise, level.getChunkSource().randomState(),
var regional = PopulationHydrologyRuntime.regionPlan(noise, binding.random(),
PopulationHydrologyRuntime.capacity(noise).regionAt(chunk.getPos().getMinBlockX(), chunk.getPos().getMinBlockZ()));
var plan = regional.water();
List<PopulationHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
@@ -58,9 +59,11 @@ public final class PopulationSpringOutlets {
/** All neighbouring FEATURES have finished before a chunk reaches this stage. */
public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) {
if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|| !PopulationHydrologyRuntime.enabled(noise)) return;
var random = level.getChunkSource().randomState();
if (pending.isEmpty()) return;
var binding = PopulationGenerationContext.of(level);
if (binding == null) return;
var noise = binding.generator();
var random = binding.random();
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
for (var outlet : pending.outlets()) {
if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet);
@@ -0,0 +1,81 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.biome.Climate;
/** Alpha.13 keeps Population's origin woodlands and the laboratory's native cave pockets.
* Child islands retain the laboratory's complete climate palette. */
public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
public static final List<String> REQUIRED_BIOMES = Stream.concat(
ExpansionBiomeSource.REQUIRED_BIOMES.stream(), Stream.of(
PopulationIslandBiomeSource.OAK_FOREST, PopulationIslandBiomeSource.BIRCH_FOREST,
PopulationIslandBiomeSource.CLEARING, PopulationIslandBiomeSource.DRY_WOODLAND,
PopulationIslandBiomeSource.ROCKY_HEATH, PopulationIslandBiomeSource.DARK_GROVE,
PopulationIslandBiomeSource.BAMBOO_GROVE, PopulationIslandBiomeSource.SULFUR_DEPTHS,
PopulationIslandBiomeSource.LUSH_CAVES, PopulationIslandBiomeSource.DRIPSTONE_CAVES
).map(key -> key.identifier().toString())).toList();
public static final MapCodec<SanctuaryBiomeSource> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, SanctuaryBiomeSource::new));
private final List<Holder<Biome>> allBiomes;
private final PopulationIslandBiomeSource population;
public SanctuaryBiomeSource(List<Holder<Biome>> biomes) {
super(biomes);
allBiomes = List.copyOf(biomes);
population = new PopulationIslandBiomeSource(
required(PopulationIslandBiomeSource.OAK_FOREST), required(PopulationIslandBiomeSource.BIRCH_FOREST),
required(PopulationIslandBiomeSource.CLEARING), required(PopulationIslandBiomeSource.DRY_WOODLAND),
required(PopulationIslandBiomeSource.ROCKY_HEATH), required(PopulationIslandBiomeSource.DARK_GROVE),
required(PopulationIslandBiomeSource.BAMBOO_GROVE), required(PopulationIslandBiomeSource.SULFUR_DEPTHS),
required(PopulationIslandBiomeSource.LUSH_CAVES), required(PopulationIslandBiomeSource.DRIPSTONE_CAVES));
}
/** A world or delegate gets its own activation snapshot; registered holders remain immutable. */
public SanctuaryBiomeSource copy() { return new SanctuaryBiomeSource(allBiomes); }
@Override protected MapCodec<SanctuaryBiomeSource> codec() { return CODEC; }
@Override public BiomeResolver createResolver(Climate.Sampler sampler) {
BiomeResolver woodlands = population.createResolver(sampler);
return (quartX, quartY, quartZ) -> resolveCombined(islands(), woodlands, quartX, quartY, quartZ);
}
@Override public BiomeResolver createResolverForChunk(Climate.Sampler sampler, int minQuartX, int minQuartY,
int minQuartZ, int quartSizeX, int quartSizeY, int quartSizeZ) {
List<ExpansionIsland> snapshot = islands();
BiomeResolver woodlands = population.createResolver(sampler);
return (quartX, quartY, quartZ) -> resolveCombined(snapshot, woodlands, quartX, quartY, quartZ);
}
private Holder<Biome> resolveCombined(List<ExpansionIsland> snapshot, BiomeResolver woodlands,
int quartX, int quartY, int quartZ) {
int x = quartX * 4, y = quartY * 4, z = quartZ * 4;
// Reuse the historical selector itself: its noise, thresholds, order and child
// climate choices stay exact rather than being approximated by another climate field.
Holder<Biome> laboratory = super.resolve(snapshot, x, y, z);
if (y < 0 || y >= 384 || laboratory.is(Biomes.LUSH_CAVES)
|| laboratory.is(Biomes.DRIPSTONE_CAVES) || laboratory.is(Biomes.SULFUR_CAVES)) return laboratory;
for (ExpansionIsland island : snapshot) {
if (island.origin() && island.contains(x, z)) return woodlands.getNoiseBiome(quartX, quartY, quartZ);
}
return laboratory;
}
private Holder<Biome> required(ResourceKey<Biome> key) {
return allBiomes.stream().filter(holder -> holder.is(key)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Missing Sanctuary biome: " + key.identifier()));
}
}
@@ -0,0 +1,106 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import java.util.List;
import net.minecraft.core.registries.Registries;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
/** Alpha.13 combines the complete starter island with the versioned expansion runtime.
* The historical laboratory codec keeps its original algorithm and journal. */
public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
public static final Codec<Integer> DIAMETER_CODEC = Codec.INT.validate(value ->
List.of(512, 724, 1024).contains(value) ? DataResult.success(value)
: DataResult.error(() -> "Sanctuary diameter must be 512, 724 or 1024"));
public static final MapCodec<SanctuaryChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
).apply(i, SanctuaryChunkGenerator::new));
private volatile Context originContext;
// Root extent is finite (at most 68x68 chunks); each immutable mask is at most 12 KiB.
private final java.util.concurrent.ConcurrentHashMap<Long, SanctuaryDecorationProtection> protections = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.atomic.LongAdder rejectedWrites = new java.util.concurrent.atomic.LongAdder();
public boolean protectsDecoration(net.minecraft.core.BlockPos pos) {
if (pos.getY() < 0 || pos.getY() >= 384 || !capacity().areaExists(pos.getX() >> 4, pos.getZ() >> 4)) return false;
int cx = pos.getX() >> 4, cz = pos.getZ() >> 4;
long key = ((long) cx << 32) ^ (cz & 0xffffffffL);
return protections.computeIfAbsent(key, ignored -> SanctuaryDecorationProtection.create(starterContext(), cx, cz)).protects(pos);
}
public void rejectedDecorationWrite() { rejectedWrites.increment(); }
public long rejectedDecorationWrites() { return rejectedWrites.sum(); }
public SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter) {
super(source, initialDiameter, true);
}
@Override public SanctuaryBiomeSource source() { return (SanctuaryBiomeSource) super.source(); }
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return CODEC; }
public SanctuaryChunkGenerator withInitialDiameter(int diameter) {
return new SanctuaryChunkGenerator(source().copy(), diameter);
}
public IslandCapacity capacity() {
return switch (initialDiameter()) {
case 512 -> IslandCapacity.FIVE;
case 724 -> IslandCapacity.TEN;
case 1024 -> IslandCapacity.TWENTY;
default -> throw new IllegalStateException("Unvalidated Sanctuary size");
};
}
@Override public net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> structureBiome(
net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> biome) {
String name = biome.unwrapKey().orElseThrow().identifier().toString();
String equivalent = switch (name) {
case "sanctuary:population_oak_forest" -> "forest";
case "sanctuary:population_birch_forest" -> "birch_forest";
case "sanctuary:population_clearing" -> "plains";
case "sanctuary:population_dry_woodland" -> "forest";
case "sanctuary:population_rocky_heath" -> "meadow";
case "sanctuary:population_dark_grove" -> "dark_forest";
case "sanctuary:population_bamboo_grove" -> "bamboo_jungle";
default -> null;
};
return equivalent == null ? biome : registries.lookupOrThrow(Registries.BIOME).getOrThrow(
net.minecraft.resources.ResourceKey.create(Registries.BIOME, net.minecraft.resources.Identifier.withDefaultNamespace(equivalent)));
}
@Override public String journalDirectory() { return "sanctuary-world-v13"; }
@Override public int rootGeneration() { return 13; }
@Override public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
super.initialize(level, islands);
// Publish one root context before any generation workers use it.
context(islands.getFirst());
}
@Override protected Context context(ExpansionIsland island) {
if (!island.origin()) return super.context(island);
Context current = originContext;
if (current != null) return current;
synchronized (this) {
if (originContext == null) {
if (registries == null) throw new IllegalStateException("Sanctuary used before world attachment");
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(net.minecraft.resources.ResourceKey.create(Registries.NOISE_SETTINGS,
SanctuaryMod.id("unified_" + capacity().players())));
var delegate = new NoiseBasedChunkGenerator(source(), settings);
var random = RandomState.create(registries.lookupOrThrow(Registries.NOISE), island.seed(), settings.value());
originContext = new Context(delegate, random);
}
return originContext;
}
}
public PopulationGenerationContext.Binding starterContext() {
var current = originContext;
if (current == null) throw new IllegalStateException("Sanctuary used before world attachment");
return new PopulationGenerationContext.Binding(current.generator(), current.random());
}
@Override protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) {
// Root water/lava are already applied by the Population hook on the named delegate.
if (!island.origin()) super.afterTerrain(island, chunk);
}
}
@@ -0,0 +1,60 @@
package fr.koka.sanctuary.worldgen;
import java.util.BitSet;
import net.minecraft.core.BlockPos;
/** Immutable per-chunk mask of the starter's water, beds, supports and flow passages.
* Native cave features may decorate freely outside these previously planned volumes. */
public final class SanctuaryDecorationProtection {
private final int minX, minZ;
private final BitSet mask = new BitSet(16 * 16 * 384);
private SanctuaryDecorationProtection(int chunkX, int chunkZ) { minX = chunkX * 16; minZ = chunkZ * 16; }
public static SanctuaryDecorationProtection create(PopulationGenerationContext.Binding binding, int chunkX, int chunkZ) {
var result = new SanctuaryDecorationProtection(chunkX, chunkZ);
for (var region : PopulationHydrologyRuntime.plansIntersecting(binding.generator(), binding.random(),
result.minX - 1, result.minZ - 1, result.minX + 16, result.minZ + 16)) {
// A retaining wall can belong to the neighbouring chunk of its water cell.
for (int cx = chunkX - 1; cx <= chunkX + 1; cx++)
for (int cz = chunkZ - 1; cz <= chunkZ + 1; cz++)
for (var cell : region.water().cellsInChunk(cx, cz)) {
if (cell.x() < result.minX - 1 || cell.x() > result.minX + 16
|| cell.z() < result.minZ - 1 || cell.z() > result.minZ + 16) continue;
result.box(cell.x() - 1, cell.bedY() - cell.sedimentDepth() - 2, cell.z() - 1,
cell.x() + 1, cell.carveTop() + 1, cell.z() + 1);
}
for (var spring : region.water().springs()) {
result.flow(spring.flowBounds(), 2);
var source = spring.source();
result.cube(source.x(), source.y(), source.z(), 3);
}
for (var spill : region.water().spills()) result.flow(spill.flowBounds(), 3);
for (var fall : region.lava().flowBounds()) result.flow(fall, 8);
for (var cell : region.lava().cells()) result.cube(cell.x(), cell.y(), cell.z(), 2);
for (var support : region.lava().supports()) result.cube(support.x(), support.y(), support.z(), 2);
}
return result;
}
public boolean protects(BlockPos pos) {
int x = pos.getX() - minX, z = pos.getZ() - minZ, y = pos.getY();
return x >= 0 && x < 16 && z >= 0 && z < 16 && y >= 0 && y < 384
&& mask.get((x * 16 + z) * 384 + y);
}
public int protectedBlocks() { return mask.cardinality(); }
private void flow(PopulationHydrology.FlowBounds b, int pad) {
box(b.minX() - pad, b.minY() - pad, b.minZ() - pad,
b.maxX() + pad, b.maxY() + pad, b.maxZ() + pad);
}
private void cube(int x, int y, int z, int radius) {
box(x - radius, y - radius, z - radius, x + radius, y + radius, z + radius);
}
private void box(int x1, int y1, int z1, int x2, int y2, int z2) {
int lo = Math.max(0, y1), hi = Math.min(383, y2);
if (lo > hi) return;
for (int x = Math.max(minX, x1); x <= Math.min(minX + 15, x2); x++)
for (int z = Math.max(minZ, z1); z <= Math.min(minZ + 15, z2); z++) {
int column = ((x - minX) * 16 + z - minZ) * 384;
mask.set(column + lo, column + hi + 1);
}
}
}
@@ -38,6 +38,8 @@ public final class SanctuarySpawn {
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_5_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_5"));
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_10_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_10"));
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_20_SETTINGS =
ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("population_20"));
public static final ResourceKey<NoiseGeneratorSettings> POPULATION_100_SETTINGS =
@@ -56,6 +58,7 @@ public final class SanctuarySpawn {
|| generator.generatorSettings().is(RIFT_SETTINGS)
|| generator.generatorSettings().is(CAVERN_SETTINGS)
|| generator.generatorSettings().is(POPULATION_5_SETTINGS)
|| generator.generatorSettings().is(POPULATION_10_SETTINGS)
|| generator.generatorSettings().is(POPULATION_20_SETTINGS)
|| generator.generatorSettings().is(POPULATION_100_SETTINGS));
}
@@ -1,5 +1,6 @@
{
"generator.sanctuary.sanctuary": "Sanctuary (20 players)",
"generator.sanctuary.sanctuary": "Sanctuary",
"generator.sanctuary.sanctuary_10": "Sanctuary (Large — legacy)",
"biome.sanctuary.starter_forest": "Sanctuary Forest",
"biome.sanctuary.starter_forest_hydrology": "Sanctuary Forest and Shores",
"biome.sanctuary.temperate_forest": "Sanctuary Temperate Forest",
@@ -38,7 +39,7 @@
"biome.sanctuary.cavern_sulfur_depths": "Sulfur Depths",
"biome.sanctuary.cavern_lush_caves": "Sanctuary Lush Caves",
"biome.sanctuary.cavern_dripstone_caves": "Sanctuary Dripstone Caves",
"generator.sanctuary.sanctuary_5": "Sanctuary (5 players)",
"generator.sanctuary.sanctuary_5": "Sanctuary (Small)",
"generator.sanctuary.sanctuary_100": "Sanctuary (100 players)",
"biome.sanctuary.population_oak_forest": "Sanctuary Oak Woodland",
"biome.sanctuary.population_birch_forest": "Sanctuary Birch Woodland",
@@ -49,5 +50,72 @@
"biome.sanctuary.population_bamboo_grove": "Bamboo Grove",
"biome.sanctuary.population_sulfur_depths": "Sulfur Depths",
"biome.sanctuary.population_lush_caves": "Sanctuary Lush Caves",
"biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves"
"biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves",
"generator.sanctuary.sanctuary_20": "Sanctuary (20 players — legacy size)",
"generator.sanctuary.expansion_lab": "Sanctuary (legacy laboratory)",
"sanctuary.expansion.help": "/sanctuary expansion quick <direction> [climate] [diameter] [relief] [distance]; create or preview <id> <parent> <direction> <climate> <diameter> <relief> [distance]; list; visit <id>; resume <id>. Diameters: 64, 128, 256, 512. Minimum centre-to-centre distance in blocks (0 = automatic, maximum 100000). Fill in preceding arguments to specify distance. Example: quick north autumn 128 peaks 2048. Creation searches farther if needed, up to 32 locations and 30 seconds.",
"sanctuary.expansion.entry": "%s ← %s · %s · %s · Ø%s · %s · X%s Z%s · %s",
"sanctuary.expansion.progress": "Preparation: %s",
"sanctuary.expansion.state.ready": "Ready",
"sanctuary.expansion.state.reserved": "Reserved, preparing",
"sanctuary.expansion.preview": "Proposal %s: X%s Z%s, %s, diameter %s, relief %s. Proposed bounds; occupancy and terrain have not been validated.",
"sanctuary.expansion.created": "Island %s recorded at X%s Z%s: %s, diameter %s, relief %s. Check /sanctuary expansion list.",
"sanctuary.expansion.ready": "Island %s ready: %s chunks. /sanctuary expansion visit %s",
"sanctuary.expansion.visited": "Arrived on %s.",
"sanctuary.expansion.resumed": "Resume requested for %s.",
"sanctuary.expansion.error": "Expansion refused: %s",
"sanctuary.expansion.error.laboratory_required": "Create a new Sanctuary world. Older generators are not converted.",
"sanctuary.expansion.error.unknown_island": "Unknown island.",
"sanctuary.expansion.error.reserved_id": "Reserved identifier.",
"sanctuary.expansion.error.invalid_direction": "Unknown direction.",
"sanctuary.expansion.error.no_space": "No available location in this direction.",
"sanctuary.expansion.error.id_conflict": "This identifier already belongs to a different island.",
"sanctuary.expansion.error.island_limit": "Limit: 64 islands including the origin.",
"sanctuary.expansion.error.no_ground": "No ground found in the proposed terrain. Choose another identifier or profile.",
"sanctuary.expansion.error.not_ready": "This island must finish preparing first.",
"sanctuary.expansion.error.no_landing": "No safe landing found.",
"sanctuary.expansion.error.dismount_first": "Dismount before travelling.",
"sanctuary.expansion.error.parent_not_ready": "The parent must be ready before creating a branch.",
"sanctuary.expansion.error.occupied": "This region already contains chunks. Increase the distance to search farther away.",
"sanctuary.expansion.error.teleport_failed": "Teleportation failed.",
"sanctuary.expansion.climate.temperate": "Temperate",
"sanctuary.expansion.climate.boreal": "Boreal",
"sanctuary.expansion.climate.glacial": "Glacial",
"sanctuary.expansion.climate.cold_humid": "Cold humid",
"sanctuary.expansion.climate.humid": "Humid",
"sanctuary.expansion.climate.tropical": "Tropical",
"sanctuary.expansion.climate.warm": "Warm",
"sanctuary.expansion.climate.arid": "Arid",
"sanctuary.expansion.climate.dry": "Dry",
"sanctuary.expansion.climate.cold_dry": "Cold dry",
"sanctuary.expansion.climate.autumn": "Autumn",
"sanctuary.expansion.climate.cherry": "Cherry",
"sanctuary.expansion.relief.natural": "Natural",
"sanctuary.expansion.relief.peaks": "Peaks",
"sanctuary.expansion.relief.canyon": "Canyon",
"sanctuary.expansion.relief.volcano": "Volcano",
"sanctuary.expansion.direction.north": "North",
"sanctuary.expansion.direction.northeast": "Northeast",
"sanctuary.expansion.direction.east": "East",
"sanctuary.expansion.direction.southeast": "Southeast",
"sanctuary.expansion.direction.south": "South",
"sanctuary.expansion.direction.southwest": "Southwest",
"sanctuary.expansion.direction.west": "West",
"sanctuary.expansion.direction.northwest": "Northwest",
"sanctuary.expansion.journal_unavailable": "The expansion journal is unavailable. Preparation has stopped; fix storage and reload the world before resuming.",
"sanctuary.expansion.error.journal_unavailable": "The expansion journal is unavailable. Preparation has stopped; fix storage and reload the world before resuming.",
"sanctuary.expansion.error.invalid_id": "An identifier must start with a lowercase letter and contain at most 32 lowercase letters, digits or underscores.",
"sanctuary.expansion.error.invalid_climate": "Unknown climate. Use autocomplete to choose a profile.",
"sanctuary.expansion.error.invalid_diameter": "Allowed diameters are 64, 128, 256 and 512 blocks.",
"sanctuary.expansion.error.invalid_relief": "Unknown relief: natural, peaks, canyon or volcano.",
"sanctuary.expansion.error.invalid_distance": "Distance must be between 0 and 100000 blocks. 0 uses automatic placement.",
"sanctuary.expansion.error.no_free_space": "No free location found within the search limits. Try a greater distance or a different parent. No existing terrain was changed.",
"sanctuary.expansion.error.search_timeout": "The search reached its 30-second limit. Try a greater distance. No existing terrain was changed.",
"sanctuary.world_options.title": "Sanctuary Island",
"sanctuary.world_options.description": "A floating island, its rivers and caverns, then continents to discover.",
"sanctuary.world_options.size": "Island size",
"sanctuary.world_options.size.small": "Small",
"sanctuary.world_options.size.medium": "Medium",
"sanctuary.world_options.size.large": "Large",
"sanctuary.world_options.diameter": "Nominal diameter: %s blocks. The terrain and seed shape the outline."
}
@@ -1,5 +1,6 @@
{
"generator.sanctuary.sanctuary": "Sanctuary (20 joueurs)",
"generator.sanctuary.sanctuary": "Sanctuary",
"generator.sanctuary.sanctuary_10": "Sanctuary (Grand — ancien format)",
"biome.sanctuary.starter_forest": "Forêt de Sanctuary",
"biome.sanctuary.starter_forest_hydrology": "Forêt et rives de Sanctuary",
"biome.sanctuary.temperate_forest": "Forêt tempérée de Sanctuary",
@@ -38,7 +39,7 @@
"biome.sanctuary.cavern_sulfur_depths": "Profondeurs soufrées",
"biome.sanctuary.cavern_lush_caves": "Grottes luxuriantes de Sanctuary",
"biome.sanctuary.cavern_dripstone_caves": "Grottes de spéléothèmes de Sanctuary",
"generator.sanctuary.sanctuary_5": "Sanctuary (5 joueurs)",
"generator.sanctuary.sanctuary_5": "Sanctuary (Petit)",
"generator.sanctuary.sanctuary_100": "Sanctuary (100 joueurs)",
"biome.sanctuary.population_oak_forest": "Chênaie de Sanctuary",
"biome.sanctuary.population_birch_forest": "Bétulaie de Sanctuary",
@@ -49,5 +50,72 @@
"biome.sanctuary.population_bamboo_grove": "Bosquet de bambous",
"biome.sanctuary.population_sulfur_depths": "Profondeurs soufrées",
"biome.sanctuary.population_lush_caves": "Grottes luxuriantes de Sanctuary",
"biome.sanctuary.population_dripstone_caves": "Grottes de spéléothèmes de Sanctuary"
"biome.sanctuary.population_dripstone_caves": "Grottes de spéléothèmes de Sanctuary",
"generator.sanctuary.sanctuary_20": "Sanctuary (20 joueurs — ancien format)",
"generator.sanctuary.expansion_lab": "Sanctuary (ancien laboratoire)",
"sanctuary.expansion.help": "/sanctuary expansion quick <direction> [climat] [diamètre] [relief] [distance] ; create ou preview <id> <parent> <direction> <climat> <diamètre> <relief> [distance] ; list ; visit <id> ; resume <id>. Diamètres : 64, 128, 256, 512. Distance minimale entre les centres, en blocs (0 = automatique, maximum 100000). Remplir les arguments précédents pour préciser la distance. Exemple : quick north autumn 128 peaks 2048. La création cherche plus loin si nécessaire, dans la limite de 32 emplacements et 30 secondes.",
"sanctuary.expansion.entry": "%s ← %s · %s · %s · Ø%s · %s · X%s Z%s · %s",
"sanctuary.expansion.progress": "Préparation : %s",
"sanctuary.expansion.state.ready": "Prêt",
"sanctuary.expansion.state.reserved": "Réservé, préparation en cours",
"sanctuary.expansion.preview": "Projet %s : X%s Z%s, %s, diamètre %s, relief %s. Emprise proposée, occupation et terrain non validés.",
"sanctuary.expansion.created": "Île %s enregistrée à X%s Z%s : %s, diamètre %s, relief %s. Suivre avec /sanctuary expansion list.",
"sanctuary.expansion.ready": "Île %s prête : %s chunks. /sanctuary expansion visit %s",
"sanctuary.expansion.visited": "Arrivée sur %s.",
"sanctuary.expansion.resumed": "Reprise demandée pour %s.",
"sanctuary.expansion.error": "Expansion refusée : %s",
"sanctuary.expansion.error.laboratory_required": "Créez un nouveau monde Sanctuary. Les anciennes générations ne sont pas converties.",
"sanctuary.expansion.error.unknown_island": "Île inconnue.",
"sanctuary.expansion.error.reserved_id": "Identifiant réservé.",
"sanctuary.expansion.error.invalid_direction": "Direction inconnue.",
"sanctuary.expansion.error.no_space": "Aucun emplacement libre dans cette direction.",
"sanctuary.expansion.error.id_conflict": "Cet identifiant désigne déjà une autre île.",
"sanctuary.expansion.error.island_limit": "Limite : 64 îles, origine comprise.",
"sanctuary.expansion.error.no_ground": "Aucun sol trouvé dans le terrain proposé. Choisir un autre identifiant ou profil.",
"sanctuary.expansion.error.not_ready": "La préparation de cette île doit être terminée.",
"sanctuary.expansion.error.no_landing": "Aucun point darrivée sûr trouvé.",
"sanctuary.expansion.error.dismount_first": "Descendre du véhicule avant le voyage.",
"sanctuary.expansion.error.parent_not_ready": "Le parent doit être prêt avant de créer une branche.",
"sanctuary.expansion.error.occupied": "La région contient déjà des chunks. Augmenter la distance pour chercher plus loin.",
"sanctuary.expansion.error.teleport_failed": "La téléportation a échoué.",
"sanctuary.expansion.climate.temperate": "Tempéré",
"sanctuary.expansion.climate.boreal": "Boréal",
"sanctuary.expansion.climate.glacial": "Glacial",
"sanctuary.expansion.climate.cold_humid": "Froid humide",
"sanctuary.expansion.climate.humid": "Humide",
"sanctuary.expansion.climate.tropical": "Tropical",
"sanctuary.expansion.climate.warm": "Chaud",
"sanctuary.expansion.climate.arid": "Aride",
"sanctuary.expansion.climate.dry": "Sec",
"sanctuary.expansion.climate.cold_dry": "Froid sec",
"sanctuary.expansion.climate.autumn": "Automnal",
"sanctuary.expansion.climate.cherry": "Cerisiers",
"sanctuary.expansion.relief.natural": "Naturel",
"sanctuary.expansion.relief.peaks": "Pics",
"sanctuary.expansion.relief.canyon": "Canyon",
"sanctuary.expansion.relief.volcano": "Volcan",
"sanctuary.expansion.direction.north": "Nord",
"sanctuary.expansion.direction.northeast": "Nord-est",
"sanctuary.expansion.direction.east": "Est",
"sanctuary.expansion.direction.southeast": "Sud-est",
"sanctuary.expansion.direction.south": "Sud",
"sanctuary.expansion.direction.southwest": "Sud-ouest",
"sanctuary.expansion.direction.west": "Ouest",
"sanctuary.expansion.direction.northwest": "Nord-ouest",
"sanctuary.expansion.journal_unavailable": "Le journal dexpansion est indisponible. La préparation est arrêtée ; corrigez le stockage puis rechargez le monde avant de reprendre.",
"sanctuary.expansion.error.journal_unavailable": "Le journal dexpansion est indisponible. La préparation est arrêtée ; corrigez le stockage puis rechargez le monde avant de reprendre.",
"sanctuary.expansion.error.invalid_id": "Lidentifiant doit commencer par une lettre minuscule et contenir au plus 32 lettres minuscules, chiffres ou tirets bas.",
"sanctuary.expansion.error.invalid_climate": "Climat inconnu. Utiliser lautocomplétion pour choisir un profil.",
"sanctuary.expansion.error.invalid_diameter": "Les diamètres autorisés sont 64, 128, 256 et 512 blocs.",
"sanctuary.expansion.error.invalid_relief": "Relief inconnu : natural, peaks, canyon ou volcano.",
"sanctuary.expansion.error.invalid_distance": "La distance doit être comprise entre 0 et 100000 blocs. 0 utilise le placement automatique.",
"sanctuary.expansion.error.no_free_space": "Aucun emplacement libre trouvé dans les limites de recherche. Réessayer avec une distance plus grande ou un autre parent. Aucun terrain existant na été modifié.",
"sanctuary.expansion.error.search_timeout": "La recherche a atteint sa limite de 30 secondes. Réessayer avec une distance plus grande. Aucun terrain existant na été modifié.",
"sanctuary.world_options.title": "Sanctuary Island",
"sanctuary.world_options.description": "Une île flottante, ses rivières et ses cavités, puis des continents à découvrir.",
"sanctuary.world_options.size": "Taille de l’île",
"sanctuary.world_options.size.small": "Petit",
"sanctuary.world_options.size.medium": "Moyen",
"sanctuary.world_options.size.large": "Grand",
"sanctuary.world_options.diameter": "Diamètre nominal : %s blocs. Le relief et la graine dessinent le contour."
}
@@ -1,8 +1,6 @@
{
"replace": false,
"values": [
"sanctuary:sanctuary",
"sanctuary:sanctuary_5",
"sanctuary:sanctuary_100"
"sanctuary:sanctuary"
]
}
@@ -0,0 +1,53 @@
{
"type": "sanctuary:population_island",
"terrain": {
"type": "minecraft:squeeze",
"input": {
"type": "minecraft:interpolated",
"cell_size_xz": 8,
"cell_size_y": 4,
"input": {
"type": "minecraft:mul",
"left": 0.64,
"right": "sanctuary:floating_archipelago_384"
}
}
},
"distortion": {
"type": "minecraft:noise",
"noise": "sanctuary:island_edge",
"xz_scale": 1.0,
"y_scale": 0.0
},
"sculpt": {
"type": "minecraft:noise",
"noise": "sanctuary:island_sculpt",
"xz_scale": 1.0,
"y_scale": 1.0
},
"detail": {
"type": "minecraft:noise",
"noise": "sanctuary:island_detail",
"xz_scale": 1.0,
"y_scale": 1.0
},
"underside": {
"type": "minecraft:noise",
"noise": "sanctuary:island_underside",
"xz_scale": 1.0,
"y_scale": 1.0
},
"players": 10,
"rift_warp": {
"type": "minecraft:noise",
"noise": "sanctuary:rift_warp",
"xz_scale": 1.0,
"y_scale": 1.0
},
"rift_detail": {
"type": "minecraft:noise",
"noise": "sanctuary:rift_detail",
"xz_scale": 1.0,
"y_scale": 1.0
}
}
@@ -0,0 +1,27 @@
{
"type": "minecraft:sequence",
"sequence": [
{
"type": "minecraft:condition",
"if_true": {
"type": "minecraft:biome",
"biome_is": [
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:sulfur_caves"
]
},
"then_run": {
"type": "minecraft:sequence",
"sequence": [
"minecraft:overworld",
{
"type": "minecraft:block",
"result_state": "minecraft:stone"
}
]
}
},
"sanctuary:population_island"
]
}
@@ -0,0 +1,23 @@
{
"default_block": "minecraft:stone",
"default_fluid": "minecraft:air",
"disable_mob_generation": false,
"legacy_random_source": true,
"material_rule": "sanctuary:population_island",
"noise": {
"height": 384,
"min_y": 0
},
"noise_router": {
"chunk_surface_level": 0.0,
"continents": 0.0,
"depth": 0.0,
"erosion": 0.0,
"final_density": "sanctuary:population_10",
"ridges": 0.0,
"temperature": "sanctuary:temperate_variation",
"vegetation": "sanctuary:temperate_moisture"
},
"sea_level": -64,
"spawn_target": []
}
@@ -0,0 +1,23 @@
{
"default_block": "minecraft:stone",
"default_fluid": "minecraft:air",
"disable_mob_generation": false,
"legacy_random_source": true,
"material_rule": "sanctuary:unified_island",
"noise": {
"height": 384,
"min_y": 0
},
"noise_router": {
"chunk_surface_level": 0.0,
"continents": 0.0,
"depth": 0.0,
"erosion": 0.0,
"final_density": "sanctuary:population_10",
"ridges": 0.0,
"temperature": "sanctuary:temperate_variation",
"vegetation": "sanctuary:temperate_moisture"
},
"sea_level": -64,
"spawn_target": []
}
@@ -0,0 +1,23 @@
{
"default_block": "minecraft:stone",
"default_fluid": "minecraft:air",
"disable_mob_generation": false,
"legacy_random_source": true,
"material_rule": "sanctuary:unified_island",
"noise": {
"height": 384,
"min_y": 0
},
"noise_router": {
"chunk_surface_level": 0.0,
"continents": 0.0,
"depth": 0.0,
"erosion": 0.0,
"final_density": "sanctuary:population_20",
"ridges": 0.0,
"temperature": "sanctuary:temperate_variation",
"vegetation": "sanctuary:temperate_moisture"
},
"sea_level": -64,
"spawn_target": []
}
@@ -0,0 +1,23 @@
{
"default_block": "minecraft:stone",
"default_fluid": "minecraft:air",
"disable_mob_generation": false,
"legacy_random_source": true,
"material_rule": "sanctuary:unified_island",
"noise": {
"height": 384,
"min_y": 0
},
"noise_router": {
"chunk_surface_level": 0.0,
"continents": 0.0,
"depth": 0.0,
"erosion": 0.0,
"final_density": "sanctuary:population_5",
"ridges": 0.0,
"temperature": "sanctuary:temperate_variation",
"vegetation": "sanctuary:temperate_moisture"
},
"sea_level": -64,
"spawn_target": []
}
@@ -0,0 +1,70 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "sanctuary:expansion_lab",
"biome_source": {
"biomes": [
"minecraft:the_void",
"minecraft:forest",
"minecraft:birch_forest",
"minecraft:flower_forest",
"minecraft:plains",
"minecraft:meadow",
"minecraft:taiga",
"minecraft:snowy_taiga",
"minecraft:snowy_plains",
"minecraft:grove",
"minecraft:ice_spikes",
"minecraft:frozen_peaks",
"minecraft:old_growth_spruce_taiga",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:pale_garden",
"minecraft:jungle",
"minecraft:sparse_jungle",
"minecraft:bamboo_jungle",
"minecraft:mangrove_swamp",
"minecraft:savanna",
"minecraft:savanna_plateau",
"minecraft:desert",
"minecraft:badlands",
"minecraft:eroded_badlands",
"minecraft:wooded_badlands",
"minecraft:windswept_hills",
"minecraft:windswept_gravelly_hills",
"minecraft:stony_peaks",
"minecraft:dappled_forest",
"minecraft:cherry_grove",
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:sulfur_caves"
]
},
"initial_diameter": 512
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -3,21 +3,56 @@
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "minecraft:noise",
"type": "sanctuary:island_v13",
"biome_source": {
"type": "sanctuary:population_island",
"oak_forest": "sanctuary:population_oak_forest",
"birch_forest": "sanctuary:population_birch_forest",
"clearing": "sanctuary:population_clearing",
"dry_woodland": "sanctuary:population_dry_woodland",
"dark_grove": "sanctuary:population_dark_grove",
"bamboo_grove": "sanctuary:population_bamboo_grove",
"sulfur_depths": "sanctuary:population_sulfur_depths",
"rocky_heath": "sanctuary:population_rocky_heath",
"lush_caves": "sanctuary:population_lush_caves",
"dripstone_caves": "sanctuary:population_dripstone_caves"
"biomes": [
"minecraft:the_void",
"minecraft:forest",
"minecraft:birch_forest",
"minecraft:flower_forest",
"minecraft:plains",
"minecraft:meadow",
"minecraft:taiga",
"minecraft:snowy_taiga",
"minecraft:snowy_plains",
"minecraft:grove",
"minecraft:ice_spikes",
"minecraft:frozen_peaks",
"minecraft:old_growth_spruce_taiga",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:pale_garden",
"minecraft:jungle",
"minecraft:sparse_jungle",
"minecraft:bamboo_jungle",
"minecraft:mangrove_swamp",
"minecraft:savanna",
"minecraft:savanna_plateau",
"minecraft:desert",
"minecraft:badlands",
"minecraft:eroded_badlands",
"minecraft:wooded_badlands",
"minecraft:windswept_hills",
"minecraft:windswept_gravelly_hills",
"minecraft:stony_peaks",
"minecraft:dappled_forest",
"minecraft:cherry_grove",
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:sulfur_caves",
"sanctuary:population_oak_forest",
"sanctuary:population_birch_forest",
"sanctuary:population_clearing",
"sanctuary:population_dry_woodland",
"sanctuary:population_rocky_heath",
"sanctuary:population_dark_grove",
"sanctuary:population_bamboo_grove",
"sanctuary:population_sulfur_depths",
"sanctuary:population_lush_caves",
"sanctuary:population_dripstone_caves"
]
},
"settings": "sanctuary:population_20"
"initial_diameter": 724
}
},
"minecraft:the_end": {
@@ -0,0 +1,45 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "sanctuary:population_island",
"oak_forest": "sanctuary:population_oak_forest",
"birch_forest": "sanctuary:population_birch_forest",
"clearing": "sanctuary:population_clearing",
"dry_woodland": "sanctuary:population_dry_woodland",
"dark_grove": "sanctuary:population_dark_grove",
"bamboo_grove": "sanctuary:population_bamboo_grove",
"sulfur_depths": "sanctuary:population_sulfur_depths",
"rocky_heath": "sanctuary:population_rocky_heath",
"lush_caves": "sanctuary:population_lush_caves",
"dripstone_caves": "sanctuary:population_dripstone_caves"
},
"settings": "sanctuary:population_10"
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -0,0 +1,45 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "sanctuary:sanctuary_384",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "sanctuary:population_island",
"oak_forest": "sanctuary:population_oak_forest",
"birch_forest": "sanctuary:population_birch_forest",
"clearing": "sanctuary:population_clearing",
"dry_woodland": "sanctuary:population_dry_woodland",
"dark_grove": "sanctuary:population_dark_grove",
"bamboo_grove": "sanctuary:population_bamboo_grove",
"sulfur_depths": "sanctuary:population_sulfur_depths",
"rocky_heath": "sanctuary:population_rocky_heath",
"lush_caves": "sanctuary:population_lush_caves",
"dripstone_caves": "sanctuary:population_dripstone_caves"
},
"settings": "sanctuary:population_20"
}
},
"minecraft:the_end": {
"type": "minecraft:the_end",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:the_end"
},
"settings": "minecraft:end"
}
},
"minecraft:the_nether": {
"type": "minecraft:the_nether",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:multi_noise",
"preset": "minecraft:nether"
},
"settings": "minecraft:nether"
}
}
}
}
@@ -3,16 +3,20 @@
"package": "fr.koka.sanctuary.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"ExpansionChunkMapAccessor",
"ExpansionCarverMixin",
"InitialSpawnMixin",
"SurfaceHydrologyMixin",
"LayeredSpringOutletMixin",
"WoodlandSpringOutletMixin",
"RiftSpringOutletMixin",
"CavernSpringOutletMixin"
"CavernSpringOutletMixin",
"UnifiedDecorationProtectionMixin"
],
"client": [
"client.SanctuarySkyMixin",
"client.SanctuaryFogMixin"
"client.SanctuaryFogMixin",
"client.SanctuaryWorldOptionsMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,221 @@
package fr.koka.sanctuary.expansion;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/** Persistence failures must never turn saved continents into a new empty history. No game world is opened. */
public final class ExpansionJournalSmoke {
private static final long SEED = Long.MIN_VALUE + 37;
public static void main(String[] args) throws Exception {
Path scratch = Files.createTempDirectory("sanctuary-expansion-journal-");
try {
Path directory = scratch.resolve("normal");
var journal = new ExpansionJournal(directory, SEED, 512);
ExpansionIsland root = ExpansionIsland.origin(512, SEED);
ExpansionIsland north = island("north_one", "sanctuary", "north", 0, -1024);
require(journal.islands().equals(List.of(root)), "New journal must contain the exact root");
byte[] original = Files.readAllBytes(directory.resolve(ExpansionJournal.FILE_NAME));
journal.commit(List.of(root));
require(Arrays.equals(original, Files.readAllBytes(directory.resolve(ExpansionJournal.FILE_NAME))), "Repeating a commit must leave the journal unchanged");
journal.commit(List.of(root, north));
require(new ExpansionJournal(directory, SEED, 512).islands().equals(List.of(root, north)), "Reservation must survive restart before generation");
journal.commit(List.of(root, north.ready()));
require(new ExpansionJournal(directory, SEED, 512).islands().equals(List.of(root, north.ready())), "Completion must survive restart");
try { journal.islands().add(north); throw new AssertionError("Journal list was mutable"); } catch (UnsupportedOperationException expected) { }
reject(() -> journal.commit(List.of(root)), "Removing a region");
reject(() -> journal.commit(List.of(root, north)), "Reverting a completed region");
reject(() -> journal.commit(List.of(root, island("north_one", "sanctuary", "north", 0, -2048).ready())), "Moving an existing region");
reject(() -> journal.commit(List.of(root, north.ready(), north)), "Duplicating an identifier");
reject(() -> journal.commit(List.of(root, north.ready(), island("near", "sanctuary", "east", 512, 0))), "Overlapping reservations");
reject(() -> journal.commit(List.of(root, north.ready(), island("orphan", "missing", "east", 2048, 0))), "Missing parent");
reject(() -> journal.commit(List.of(root, north.ready(), island("wrong_way", "sanctuary", "east", 0, 2048))), "Wrong direction");
reject(() -> journal.commit(List.of(root, north.ready(), island("early_ready", "sanctuary", "east", 2048, 0).ready())), "Skipping reservation");
reject(() -> new ExpansionJournal(directory, SEED + 1, 512), "Wrong world seed");
reject(() -> new ExpansionJournal(directory, SEED, 724), "Wrong root diameter");
require(journal.islands().equals(List.of(root, north.ready())), "Rejected plans changed the live journal");
var larger = new ExpansionJournal(scratch.resolve("large"), SEED, 724);
require(larger.islands().getFirst().equals(ExpansionIsland.origin(724, SEED)), "Large origin must reload exactly");
reject(() -> new ExpansionJournal(scratch.resolve("unsupported"), SEED, 256), "Unsupported origin size");
// The new root shares schema 1, while its generation and diameter remain immutable.
Path unifiedDirectory = scratch.resolve("unified-large");
var unified = new ExpansionJournal(unifiedDirectory, SEED, 1024, 13);
var unifiedRoot = ExpansionIsland.origin(1024, SEED, 13);
require(unified.islands().equals(List.of(unifiedRoot)), "Unified large journal must contain the exact generation-13 root");
unified.commit(List.of(unifiedRoot, north));
Path unifiedState = unifiedDirectory.resolve(ExpansionJournal.FILE_NAME);
byte[] unifiedBytes = Files.readAllBytes(unifiedState);
require(com.google.gson.JsonParser.parseString(Files.readString(unifiedState)).getAsJsonObject()
.get("schema").getAsInt() == 1, "Unified origins must keep journal schema 1");
require(new ExpansionJournal(unifiedDirectory, SEED, 1024, 13).islands().equals(List.of(unifiedRoot, north)),
"Unified root and its generation-12 child reservation must reload identically");
reject(() -> new ExpansionJournal(unifiedDirectory, SEED, 1024, 12), "Unified large journal read as generation 12");
reject(() -> new ExpansionJournal(unifiedDirectory, SEED, 512, 13), "Unified large journal read with Small diameter");
reject(() -> new ExpansionJournal(unifiedDirectory, SEED, 724, 13), "Unified large journal read with Medium diameter");
require(Arrays.equals(unifiedBytes, Files.readAllBytes(unifiedState)), "Rejected unified journal reads changed its bytes");
// 724 is valid in both generations: this reaches the persisted root check, not the size guard.
Path unifiedMediumDirectory = scratch.resolve("unified-medium");
new ExpansionJournal(unifiedMediumDirectory, SEED, 724, 13);
byte[] unifiedMediumBytes = Files.readAllBytes(unifiedMediumDirectory.resolve(ExpansionJournal.FILE_NAME));
reject(() -> new ExpansionJournal(unifiedMediumDirectory, SEED, 724, 12), "Same-size unified journal read as generation 12");
require(Arrays.equals(unifiedMediumBytes, Files.readAllBytes(unifiedMediumDirectory.resolve(ExpansionJournal.FILE_NAME))),
"Rejected cross-generation read changed its journal");
Path missing = scratch.resolve("missing"); Files.createDirectory(missing);
reject(() -> new ExpansionJournal(missing, SEED, 512), "Existing directory with no state");
Files.writeString(missing.resolve(".expansions-interrupted.tmp"), "partial");
reject(() -> new ExpansionJournal(missing, SEED, 512), "Interrupted bootstrap");
require(!Files.exists(missing.resolve(ExpansionJournal.FILE_NAME)), "Missing journal was silently created");
String valid = Files.readString(directory.resolve(ExpansionJournal.FILE_NAME));
String[] invalid = {
"{",
valid + " {}",
valid.replace("\"schema\": 1", "\"schema\": 2"),
valid.replace("\"schema\": 1", "\"schema\": 1, \"schema\": 1"),
valid.replace("\"schema\": 1", "\"schema\": 1.1"),
valid.replace("\"root_diameter\": 512", "\"root_diameter\": \"512\""),
valid.replace("\"center_x\": 0", "\"center_x\": 0.1"),
valid.replace("\"generation\": 12", "\"generation\": 13"),
valid.replace("\"parent\": \"sanctuary\"", "\"parent\": \"unknown\""),
valid.replace("\"id\": \"north_one\"", "\"id\": \"sanctuary\""),
valid.replace("\"seed\": " + north.seed(), "\"seed\": 0"),
valid.replace("\"center_z\": -1024", "\"center_z\": -16")
};
for (int i = 0; i < invalid.length; i++) {
Path damaged = scratch.resolve("damaged_" + i); Files.createDirectory(damaged);
Path state = damaged.resolve(ExpansionJournal.FILE_NAME); Files.writeString(state, invalid[i]);
byte[] before = Files.readAllBytes(state);
reject(() -> new ExpansionJournal(damaged, SEED, 512), "Corrupt journal " + i);
require(Arrays.equals(before, Files.readAllBytes(state)), "Corrupt journal was overwritten");
}
// A second writer and external damage must fail closed before replacing the on-disk plan.
var stale = new ExpansionJournal(directory, SEED, 512);
ExpansionIsland east = island("east_one", "sanctuary", "east", 2048, 0);
journal.commit(List.of(root, north.ready(), east));
reject(() -> stale.commit(List.of(root, north.ready())), "Stale writer");
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
Files.writeString(state, "broken", StandardCharsets.UTF_8);
reject(() -> journal.commit(List.of(root, north.ready(), east.ready())), "Externally damaged state");
require(Files.readString(state).equals("broken"), "Damaged live state was overwritten");
// A retained reservation can be completed after reload, then extended through its ready parent.
Path resumeDirectory = scratch.resolve("resume");
var beforeCrash = new ExpansionJournal(resumeDirectory, SEED, 512);
beforeCrash.commit(List.of(root, north));
var afterCrash = new ExpansionJournal(resumeDirectory, SEED, 512);
ExpansionIsland onward = island("onward", "north_one", "north", 0, -2048);
reject(() -> afterCrash.commit(List.of(root, north, onward)), "Unfinished parent");
afterCrash.commit(List.of(root, north.ready()));
afterCrash.commit(List.of(root, north.ready(), onward));
require(new ExpansionJournal(resumeDirectory, SEED, 512).islands().size() == 3, "Resumed region lost its descendant");
windowsDirectoryPolicy(scratch.resolve("windows-policy"));
directoryFailureAfterReplacement(scratch.resolve("directory-failure"));
readbackFailureAfterReplacement(scratch.resolve("readback-failure"));
var tooMany = new ArrayList<ExpansionIsland>(); tooMany.add(root);
for (int i = 0; i < 64; i++) tooMany.add(island("overflow_" + i, "sanctuary", "north", 0, -(i + 1) * 1024));
reject(() -> afterCrash.commit(tooMany), "Region count limit");
System.out.println("Expansion journal smoke passed: atomic persistence, restart, immutable history, corruption, interrupted reservation, unified root identity, Windows directory policy and post-replacement failures");
} finally {
try (var paths = Files.walk(scratch)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
}
}
}
private static void windowsDirectoryPolicy(Path directory) throws Exception {
var directoryAttempts = new AtomicInteger();
var windowsSync = ExpansionJournal.directorySyncFor("\\", path -> {
directoryAttempts.incrementAndGet();
throw new AccessDeniedException(path.toString());
});
var root = ExpansionIsland.origin(724, SEED, 13);
var north = island("windows_north", "sanctuary", "north", 0, -1024);
var journal = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
require(journal.healthy() && journal.islands().equals(List.of(root)),
"Windows policy must bootstrap a verified journal without opening the directory as a file");
journal.commit(List.of(root, north));
var reserved = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
require(reserved.healthy() && reserved.islands().equals(List.of(root, north)),
"Windows policy must persist and reload the exact reservation");
reserved.commit(List.of(root, north.ready()));
var ready = new ExpansionJournal(directory, SEED, 724, 13, windowsSync);
require(ready.healthy() && ready.islands().equals(List.of(root, north.ready())),
"Windows policy must complete and reload the same reservation");
require(directoryAttempts.get() == 0,
"Windows policy must never attempt the unsupported directory channel, including bootstrap and completion");
}
private static void directoryFailureAfterReplacement(Path directory) throws Exception {
var root = ExpansionIsland.origin(724, SEED, 13);
var north = island("retained_north", "sanctuary", "north", 0, -1024);
new ExpansionJournal(directory, SEED, 724, 13);
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
var attempts = new AtomicInteger();
var denied = new AccessDeniedException(directory.toString());
var requiredSync = ExpansionJournal.directorySyncFor("/", path -> {
attempts.incrementAndGet();
throw denied;
});
var failed = new ExpansionJournal(directory, SEED, 724, 13, requiredSync);
try {
failed.commit(List.of(root, north));
throw new AssertionError("A directory sync failure on a supported filesystem was ignored");
} catch (AccessDeniedException actual) {
require(actual == denied, "The original directory sync error must reach the caller unchanged");
}
require(attempts.get() == 1, "A supported filesystem must attempt directory synchronization");
require(!failed.healthy() && failed.islands().equals(List.of(root)),
"Uncertain persistence must fail closed without publishing the new reservation to world generation");
byte[] reservedBytes = Files.readAllBytes(state);
var recovered = new ExpansionJournal(directory, SEED, 724, 13);
require(recovered.healthy() && recovered.islands().equals(List.of(root, north)),
"A verified reload must recover the reservation already installed before directory sync failed");
reject(() -> failed.commit(List.of(root, north)), "Retrying a failed journal without reload");
require(attempts.get() == 1 && Arrays.equals(reservedBytes, Files.readAllBytes(state)),
"A failed session must neither retry storage operations nor overwrite its retained reservation");
recovered.commit(List.of(root, north.ready()));
require(new ExpansionJournal(directory, SEED, 724, 13).islands().equals(List.of(root, north.ready())),
"A recovered reservation must complete normally without changing its identity");
}
private static void readbackFailureAfterReplacement(Path directory) throws Exception {
var root = ExpansionIsland.origin(724, SEED, 13);
var north = island("readback_north", "sanctuary", "north", 0, -1024);
new ExpansionJournal(directory, SEED, 724, 13);
Path state = directory.resolve(ExpansionJournal.FILE_NAME);
var journal = new ExpansionJournal(directory, SEED, 724, 13,
path -> Files.writeString(state, "damaged after atomic replacement", StandardCharsets.UTF_8));
reject(() -> journal.commit(List.of(root, north)), "Corruption detected by post-replacement verification");
require(!journal.healthy() && journal.islands().equals(List.of(root)),
"Successful replacement alone must not publish an unverified reservation");
require(Files.readString(state).equals("damaged after atomic replacement"),
"A failed readback must preserve evidence instead of silently recreating the journal");
reject(() -> new ExpansionJournal(directory, SEED, 724, 13), "Reloading a journal damaged after replacement");
}
private static ExpansionIsland island(String id, String parent, String direction, int x, int z) {
return new ExpansionIsland(id, parent, direction, ExpansionIsland.directionalClimate(direction), 64, x, z,
ExpansionIsland.deriveSeed(SEED, id), "natural", "reserved", ExpansionIsland.GENERATION);
}
private static void reject(IOAction action, String description) throws Exception {
try { action.run(); } catch (IOException expected) { return; }
throw new AssertionError(description + " was accepted");
}
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
@FunctionalInterface private interface IOAction { void run() throws Exception; }
}
@@ -0,0 +1,159 @@
package fr.koka.sanctuary.expansion;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
/** Requested distances and retry positions must preserve the saved graph and its reservation boundaries. */
public final class ExpansionPlacementSmoke {
private static final long SEED = 42;
public static void main(String[] args) {
var root = ExpansionIsland.origin(512, SEED);
var saved = new ArrayList<>(List.of(root));
var before = List.copyOf(saved);
for (String direction : ExpansionIsland.DIRECTIONS) {
var defaults = candidates(saved, "new_island", root.id(), direction, 64, 0);
verifyCandidates(defaults, saved, root, direction, 64, 0);
require(defaults.size() == 32, "An unobstructed origin must offer later positions when the first saved chunks are occupied");
var first = defaults.getFirst();
require(Math.max(Math.abs(first.centerX()), Math.abs(first.centerZ())) == 800,
"Distance zero must retain the alpha.12 first placement in " + direction);
require(first.equals(candidates(saved, "new_island", root.id(), direction, 64, 1).getFirst()),
"A short requested distance must still respect the existing reservation and generation halo");
for (int diameter : ExpansionIsland.DIAMETERS) {
var distant = candidates(saved, "new_island", root.id(), direction, diameter, 2048);
verifyCandidates(distant, saved, root, direction, diameter, 2048);
require(distance(root, distant.getFirst()) < 2048 + Math.sqrt(2) * 16,
"An unobstructed requested distance needs only chunk alignment, not an unnecessary extra ring");
require(distant.equals(candidates(saved, "new_island", root.id(), direction, diameter, 2048)),
"Repeating a preview must produce the same ordered candidates");
}
rejectMutation(defaults, root);
}
require(saved.equals(before), "Planning changed the saved island descriptors or their order");
require(root.generation() == 12 && root.status().equals("ready") && root.centerX() == 0 && root.centerZ() == 0,
"The distance option must not migrate or move an existing alpha.12 origin");
var first = candidates(saved, "occupied", root.id(), "north", 64, 0).getFirst().ready();
var withSibling = new ArrayList<>(List.of(root, first));
var siblingSnapshot = List.copyOf(withSibling);
var onward = candidates(withSibling, "later", root.id(), "north", 64, 0);
verifyCandidates(onward, withSibling, root, "north", 64, 0);
require(distance(root, onward.getFirst()) > distance(root, first),
"An already reserved island must move the search farther along the chosen ray");
require(withSibling.equals(siblingSnapshot), "Avoiding a sibling changed existing saved regions");
// Two distant parents isolate translation from any obstruction around the origin.
var parentA = parent("branch", 8192, -8192);
var parentB = parent("branch", -16384, 16384);
for (String direction : ExpansionIsland.DIRECTIONS) {
var localA = candidates(List.of(root, parentA), "child", parentA.id(), direction, 128, 2048);
var localB = candidates(List.of(root, parentB), "child", parentB.id(), direction, 128, 2048);
verifyCandidates(localA, List.of(root, parentA), parentA, direction, 128, 2048);
verifyCandidates(localB, List.of(root, parentB), parentB, direction, 128, 2048);
require(localA.getFirst().centerX() - parentA.centerX() == localB.getFirst().centerX() - parentB.centerX()
&& localA.getFirst().centerZ() - parentA.centerZ() == localB.getFirst().centerZ() - parentB.centerZ(),
"The requested distance is measured from the selected parent, including negative coordinates");
}
var tropical = ExpansionPlacement.candidates(SEED, saved, "palette", root.id(), "north", "tropical", 128, "natural", 2048);
var arid = ExpansionPlacement.candidates(SEED, saved, "palette", root.id(), "north", "arid", 128, "volcano", 2048);
require(tropical.getFirst().centerX() == arid.getFirst().centerX() && tropical.getFirst().centerZ() == arid.getFirst().centerZ(),
"Climate and relief choices must not impose a different geographical direction or distance");
for (int sign : new int[]{-1, 1}) {
String direction = sign < 0 ? "west" : "east";
var edge = parent("edge", sign * 99200, 0);
var nearLimit = candidates(List.of(root, edge), "border", edge.id(), direction, 64, 0);
verifyCandidates(nearLimit, List.of(root, edge), edge, direction, 64, 0);
require(nearLimit.size() < 32, "The search must stop at the coordinate boundary instead of wrapping around");
var maximum = candidates(saved, "maximum", root.id(), direction, 64, 100000);
verifyCandidates(maximum, saved, root, direction, 64, 100000);
require(maximum.getFirst().centerX() == sign * 100000, "The inclusive distance and coordinate limit must remain usable");
var boundary = parent("boundary", sign * 100000, 0);
require(candidates(List.of(root, boundary), "beyond", boundary.id(), direction, 64, 0).isEmpty(),
"An exhausted ray must return no candidate instead of clamping or wrapping into existing terrain");
}
rejectDistance(saved, -1);
rejectDistance(saved, Integer.MIN_VALUE);
rejectDistance(saved, 100001);
rejectDistance(saved, Integer.MAX_VALUE);
require(saved.equals(before), "Failed or alternative previews mutated the saved origin");
System.out.println("ExpansionPlacementSmoke: eight directions, explicit distances, all diameters, reservation avoidance, parent translation, bounds and immutable history passed.");
}
private static List<ExpansionIsland> candidates(List<ExpansionIsland> saved, String id, String parent, String direction, int diameter, int minimumDistance) {
return ExpansionPlacement.candidates(SEED, saved, id, parent, direction, "autumn", diameter, "peaks", minimumDistance);
}
private static void verifyCandidates(List<ExpansionIsland> candidates, List<ExpansionIsland> saved, ExpansionIsland parent,
String direction, int diameter, int minimumDistance) {
require(!candidates.isEmpty() && candidates.size() <= 32, "A feasible ray returns one to thirty-two candidates");
var positions = new HashSet<String>();
double previous = -1;
for (var candidate : candidates) {
long dx = (long) candidate.centerX() - parent.centerX();
long dz = (long) candidate.centerZ() - parent.centerZ();
require((candidate.centerX() & 15) == 0 && (candidate.centerZ() & 15) == 0, "Candidate centres must align to whole chunks");
require(Math.abs((long) candidate.centerX()) <= 100000 && Math.abs((long) candidate.centerZ()) <= 100000,
"Candidate centres stay within the saved coordinate contract");
require(positions.add(candidate.centerX() + ":" + candidate.centerZ()), "A retry must not revisit an earlier candidate");
require(onRay(dx, dz, direction), "Every retry remains on the chosen parent-relative direction");
double current = distance(parent, candidate);
require(current >= minimumDistance && current > previous, "Retries move outward and respect the minimum centre-to-centre distance");
previous = current;
for (var existing : saved) require(!candidate.conflicts(existing), "Candidates must not overlap any existing reservation and its halo");
require(candidate.parent().equals(parent.id()) && candidate.direction().equals(direction)
&& candidate.climate().equals("autumn") && candidate.diameter() == diameter && candidate.relief().equals("peaks"),
"Placement must preserve the selected branch and generation profile");
require(candidate.seed() == ExpansionIsland.deriveSeed(SEED, candidate.id()) && candidate.generation() == 12
&& candidate.status().equals("reserved"), "Retries preserve the island seed, generation version and initial reservation state");
}
}
private static boolean onRay(long dx, long dz, String direction) {
return switch (direction) {
case "north" -> dx == 0 && dz < 0;
case "northeast" -> dx > 0 && dz == -dx;
case "east" -> dx > 0 && dz == 0;
case "southeast" -> dx > 0 && dz == dx;
case "south" -> dx == 0 && dz > 0;
case "southwest" -> dx < 0 && dz == -dx;
case "west" -> dx < 0 && dz == 0;
case "northwest" -> dx < 0 && dz == dx;
default -> false;
};
}
private static ExpansionIsland parent(String id, int x, int z) {
String direction = z == 0 ? (x < 0 ? "west" : "east")
: (z < 0 ? (x > 0 ? "northeast" : "northwest") : (x > 0 ? "southeast" : "southwest"));
return new ExpansionIsland(id, "sanctuary", direction, "temperate", 64, x, z,
ExpansionIsland.deriveSeed(SEED, id), "natural", "ready", 12);
}
private static double distance(ExpansionIsland from, ExpansionIsland to) {
return Math.hypot((double) to.centerX() - from.centerX(), (double) to.centerZ() - from.centerZ());
}
private static void rejectDistance(List<ExpansionIsland> saved, int distance) {
try { candidates(saved, "invalid", "sanctuary", "north", 64, distance); }
catch (IllegalArgumentException expected) {
require("invalid_distance".equals(expected.getMessage()), "An invalid distance needs its specific translated error key");
return;
}
throw new AssertionError("Out-of-range distance was accepted: " + distance);
}
private static void rejectMutation(List<ExpansionIsland> candidates, ExpansionIsland root) {
try { candidates.add(root); }
catch (UnsupportedOperationException expected) { return; }
throw new AssertionError("The candidate list exposes mutable shared state");
}
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
}
@@ -0,0 +1,88 @@
package fr.koka.sanctuary.expansion;
/** Coordinate continuity, finite small islands, preserved root underside and real upper landforms. */
public final class ExpansionReliefSmoke {
public static void main(String[] args) {
for (long seed : new long[]{0, 42, 8675309, Long.MIN_VALUE, Long.MAX_VALUE}) {
for (int diameter : new int[]{512, 724}) {
var root = ExpansionIsland.origin(diameter, seed);
require(ExpansionRelief.verticalScale(root) == 1.0, "Root height must not be compressed");
for (int x = -300; x <= 300; x += 31) for (int z = -300; z <= 300; z += 37) {
var column = ExpansionRelief.column(root, x, z);
require(ExpansionRelief.sourceX(root, x) == x && ExpansionRelief.sourceZ(root, z) == z,
"Rounded public root diameter must not rescale the old coordinates");
for (int y = 1; y <= 160; y += 7) {
require(ExpansionRelief.sourceY(root, column, y) == y, "Keep root underside exactly in place");
require(ExpansionRelief.sculpt(root, column, y, 0.7F) == 0.7F, "Do not carve the natural root underside");
}
}
}
for (int diameter : ExpansionIsland.DIAMETERS) for (String relief : ExpansionIsland.RELIEFS) {
var island = island(seed, diameter, relief, 1024, -2048);
require(ExpansionRelief.verticalScale(island) >= 0.4 && ExpansionRelief.verticalScale(island) <= 1.0,
"Small islands keep enough vertical space for two levels");
for (int x = -diameter; x <= diameter; x += Math.max(1, diameter / 12)) {
int wx = island.centerX() + x, wz = island.centerZ() + diameter / 11;
var column = ExpansionRelief.column(island, wx, wz);
require(column.equals(ExpansionRelief.column(island, wx, wz)), "Seed and coordinates reproduce the relief");
require(Math.abs(column.uplift()) <= 48 && column.canyonWeight() >= 0 && column.canyonWeight() <= 1
&& column.craterWeight() >= 0 && column.craterWeight() <= 1, "Deformation is finite and bounded");
double previous = Double.NEGATIVE_INFINITY;
for (int y = 0; y <= 384; y++) {
double sy = ExpansionRelief.sourceY(island, column, y);
require(Double.isFinite(sy) && sy > previous, "Y remapping must not fold or invert the source terrain");
previous = sy;
require(ExpansionRelief.sculpt(island, column, y, -0.3F) == -0.3F, "Cuts retain preexisting void");
float density = ExpansionRelief.sculpt(island, column, y, 0.8F);
require(Float.isFinite(density) && density <= 0.8F && density >= -1.0F, "Cuts never add material");
}
if (Math.abs(x) >= island.radius()) require(column.equals(new ExpansionRelief.Column(0, 0, 0)),
"Upper deformation fades before the outer rim");
}
var shifted = island(seed, diameter, relief, -4096, 8192);
require(ExpansionRelief.column(island, island.centerX() + 9, island.centerZ() - 7)
.equals(ExpansionRelief.column(shifted, shifted.centerX() + 9, shifted.centerZ() - 7)),
"Moving an island must not change its local relief");
if (relief.equals("volcano")) {
require(ExpansionRelief.inCrater(island, island.centerX(), island.centerZ()), "A volcano has an inner bowl");
require(!ExpansionRelief.inCrater(island, island.centerX() + diameter / 4, island.centerZ()), "Its flank is not the crater");
int center = syntheticSurface(island, 0, 0);
int flank = syntheticSurface(island, diameter / 8, 0);
require(flank - center >= 8, "A volcanic profile must form an elevated ring around an actual depressed centre");
}
if (relief.equals("canyon")) {
var center = ExpansionRelief.column(island, island.centerX(), island.centerZ());
require(ExpansionRelief.sculpt(island, center, ExpansionRelief.worldY(island, 220), 1.0F) < 0,
"The canyon must cut through a solid upper slab");
require(ExpansionRelief.sculpt(island, center, ExpansionRelief.worldY(island, 110), 1.0F) == 1.0F,
"The new canyon leaves the lowest support intact");
}
}
}
var first = island(0, 512, "peaks", 0, 0);
var second = island(42, 512, "peaks", 0, 0);
require(!ExpansionRelief.column(first, 67, -34).equals(ExpansionRelief.column(second, 67, -34)),
"The island seed must change upper relief");
int spread = 0;
for (int x = -160; x <= 160; x += 8) spread = Math.max(spread, Math.abs(syntheticSurface(first, x, 0) - syntheticSurface(first, 0, 0)));
require(spread >= 8, "Peaks must produce a visible vertical difference, not only a renamed palette");
System.out.println("ExpansionReliefSmoke: monotone scaling, preserved root underside, small-island bounds, canyon and volcanic ring passed.");
}
private static ExpansionIsland island(long seed, int diameter, String relief, int x, int z) {
return new ExpansionIsland("test", "sanctuary", "south", "warm", diameter, x, z, seed, relief, "ready", 12);
}
private static int syntheticSurface(ExpansionIsland island, int dx, int dz) {
var column = ExpansionRelief.column(island, island.centerX() + dx, island.centerZ() + dz);
int surface = -1;
for (int y = 1; y < 384; y++) {
double sy = ExpansionRelief.sourceY(island, column, y);
float slab = (float) Math.clamp(Math.min((sy - 70.0) / 12.0, (240.0 - sy) / 12.0), -1, 1);
if (ExpansionRelief.sculpt(island, column, y, slab) > 0) surface = y;
}
return surface;
}
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
}
@@ -6,8 +6,8 @@ import com.google.gson.JsonPrimitive;
/** Capacity and region boundaries are persistence contracts, including negative coordinates. */
public final class PopulationCapacitySmoke {
public static void main(String[] args) {
require(IslandCapacity.values().length == 3, "Only the three validated sizes are exposed");
for (int players : new int[]{5, 20, 100}) {
require(IslandCapacity.values().length == 4, "New small/large and both historical geometries remain supported");
for (int players : new int[]{5, 10, 20, 100}) {
var capacity = IslandCapacity.fromPlayers(players);
double ratio = Math.pow(capacity.radius() / IslandCapacity.FIVE.radius(), 2);
require(Math.abs(ratio - players / 5.0) < 1e-10, "Area scales with planned population");
@@ -34,7 +34,7 @@ public final class PopulationCapacitySmoke {
}
}
}
for (int invalid : new int[]{0, 1, 4, 6, 10, 21, 99, 101, -5}) {
for (int invalid : new int[]{0, 1, 4, 6, 11, 21, 99, 101, -5}) {
require(IslandCapacity.CODEC.parse(JsonOps.INSTANCE, new JsonPrimitive(invalid)).error().isPresent(),
"Unsupported saved counts must fail, not silently become a different island");
boolean rejected = false;
@@ -42,7 +42,7 @@ public final class PopulationCapacitySmoke {
catch (IllegalArgumentException expected) { rejected = true; }
require(rejected, "Unsupported configuration is rejected");
}
System.out.println("Population capacity smoke passed: 5/20/100, codec, area and signed region ownership");
System.out.println("Population capacity smoke passed: small/large, legacy sizes, codec and signed region ownership");
}
private static void require(boolean condition, String message) {
@@ -0,0 +1,65 @@
package fr.koka.sanctuary.worldgen;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/** Detects the world-wide stall when a cold region holds the planning lock. */
public final class PopulationPlanCacheSmoke {
public static void main(String[] args) throws Exception {
var cache = new PopulationPlanCache<String, Integer>();
cache.getOrCreate("ready", () -> 10);
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
try (var workers = Executors.newFixedThreadPool(8)) {
Future<Integer> cold = workers.submit(() -> cache.getOrCreate("cold", () -> {
entered.countDown();
try {
if (!release.await(10, TimeUnit.SECONDS)) throw new AssertionError("Cold plan was not released");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError(e);
}
return 20;
}));
try {
require(entered.await(5, TimeUnit.SECONDS), "Cold plan did not start");
Future<Integer> ready = workers.submit(() -> cache.getOrCreate("ready", () -> {
throw new AssertionError("Completed plan must not be rebuilt");
}));
require(ready.get(5, TimeUnit.SECONDS) == 10, "Warm reads must not await unrelated planning");
require(cache.size() == 1, "Unfinished plans must not be exposed");
} finally {
release.countDown();
}
require(cold.get(5, TimeUnit.SECONDS) == 20, "Cold result lost");
var builds = new AtomicInteger();
var start = new CountDownLatch(1);
var results = new ArrayList<Future<Integer>>();
for (int i = 0; i < 8; i++) results.add(workers.submit(() -> {
start.await();
return cache.getOrCreate("shared", builds::incrementAndGet);
}));
start.countDown();
for (var result : results) require(result.get(5, TimeUnit.SECONDS) == 1, "All callers need the same complete plan");
require(builds.get() == 1, "Concurrent misses must compute their region once");
}
try {
cache.getOrCreate("failed", () -> { throw new IllegalStateException("Expected planner failure"); });
throw new AssertionError("Planner failure was swallowed");
} catch (IllegalStateException expected) {
require(cache.size() == 3, "Failed plan must not be published");
}
require(cache.getOrCreate("failed", () -> 40) == 40, "A failed computation must remain retryable");
System.out.println("Population plan cache smoke passed: warm reads, single publication and failed-plan retry");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
+5 -2
View File
@@ -51,12 +51,15 @@ 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.10.mrpack
packwiz modrinth export --output ../Sanctuary-0.1.0-alpha.11.mrpack
```
Lexemple vise lalpha.11 et nécessite ses artefacts vérifiés.
Cette version propose Petit/Grand, avec Grand par défaut, et loptimisation ;
les anciennes tailles restent attachées aux sauvegardes déjà créées.
L'export contient le JAR Sanctuary local et la référence vérifiée de Fabric API.
Il ne publie rien sur Modrinth.
Lalpha.10 est publiée sur le canal stable. Linstance Sanctuary Beta existante
Lalpha.11 est publiée sur le canal stable. Linstance Sanctuary Beta existante
a été synchronisée deux fois et ses fichiers personnels suivis sont conservés.
Les instances déjà raccordées reçoivent cette mise à jour sans nouvelle importation.
Cet export reste destiné aux imports ponctuels.
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "0.1.0-alpha.10"
version = "0.1.0-alpha.13.1"
pack-format = "packwiz:1.1.0"
[index]