Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19e93dd21 | ||
|
|
3569cf1f5f | ||
|
|
78b63be8e1 | ||
|
|
818c6753f9 | ||
|
|
332d60be96 |
+62
-1
@@ -1,6 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-alpha.12 — 2026-09-09
|
||||
## 0.1.0-alpha.13.1 — 2026-09-09
|
||||
|
||||
- Corrige l’arrêt des expansions sous Windows : le journal ne tente plus
|
||||
d’ouvrir 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 l’erreur 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
|
||||
n’a 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 l’hydrologie 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 d’expansion 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
|
||||
|
||||
@@ -15,8 +15,59 @@ 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’économie, de progression collective et les dimensions
|
||||
décrits dans la vision ne sont pas encore implémentés. L’alpha.12 fournit des
|
||||
outils d’expansion réservés à un nouveau monde de laboratoire.
|
||||
décrits dans la vision ne sont pas encore implémentés. L’alpha.13 réunit l’île
|
||||
initiale et les outils d’expansion dans un seul choix de monde Sanctuary.
|
||||
Les trois tailles et l’interface native sont vérifiées. L’alpha.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 d’expansion n’essaie plus d’ouvrir 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 d’un **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 l’appliquer. 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 d’eau 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
|
||||
d’expansion, avec leur distance facultative et leur recherche d’emplacements
|
||||
libres, sont accessibles dans ce même nouveau monde. Les déblocages par la
|
||||
production et l’XP 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 n’est 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
|
||||
d’expansion/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’à l’alpha.8
|
||||
|
||||
@@ -63,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 l’incrément suivant. Les observations et leurs
|
||||
limites figurent dans [Validation](docs/testing.md).
|
||||
|
||||
## Alpha.9 : cavités vivantes et terrasses d’eau
|
||||
## Alpha.9, historique : cavités vivantes et terrasses d’eau
|
||||
|
||||
L’incrément ajoute des cavités luxuriantes et lumineuses sous l’île, des
|
||||
secteurs à spéléothèmes, des géodes d’améthyste et des poches de soufre moins
|
||||
@@ -129,7 +180,7 @@ 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 : Petit, Grand et génération plus rapide
|
||||
## 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
|
||||
@@ -160,7 +211,7 @@ de la même instance sont consignées dans [Distribution](docs/packwiz.md).
|
||||
L’alpha.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 — laboratoire d’expansion
|
||||
## Alpha.12, historique — laboratoire d’expansion
|
||||
|
||||
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
|
||||
@@ -199,6 +250,25 @@ passent. L’alpha.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.
|
||||
L’alpha.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
|
||||
@@ -213,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.12, publiée et synchronisée |
|
||||
| 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
|
||||
@@ -234,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.12.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).
|
||||
@@ -258,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
|
||||
|
||||
@@ -66,13 +66,37 @@ avec un camp abandonné ; aucune structure n’est garantie sur chaque graine.
|
||||
```
|
||||
|
||||
`quick` part toujours de Sanctuary et attribue un identifiant `island_N`.
|
||||
`create <id> <parent> <direction> <climate> <diameter> <relief>` ouvre une branche
|
||||
`create <id> <parent> <direction> <climate> <diameter> <relief> [distance]` ouvre une branche
|
||||
nommée depuis n’importe quelle île prête. `preview` emploie les mêmes paramètres ;
|
||||
il propose des coordonnées mais ne valide ni l’occupation 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 l’enfant, y compris en diagonale. L’alignement
|
||||
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 n’est 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 |
|
||||
@@ -127,7 +151,7 @@ La réservation ajoute 32 blocs de décoration et 176 blocs de halo moteur autou
|
||||
de l’enveloppe 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
|
||||
refusé ; le laboratoire ne réécrit pas le vide pour rapprocher les îles.
|
||||
écarté ; depuis l’alpha.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 d’occupation est synchrone et peut
|
||||
suspendre brièvement le serveur pendant les lectures disque ; la préparation
|
||||
|
||||
@@ -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
|
||||
d’expansion 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. L’ancien 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 d’un 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 à l’origine.
|
||||
|
||||
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 n’est 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 s’agit toujours des profondeurs de l’Overworld, 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.
|
||||
|
||||
L’intégration protège les volumes déclarés par les plans d’eau et de lave
|
||||
contre les écritures de décoration suivantes. Cette protection concerne les
|
||||
plans de génération, sans mettre les blocs à l’abri 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`.
|
||||
L’exemple de visite suppose qu’il s’agit de la première réservation ; `list`
|
||||
indique l’identifiant 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. L’autocomplé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 l’enfant, entre 0 et 100 000 blocs. Une valeur de 0 ou son
|
||||
omission laisse le placement automatique. Les marges et l’alignement 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 n’autorise 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
|
||||
l’occupation 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 d’expansion 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 n’est garantie. Les règles de progression
|
||||
futures restent décrites dans [Expansion](expansion.md).
|
||||
|
||||
## Correctif alpha.13.1 — expansions sous Windows
|
||||
|
||||
L’erreur `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 l’instance 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 l’instance 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, l’expansion 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 n’est 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 l’essai 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 à l’origine.
|
||||
|
||||
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 n’est 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 d’ouverture sont conservés côté serveur. La racine et le diamètre
|
||||
initial ne sont pas modifiables par une commande d’expansion.
|
||||
|
||||
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 n’est converti, agrandi, réduit ou régénéré par l’alpha.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, l’interface 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).
|
||||
@@ -453,6 +453,87 @@ 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 d’un emplacement libre — alpha.12.1
|
||||
|
||||
**Branche :** `codex/expansion-free-space`.
|
||||
|
||||
**Problème :** les huit premiers emplacements peuvent contenir du vide déjà
|
||||
sauvegardé. L’alpha.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 l’enfant ; 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 s’applique
|
||||
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 d’expansion 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 n’est 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 l’absence de preuve de camp dans l’origine unifiée des graines
|
||||
inspectées, sont documentées dans [Validation](testing.md).
|
||||
|
||||
### WG-16 — Journal d’expansion sous Windows — alpha.13.1
|
||||
|
||||
**Branche :** `codex/windows-expansion-journal`.
|
||||
|
||||
**Problème :** après le remplacement atomique du journal, l’ouverture 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 l’historique.
|
||||
- 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 n’est
|
||||
pas accessible depuis cet environnement Mac : ne pas prétendre l’avoir 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.
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
+56
-32
@@ -1,24 +1,45 @@
|
||||
# Climats, expansion et ressources
|
||||
|
||||
Ce document fixe les décisions de conception pour la suite de Sanctuary.
|
||||
L’alpha.12 fournit un laboratoire avec registre persistant et commandes opérateur,
|
||||
suivant le [contrat de laboratoire](alpha12-laboratory.md). Le déblocage par
|
||||
recherches et contributions reste à 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. L’alpha.13 réunit l’île de départ et
|
||||
les commandes d’expansion 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 n’est pas encore implémenté.
|
||||
|
||||
## Taille de l’île initiale — alpha.11
|
||||
## Taille initiale et extensions — alpha.13
|
||||
|
||||
Deux choix de départ sont proposés : **Petit** et **Grand**, Grand par défaut.
|
||||
Petit garde un diamètre nominal de 512 blocs ; Grand vise environ 724 blocs,
|
||||
soit deux fois l’aire nominale de Petit. La hauteur reste de 384 blocs. Les
|
||||
repères internes 5/10 servent au calcul de surface, sans nom de capacité affiché
|
||||
ni promesse sur le nombre de joueurs, les minerais ou l’aire constructible.
|
||||
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 se choisit pour un nouveau monde et ne remplace pas le futur
|
||||
déblocage collectif de continents. Grand utilise `population_10` ; les anciennes
|
||||
sauvegardes 5/20/100 conservent leurs réglages et leurs dimensions. Les formats
|
||||
20/100 sont cachés de la création. Les mesures de l’alpha.10 conservées plus bas
|
||||
restent historiques ; les contrôles alpha.11 sont dans [Validation](testing.md).
|
||||
L’île initiale reprend le relief, les forêts et l’hydrologie 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 d’expansion. Leur génération ne reprend pas automatiquement
|
||||
la grande rivière et les terrasses de l’île initiale.
|
||||
|
||||
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 n’est converti ou redimensionné.
|
||||
|
||||
## Tailles et mesures historiques
|
||||
|
||||
L’alpha.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 l’alpha.13,
|
||||
les rechargements et le benchmark Moyen sont consignés dans [Validation](testing.md).
|
||||
|
||||
## Les huit directions et les branches climatiques
|
||||
|
||||
@@ -130,12 +151,15 @@ Une graine peut donc manquer d’une ressource ; la présence de tout le parcour
|
||||
Minecraft n’est pas garantie. Les structures donnant accès à l’End et leur lien
|
||||
avec l’expansion restent à concevoir.
|
||||
|
||||
## Relevé de développement disponible
|
||||
## 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 l’alpha.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
|
||||
@@ -146,13 +170,13 @@ 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
|
||||
@@ -162,7 +186,7 @@ ce dernier par défaut ; `20` et `100` servent à la compatibilité historique.
|
||||
Le mode `island` calcule l’enveloppe du profil actif, avec 16 blocs de marge de
|
||||
décoration et un halo de préparation d’un chunk. Les valeurs ci-dessous sont
|
||||
les bornes calculées lors de l’alpha.10, pas des relevés exécutés ni un nombre
|
||||
de chunks déjà validé pour le nouveau Grand :
|
||||
de chunks déjà validé pour l’ancien Grand alpha.11 :
|
||||
|
||||
| Profil | Chunks comptés par `island` | Chunks FULL préparés avec halo |
|
||||
| --- | --- | --- |
|
||||
@@ -257,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 l’alpha.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 d’une 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 d’anciennes parties n’est fourni par ce diagnostic.
|
||||
|
||||
+53
-2
@@ -13,11 +13,62 @@ 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.
|
||||
|
||||
## Alpha.13 publiée — 9 septembre 2026
|
||||
|
||||
[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 d’amorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
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 n’a été ouverte, convertie ou régénérée.
|
||||
|
||||
L’instance 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 n’est plus un choix public ; ses cavités et outils d’expansion
|
||||
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 d’amorç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 n’a été ouvert
|
||||
ou régénéré. La sauvegarde ciblée est dans
|
||||
`sanctuary-backups/before-0.1.0-alpha.12.1/` de l’instance 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 pointe sur 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)
|
||||
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 d’amorçage Prism ont été téléchargés publiquement et vérifiés contre
|
||||
les artefacts locaux. SHA-256 du JAR :
|
||||
|
||||
|
||||
+215
-2
@@ -19,6 +19,219 @@ 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 d’expansion 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 n’ouvre aucune sauvegarde Minecraft personnelle.
|
||||
|
||||
Les contrôles existants de persistance, de corruption, d’historique 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`,
|
||||
n’est 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 n’a 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 d’expansion
|
||||
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 d’expansion consécutivement ; le second réutilise
|
||||
uniquement leur monde de développement. Comme pour l’alpha.13, le témoin de
|
||||
camp naturel est facultatif et absent après recherche complète sur cette
|
||||
graine ; aucune nouvelle preuve de structure n’est 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 l’interface ci-dessous restent ceux de
|
||||
la livraison alpha.13 ; ils n’ont 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` n’ajoute 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 qu’une 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 n’est 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 d’hydrologie
|
||||
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 d’eau | Blocs d’eau retenus après 1 800 ticks | Pilier de spéléothèmes dans l’air 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 d’eau. 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.
|
||||
|
||||
L’essai 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 l’End. 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 l’alpha.11.
|
||||
L’ancien 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 n’est pas une mesure
|
||||
d’équivalence de génération. Le résultat de départ reste proche des 62,67 s
|
||||
mesurées pour l’alpha.11. Un seul passage par version, sans mesure du GPU,
|
||||
des FPS, du réseau, de la disponibilité des chunks pour les ticks ou d’une
|
||||
charge multijoueur. Grand n’a 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 n’a été observé sur l’origine 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 l’origine 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 lorsqu’ils 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
|
||||
d’un camp dans l’origine 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 d’une 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 d’abord 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 l’immutabilité. 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
|
||||
@@ -31,8 +244,8 @@ 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 -PsanctuaryExpansionTests=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
|
||||
./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
|
||||
|
||||
+20
-18
@@ -50,19 +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.
|
||||
|
||||
Le choix de taille initiale se limite à **Petit** et **Grand**, avec Grand
|
||||
par défaut. Petit prend une référence de 512 blocs de diamètre ; Grand vise
|
||||
environ 724 blocs, soit deux fois son aire nominale, sur une hauteur de
|
||||
384 blocs. Les repères internes 5/10 ne constituent ni les noms affichés ni
|
||||
une promesse de capacité multijoueur. Ce resserrement des tailles doit limiter
|
||||
les distances entre constructions.
|
||||
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.
|
||||
|
||||
L’alpha.11 implémente ces deux choix et l’optimisation des chargements. Les
|
||||
anciens formats 20/100 sont masqués à la création mais restent chargeables
|
||||
avec leurs paramètres enregistrés ; aucune sauvegarde n’est réduite.
|
||||
Les mesures serveur et leurs limites sont consignées dans [Validation](testing.md).
|
||||
Ce dimensionnement ne remplace pas l’expansion collective ni ne garantit
|
||||
une aire habitable ou un stock de ressources par joueur.
|
||||
L’alpha.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.
|
||||
L’alpha.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 n’est convertie ou redimensionnée.
|
||||
L’état d’inté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,
|
||||
@@ -143,12 +143,14 @@ parent ; le serveur conserve ainsi l’histoire 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.
|
||||
|
||||
L’alpha.12 sert de laboratoire à cette conception : commandes opérateur pour
|
||||
créer et visiter les continents, reliefs hauts aussi expérimentés sur Sanctuary
|
||||
Island et structures vanilla compatibles. Ces essais sont réservés à une nouvelle
|
||||
génération. Les recherches, mailboxes et deposit boxes viendront ensuite ; elles
|
||||
ne sont pas nécessaires aux commandes d’essai. Le passage en bêta interviendra
|
||||
après clarification du périmètre de la vision et de la progression à développer.
|
||||
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 d’emplacements libres. L’alpha.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 d’essai. 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,
|
||||
|
||||
+63
-57
@@ -1,74 +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
|
||||
|
||||
L’alpha.11 propose deux choix de création, **Petit** et **Grand**, avec Grand
|
||||
par défaut. Les anciennes tailles 20/100 restent chargeables dans les sauvegardes.
|
||||
Les observations de chaque version restent dans [Validation](testing.md).
|
||||
Le choix public **Sanctuary** (`sanctuary:sanctuary`) crée l’île principale à
|
||||
l’origine de l’Overworld, 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.
|
||||
|
||||
## Laboratoire d’expansion — alpha.12
|
||||
L’île initiale reprend la densité, les forêts et l’hydrologie 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 n’est pas automatiquement reproduite
|
||||
sur chaque continent ajouté.
|
||||
|
||||
Un troisième choix, **Sanctuary — Laboratoire** (`sanctuary:expansion_lab`),
|
||||
crée une origine de 512 blocs avec un relief supérieur expérimental. Les commandes
|
||||
opérateur peuvent ensuite ouvrir des régions de 64/128/256/512 blocs, avec
|
||||
parent, direction, climat et relief indépendants. Chaque région enregistre sa
|
||||
graine et sa génération 12 dans un journal serveur propre au monde.
|
||||
Les trois tailles et les cavités passent les contrôles moteur. Le [guide alpha.13](alpha13-worldgen.md)
|
||||
décrit l’interface, les paramètres sauvegardés et les outils d’expansion ;
|
||||
[Validation](testing.md) conserve la portée des essais de chaque version.
|
||||
|
||||
Ce générateur distinct utilise la densité flottante comme base et les biomes,
|
||||
matériaux, décorations et structures compatibles de Minecraft. Il ne reprend
|
||||
pas encore les grandes rivières et terrasses alpha.11. Petit, Grand et les
|
||||
sauvegardes historiques conservent leurs réglages. Créer un nouveau monde pour
|
||||
le laboratoire ; aucune migration ou commande de régénération n’est fournie.
|
||||
Le [guide alpha.12](alpha12-laboratory.md) décrit les outils, les limites et les
|
||||
observations réelles. Les sections historiques ci-dessous gardent leur portée.
|
||||
## Créer un nouveau monde
|
||||
|
||||
## Petit et Grand — alpha.11
|
||||
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.
|
||||
|
||||
| Choix public | Diamètre nominal | Aire nominale relative | Preset | Réglage enregistré |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Petit | 512 blocs | 1 | `sanctuary:sanctuary_5` | `sanctuary:population_5` |
|
||||
| Grand, défaut | Environ 724 blocs | 2 | `sanctuary:sanctuary` | `sanctuary:population_10` |
|
||||
|
||||
La hauteur reste de 384 blocs. Les repères internes 5/10 décrivent le calcul
|
||||
de surface ; ils ne sont ni des noms affichés ni une capacité promise.
|
||||
Le relief réel garde ses trous, ses surplombs et sa bordure sculptée.
|
||||
|
||||
Le nouveau Grand reçoit sa propre clé `population_10`. Les anciens mondes
|
||||
`population_5`, `population_20` et `population_100` restent chargeables avec
|
||||
leurs paramètres ; les formats 20/100 sont cachés dans les choix publics.
|
||||
Changer la valeur du preset de création n’agrandit ni ne réduit une sauvegarde.
|
||||
Les générations antérieures restent distinctes, dont `sanctuary_cavern` et
|
||||
`sanctuary_rift`.
|
||||
|
||||
L’optimisation réutilise les coins d’interpolation avec la même arithmétique
|
||||
et borne ce cache à 192 Kio par thread, sans retenir fortement un monde.
|
||||
Les plans prêts restent accessibles pendant le calcul d’une autre région ;
|
||||
le sampler compilé est réutilisé dans ce calcul. Les contrôles d’équivalence,
|
||||
de persistance et les mesures à géométrie identique sont dans [Validation](testing.md).
|
||||
Les règles de forme, d’hydrologie et les anciens réglages restent inchangés.
|
||||
|
||||
## Créer un monde
|
||||
|
||||
La création solo propose **Sanctuary (Petit)** et **Sanctuary (Grand)**,
|
||||
ce dernier étant le défaut Sanctuary.
|
||||
Les types de monde vanilla restent disponibles.
|
||||
|
||||
Sur un nouveau serveur Fabric disposant de cette version et de Fabric API,
|
||||
le choix Grand utilise :
|
||||
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 Petit, utiliser `level-type=sanctuary:sanctuary_5`. Choisir un nouveau
|
||||
`level-name` pour tester l’autre taille. La seed est libre ; ces exemples ne
|
||||
convertissent aucun monde déjà enregistré et n’autorisent aucune régénération.
|
||||
Les fichiers de test et les sauvegardes personnelles restent distincts.
|
||||
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 n’est fournie.
|
||||
|
||||
## Compatibilité et versions historiques
|
||||
|
||||
L’alpha.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 n’est 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. L’alpha.12.1 ajoutait une distance
|
||||
facultative et la recherche d’un 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` |
|
||||
|
||||
L’ancien 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é.
|
||||
|
||||
L’optimisation alpha.11 conserve l’arithmétique de l’interpolation et les règles
|
||||
de terrain et d’hydrologie. Ses mesures et contrôles sont historiques, sans
|
||||
validation anticipée des ajouts de l’alpha.13.
|
||||
|
||||
## Socle de génération conservé
|
||||
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.3
|
||||
|
||||
mod_version=0.1.0-alpha.12
|
||||
pack_version=0.1.0-alpha.12
|
||||
mod_version=0.1.0-alpha.13.1
|
||||
pack_version=0.1.0-alpha.13.1
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
@@ -37,18 +37,24 @@ tasks.named('runGameTest') {
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -260,3 +266,11 @@ tasks.register('expansionReliefSmoke', JavaExec) {
|
||||
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') }
|
||||
|
||||
+1
@@ -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) {
|
||||
|
||||
+179
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -5,9 +5,12 @@ 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;
|
||||
@@ -56,6 +59,58 @@ public final class ExpansionCommandChecks {
|
||||
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");
|
||||
|
||||
+138
-39
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -43,6 +44,8 @@ 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");
|
||||
@@ -62,6 +65,7 @@ public final class ExpansionWorldGameTests {
|
||||
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 "
|
||||
@@ -74,6 +78,19 @@ public final class ExpansionWorldGameTests {
|
||||
"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")),
|
||||
@@ -108,24 +125,48 @@ public final class ExpansionWorldGameTests {
|
||||
"Decoding the saved generator codec must reproduce its base column and climate: " + island.id());
|
||||
samples.add(current);
|
||||
}
|
||||
JsonObject nativeStructure = previous.getAsJsonObject("native_structure");
|
||||
BlockPos containerPos = position(nativeStructure.getAsJsonArray("container"));
|
||||
full(helper, level, containerPos.getX() >> 4, containerPos.getZ() >> 4);
|
||||
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");
|
||||
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("native_container", coordinates(containerPos));
|
||||
report.put("saved_generator_codec_decoded", true);
|
||||
report.put("passed", true);
|
||||
writeDiagnostic(level, true, report);
|
||||
helper.succeed();
|
||||
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());
|
||||
@@ -135,6 +176,41 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -152,6 +228,7 @@ public final class ExpansionWorldGameTests {
|
||||
private int phase;
|
||||
private int structureIndex;
|
||||
private int validStarts;
|
||||
private final List<ExpansionIsland> blockedSites = new ArrayList<>();
|
||||
|
||||
Scenario(GameTestHelper helper) {
|
||||
this.helper = helper;
|
||||
@@ -160,11 +237,18 @@ public final class ExpansionWorldGameTests {
|
||||
this.session = ExpansionRuntime.session(level);
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("process_id", ProcessHandle.current().pid());
|
||||
report.put("generator_version", ExpansionIsland.GENERATION);
|
||||
report.put("generator_version", generator.rootGeneration());
|
||||
report.put("initial_diameter", generator.initialDiameter());
|
||||
report.put("islands", observedIslands);
|
||||
report.put("scope", "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, "
|
||||
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.");
|
||||
+ "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() {
|
||||
@@ -177,9 +261,10 @@ public final class ExpansionWorldGameTests {
|
||||
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");
|
||||
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;
|
||||
}
|
||||
@@ -189,7 +274,7 @@ public final class ExpansionWorldGameTests {
|
||||
observeIsland("beta", Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands"));
|
||||
verifyMarker();
|
||||
verifyJournal();
|
||||
rejectVisitedVoid();
|
||||
verifyUntouchedVoid(helper, blockedSites);
|
||||
prepareStructureSearch();
|
||||
phase = 3;
|
||||
}
|
||||
@@ -242,9 +327,25 @@ public final class ExpansionWorldGameTests {
|
||||
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;
|
||||
@@ -278,8 +379,8 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
|
||||
private void verifyJournal() throws IOException {
|
||||
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-expansion-v1");
|
||||
var reloaded = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter());
|
||||
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());
|
||||
}
|
||||
@@ -295,6 +396,7 @@ public final class ExpansionWorldGameTests {
|
||||
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);
|
||||
@@ -303,24 +405,6 @@ public final class ExpansionWorldGameTests {
|
||||
report.put("restart_snapshot", snapshot);
|
||||
}
|
||||
|
||||
private void rejectVisitedVoid() throws IOException {
|
||||
ExpansionIsland target = session.candidate("visited", "sanctuary", "southwest", "boreal", 64, "natural");
|
||||
helper.assertTrue(generator.islandAt(target.centerX(), target.centerZ()) == null, "Admission witness must initially be outside every island");
|
||||
LevelChunk visited = full(target.centerX() >> 4, target.centerZ() >> 4);
|
||||
helper.assertTrue(Arrays.stream(visited.getSections()).allMatch(section -> section.hasOnlyAir()),
|
||||
"The visited-region witness must be a real empty FULL chunk, not an occupied island");
|
||||
int before = session.islands().size();
|
||||
boolean refused = false;
|
||||
String reason = "";
|
||||
try { session.create("visited", "sanctuary", "southwest", "boreal", 64, "natural"); }
|
||||
catch (IOException expected) { refused = true; reason = expected.getMessage(); }
|
||||
helper.assertTrue(refused && session.islands().size() == before,
|
||||
"A visited empty chunk must prevent admission without publishing or changing a region");
|
||||
helper.assertTrue(generator.islandAt(target.centerX(), target.centerZ()) == null
|
||||
&& Arrays.stream(visited.getSections()).allMatch(section -> section.hasOnlyAir()), "Refused activation must leave the visited void unchanged");
|
||||
report.put("visited_void_refusal", Map.of("chunk", List.of(visited.getPos().x(), visited.getPos().z()), "reason", reason));
|
||||
}
|
||||
|
||||
private void prepareStructureSearch() {
|
||||
ExpansionIsland root = session.island("sanctuary");
|
||||
int limit = (root.terrainRadius() + 15) / 16;
|
||||
@@ -328,6 +412,8 @@ public final class ExpansionWorldGameTests {
|
||||
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() {
|
||||
@@ -344,10 +430,23 @@ public final class ExpansionWorldGameTests {
|
||||
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;
|
||||
@@ -418,11 +517,11 @@ public final class ExpansionWorldGameTests {
|
||||
}
|
||||
|
||||
private static Path journalFile(ServerLevel level) {
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-expansion-v1").resolve(ExpansionJournal.FILE_NAME);
|
||||
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", "expansion-alpha12-" + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
|
||||
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);
|
||||
|
||||
+41
-18
@@ -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", 10),
|
||||
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,24 +58,33 @@ 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;
|
||||
@@ -132,7 +143,7 @@ public final class PopulationDiagnostics {
|
||||
|
||||
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]);
|
||||
@@ -168,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<>();
|
||||
@@ -190,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);
|
||||
}
|
||||
|
||||
@@ -207,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);
|
||||
@@ -257,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");
|
||||
@@ -294,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);
|
||||
}
|
||||
@@ -388,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());
|
||||
}
|
||||
|
||||
@@ -428,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)
|
||||
@@ -575,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);
|
||||
@@ -597,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());}
|
||||
|
||||
+5
-5
@@ -34,12 +34,12 @@ 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.TEN,
|
||||
"The public Sanctuary preset defaults to the new large geometry");
|
||||
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", "sanctuary:sanctuary_5", "sanctuary:expansion_lab")),
|
||||
"World creation must preserve small and large and add the separate expansion laboratory");
|
||||
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");
|
||||
@@ -55,7 +55,7 @@ final class PopulationPersistenceDiagnostics {
|
||||
Files.createDirectories(testRoot);
|
||||
LevelStorageSource storage = LevelStorageSource.createDefault(testRoot);
|
||||
for (int players : new int[]{5, 10, 20, 100}) {
|
||||
String presetId = players == 10 ? "sanctuary" : "sanctuary_" + players;
|
||||
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),
|
||||
|
||||
+322
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -23,13 +23,20 @@ public final class WorldgenPerformanceGameTests {
|
||||
@GameTest(maxTicks = 3000)
|
||||
public void spawnAreaAndExploration(GameTestHelper helper) throws Exception {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator());
|
||||
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());
|
||||
@@ -78,13 +85,13 @@ public final class WorldgenPerformanceGameTests {
|
||||
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(level.getChunkSource().randomState()));
|
||||
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-players-" + capacity.players() + "-seed-" + level.getSeed() + ".json");
|
||||
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();
|
||||
|
||||
+18
-3
@@ -44,11 +44,12 @@ 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() {
|
||||
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 10 -> "sanctuary";
|
||||
case 10 -> "sanctuary_10";
|
||||
case 20 -> "sanctuary_20";
|
||||
case 100 -> "sanctuary_100";
|
||||
default -> throw new IllegalArgumentException("Test geometry must be 5, 10, 20 or 100");
|
||||
@@ -56,12 +57,26 @@ public abstract class SanctuaryGameTestServerMixin {
|
||||
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();
|
||||
if (Boolean.getBoolean("sanctuary.test.expansion")) return;
|
||||
fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn =
|
||||
fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -46,6 +49,8 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
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);
|
||||
|
||||
+104
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,57 @@ public final class ExpansionAdmission {
|
||||
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();
|
||||
@@ -33,35 +84,31 @@ public final class ExpansionAdmission {
|
||||
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));
|
||||
for (ChunkPos position : positions) rejectHolder(map, position);
|
||||
long deadline = System.nanoTime() + DEADLINE_NANOS;
|
||||
for (int start = 0; start < positions.size(); start += READ_BATCH) {
|
||||
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)));
|
||||
try {
|
||||
for (int i = 0; i < reads.size(); i++) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) throw new TimeoutException("Admission deadline expired");
|
||||
if (reads.get(i).get(remaining, TimeUnit.NANOSECONDS).isPresent()) {
|
||||
throw new IOException("Expansion intersects an existing saved chunk: " + positions.get(start + i));
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Expansion admission interrupted", e);
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
throw new IOException("Expansion could not prove that every reserved chunk is absent", e);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
private static void verifyHolders(ChunkMap map, List<ChunkPos> positions, long deadlineNanos) throws IOException {
|
||||
for (ChunkPos position : positions) {
|
||||
checkDeadline(deadlineNanos);
|
||||
rejectHolder(map, position);
|
||||
}
|
||||
for (ChunkPos position : positions) rejectHolder(map, position);
|
||||
// Caller must commit and publish on this same server turn, without yielding or processing ticks.
|
||||
}
|
||||
|
||||
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 IOException("Expansion intersects a loaded or unloading chunk: " + position);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 final class ExpansionBiomeSource extends BiomeSource {
|
||||
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",
|
||||
@@ -59,7 +59,7 @@ public final class ExpansionBiomeSource extends BiomeSource {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override protected MapCodec<ExpansionBiomeSource> codec() { return CODEC; }
|
||||
@Override protected MapCodec<? extends ExpansionBiomeSource> codec() { return CODEC; }
|
||||
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() { return biomes.stream(); }
|
||||
|
||||
@Override
|
||||
@@ -75,7 +75,7 @@ public final class ExpansionBiomeSource extends BiomeSource {
|
||||
return (quartX, quartY, quartZ) -> resolve(snapshot, quartX * 4, quartY * 4, quartZ * 4);
|
||||
}
|
||||
|
||||
private Holder<Biome> resolve(List<ExpansionIsland> snapshot, int x, int y, int z) {
|
||||
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);
|
||||
|
||||
+17
-8
@@ -39,26 +39,30 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
|
||||
|
||||
/** 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 final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
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;
|
||||
private volatile RegistryAccess registries;
|
||||
protected volatile RegistryAccess registries;
|
||||
private final Map<ExpansionIsland, Context> contexts = new ConcurrentHashMap<>();
|
||||
private record Context(NoiseBasedChunkGenerator generator, RandomState random) {}
|
||||
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) throw new IllegalArgumentException("Laboratory diameter must be 512 or 724");
|
||||
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<ExpansionChunkGenerator> codec() { return CODEC; }
|
||||
@Override protected MapCodec<? extends ExpansionChunkGenerator> codec() { return CODEC; }
|
||||
public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
|
||||
registries = level.registryAccess();
|
||||
source.setIslands(islands);
|
||||
@@ -68,12 +72,13 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
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;
|
||||
}
|
||||
private Context context(ExpansionIsland island) {
|
||||
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";
|
||||
@@ -92,6 +97,9 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
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) {
|
||||
@@ -106,7 +114,7 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
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 -> {
|
||||
ExpansionTerrain.apply(island, generated);
|
||||
afterTerrain(island, generated);
|
||||
return generated;
|
||||
});
|
||||
}
|
||||
@@ -141,6 +149,7 @@ public final class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
@Override public void addDebugScreenInfo(List<String> text, RandomState ignored, BlockPos pos, SamplerContext context) {
|
||||
var island = islandAt(pos.getX(), pos.getZ());
|
||||
text.add(island == null ? "Sanctuary alpha.12: void" : "Sanctuary alpha.12: " + island.id() + " / " + island.climate() + " / " + island.relief());
|
||||
String prefix = "Sanctuary gen " + rootGeneration() + ": ";
|
||||
text.add(island == null ? prefix + "void" : prefix + island.id() + " / " + island.climate() + " / " + island.relief());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ public final class ExpansionCommands {
|
||||
.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"))))))));
|
||||
.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));
|
||||
});
|
||||
}
|
||||
@@ -79,17 +81,28 @@ public final class ExpansionCommands {
|
||||
.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 -> 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"))
|
||||
: session.create(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"));
|
||||
describe(c, island, preview);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))))))));
|
||||
.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();
|
||||
@@ -101,7 +114,7 @@ public final class ExpansionCommands {
|
||||
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);
|
||||
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, diameter, relief, minimumDistance);
|
||||
describe(c, island, false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
|
||||
@@ -31,20 +31,23 @@ public record ExpansionIsland(String id, String parent, String direction, String
|
||||
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))) throw new IllegalArgumentException("Unsupported nominal diameter");
|
||||
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) throw new IllegalArgumentException("Unsupported island state or generation");
|
||||
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 new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", GENERATION);
|
||||
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() { return origin() && diameter == 724 ? 395 : (int) Math.ceil(radius() * 1.125); }
|
||||
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) {
|
||||
|
||||
@@ -39,11 +39,25 @@ public final class ExpansionJournal {
|
||||
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 {
|
||||
if (rootDiameter != 512 && rootDiameter != 724) throw new IOException("Unsupported expansion root diameter");
|
||||
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;
|
||||
@@ -55,7 +69,7 @@ public final class ExpansionJournal {
|
||||
// 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));
|
||||
this.islands = List.of(ExpansionIsland.origin(rootDiameter, seed, rootGeneration));
|
||||
persist(this.islands);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +117,7 @@ public final class ExpansionJournal {
|
||||
// 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;
|
||||
try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
|
||||
directorySync.force(directory);
|
||||
if (!read().equals(next)) throw new IOException("Expansion journal verification disagrees with the committed plan");
|
||||
} catch (IOException | RuntimeException e) {
|
||||
failed = true;
|
||||
@@ -116,6 +130,19 @@ public final class ExpansionJournal {
|
||||
}
|
||||
}
|
||||
|
||||
@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");
|
||||
@@ -146,7 +173,7 @@ public final class ExpansionJournal {
|
||||
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))) throw new IOException("Sanctuary root region is immutable");
|
||||
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++) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ public final class ExpansionRuntime {
|
||||
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/sanctuary-expansion-v1");
|
||||
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());
|
||||
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);
|
||||
@@ -80,57 +80,54 @@ public final class ExpansionRuntime {
|
||||
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) {
|
||||
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");
|
||||
var parent = island(parentId);
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
int[] dx = {0, 1, 1, 1, 0, -1, -1, -1};
|
||||
int[] dz = {-1, -1, 0, 1, 1, 1, 0, -1};
|
||||
var sample = new ExpansionIsland(id, parentId, direction, climate, diameter, 0, 0,
|
||||
ExpansionIsland.deriveSeed(level.getSeed(), id), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
int step = ((parent.reservedRadius() + sample.reservedRadius() + 63) / 16) * 16;
|
||||
// Eight short deterministic searches along the requested ray, without generating void.
|
||||
for (int ring = 1; ring <= 8; ring++) {
|
||||
int x = Math.addExact(parent.centerX(), dx[ordinal] * step * ring);
|
||||
int z = Math.addExact(parent.centerZ(), dz[ordinal] * step * ring);
|
||||
if (Math.abs((long) x) > 100_000 || Math.abs((long) z) > 100_000) break;
|
||||
var candidate = new ExpansionIsland(id, parentId, direction, climate, diameter, x, z,
|
||||
sample.seed(), relief, "reserved", ExpansionIsland.GENERATION);
|
||||
if (islands().stream().noneMatch(candidate::conflicts)) return candidate;
|
||||
}
|
||||
throw new IllegalArgumentException("no_space");
|
||||
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)) throw new IllegalArgumentException("id_conflict");
|
||||
|| 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 value = candidate(id, parent, direction, climate, diameter, relief);
|
||||
// Explicit refusal if a proposed location was already explored, even in the void.
|
||||
ExpansionAdmission.verify(level, value);
|
||||
try {
|
||||
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
|
||||
var next = new ArrayList<>(islands());
|
||||
next.add(value);
|
||||
commitJournal(next); // Durability and re-read come before worldgen sees the island.
|
||||
generator.setIslands(journal.islands());
|
||||
failure = "";
|
||||
enqueue(value);
|
||||
return value;
|
||||
} finally {
|
||||
generator.discardUnpublishedContext(value);
|
||||
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();
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -57,7 +58,8 @@ public final class ExpansionStructures {
|
||||
return;
|
||||
}
|
||||
var sampler = randomState.createClimateSampler(SamplerContext.builder().enableCaches().build());
|
||||
Holder<Biome> biome = generator.getBiomeSource().createResolver(sampler).getNoiseBiome(blockX >> 2, surfaceY >> 2, blockZ >> 2);
|
||||
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.
|
||||
@@ -85,7 +87,7 @@ public final class ExpansionStructures {
|
||||
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, structure.biomes()::contains);
|
||||
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;
|
||||
@@ -121,6 +123,13 @@ public final class ExpansionStructures {
|
||||
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";
|
||||
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,9 @@ public enum IslandCapacity {
|
||||
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_10_SETTINGS)) return TEN;
|
||||
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;
|
||||
}
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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) ->
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ public final class PopulationHydrologyRuntime {
|
||||
});
|
||||
}
|
||||
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) {
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
+9
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"generator.sanctuary.sanctuary": "Sanctuary (Large)",
|
||||
"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",
|
||||
@@ -51,8 +52,8 @@
|
||||
"biome.sanctuary.population_lush_caves": "Sanctuary Lush Caves",
|
||||
"biome.sanctuary.population_dripstone_caves": "Sanctuary Dripstone Caves",
|
||||
"generator.sanctuary.sanctuary_20": "Sanctuary (20 players — legacy size)",
|
||||
"generator.sanctuary.expansion_lab": "Sanctuary — Laboratory",
|
||||
"sanctuary.expansion.help": "Alpha.12 laboratory — /sanctuary expansion quick <direction> [climate] [diameter] [relief]; create or preview <id> <parent> <direction> <climate> <diameter> <relief>; list; visit <id>; resume <id>. Diameters: 64, 128, 256, 512.",
|
||||
"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",
|
||||
@@ -63,19 +64,19 @@
|
||||
"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 — Laboratory world. Existing worlds are not converted.",
|
||||
"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": "Laboratory limit: 64 islands including the origin.",
|
||||
"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. Choose another location.",
|
||||
"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",
|
||||
@@ -106,5 +107,15 @@
|
||||
"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_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 (Grand)",
|
||||
"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",
|
||||
@@ -51,8 +52,8 @@
|
||||
"biome.sanctuary.population_lush_caves": "Grottes luxuriantes 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 — Laboratoire",
|
||||
"sanctuary.expansion.help": "Laboratoire alpha.12 — /sanctuary expansion quick <direction> [climat] [diamètre] [relief] ; create ou preview <id> <parent> <direction> <climat> <diamètre> <relief> ; list ; visit <id> ; resume <id>. Diamètres : 64, 128, 256, 512.",
|
||||
"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",
|
||||
@@ -63,19 +64,19 @@
|
||||
"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éer un nouveau monde Sanctuary — Laboratoire. Les anciennes parties ne sont pas converties.",
|
||||
"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 du laboratoire : 64 îles, origine comprise.",
|
||||
"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 d’arrivé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. Choisir un autre emplacement.",
|
||||
"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",
|
||||
@@ -106,5 +107,15 @@
|
||||
"sanctuary.expansion.error.invalid_id": "L’identifiant 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 l’autocomplé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_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 n’a é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 n’a é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
-3
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:sanctuary",
|
||||
"sanctuary:sanctuary_5",
|
||||
"sanctuary:expansion_lab"
|
||||
"sanctuary:sanctuary"
|
||||
]
|
||||
}
|
||||
|
||||
+27
@@ -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"
|
||||
]
|
||||
}
|
||||
+23
@@ -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": []
|
||||
}
|
||||
+23
@@ -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": []
|
||||
}
|
||||
+23
@@ -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": []
|
||||
}
|
||||
+48
-13
@@ -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_10"
|
||||
"initial_diameter": 724
|
||||
}
|
||||
},
|
||||
"minecraft:the_end": {
|
||||
|
||||
+45
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@
|
||||
"LayeredSpringOutletMixin",
|
||||
"WoodlandSpringOutletMixin",
|
||||
"RiftSpringOutletMixin",
|
||||
"CavernSpringOutletMixin"
|
||||
"CavernSpringOutletMixin",
|
||||
"UnifiedDecorationProtectionMixin"
|
||||
],
|
||||
"client": [
|
||||
"client.SanctuarySkyMixin",
|
||||
"client.SanctuaryFogMixin"
|
||||
"client.SanctuaryFogMixin",
|
||||
"client.SanctuaryWorldOptionsMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
+103
-1
@@ -2,12 +2,14 @@ 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 {
|
||||
@@ -45,6 +47,31 @@ public final class ExpansionJournalSmoke {
|
||||
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");
|
||||
@@ -95,10 +122,14 @@ public final class ExpansionJournalSmoke {
|
||||
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 and interrupted reservation");
|
||||
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);
|
||||
@@ -106,6 +137,77 @@ public final class ExpansionJournalSmoke {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
+159
@@ -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); }
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "0.1.0-alpha.12"
|
||||
version = "0.1.0-alpha.13.1"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user