Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0969ed52da | ||
|
|
1a64fbe5b9 | ||
|
|
98fc13cf0a | ||
|
|
71d5cf7906 | ||
|
|
dd9c795695 | ||
|
|
c576d655ae | ||
|
|
1c5c2c2c7e | ||
|
|
3a6a895874 | ||
|
|
b19e93dd21 | ||
|
|
3569cf1f5f | ||
|
|
78b63be8e1 | ||
|
|
818c6753f9 | ||
|
|
332d60be96 | ||
|
|
40a86588b5 | ||
|
|
cdfbba0089 | ||
|
|
f816ffc640 | ||
|
|
f30a350240 | ||
|
|
a26b72fabd | ||
|
|
ef5d1eaad9 | ||
|
|
0a0034dd42 | ||
|
|
40b655517e | ||
|
|
bf84b37ea7 | ||
|
|
80e78e0421 | ||
|
|
d50d47e149 | ||
|
|
f1e48fa969 | ||
|
|
f621f921a1 | ||
|
|
6e94c1c539 |
+369
@@ -1,5 +1,374 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0-alpha.17 — 2026-09-09
|
||||
|
||||
- Prototype de trois réseaux oubliés sur l’île initiale : mines et ateliers,
|
||||
salles étranges, infrastructures abandonnées. Accès depuis la surface,
|
||||
couloirs et embranchements, outils vanilla utilisables, mobilier et traces de
|
||||
chantier. Placement borné, hors ville et eaux protégées, avec appuis vérifiés.
|
||||
- Option FR/EN « Structures expérimentales » dans Personnaliser, enregistrée
|
||||
avec la taille du nouveau monde. Les connexions entre réseaux, le réacteur,
|
||||
la progression et les reliques ne sont pas encore implémentés.
|
||||
- Codec `sanctuary:island_v17`, pièces natives indépendantes, journal 17.
|
||||
Les mondes 16 et antérieurs conservent leurs plans et futurs chunks.
|
||||
L’option ne supprime ni n’ajoute rétroactivement des structures.
|
||||
|
||||
`check build assemblePack` passe. Création et réouverture natives : 15 pièces,
|
||||
7 639 blocs contrôlés, 49 cibles accessibles, 12 appuis et 3 coffres conservés.
|
||||
Le cas désactivé et la sauvegarde des trois tailles passent. La génération 16
|
||||
conserve les empreintes natives et le journal des témoins publiés. Artefacts
|
||||
vérifiés, publication en cours ; voir [le protocole](docs/testing-alpha17.md).
|
||||
|
||||
## 0.1.0-alpha.16 — 2026-09-09
|
||||
|
||||
- Ville compacte obligatoire sur la nouvelle île initiale, structures activées.
|
||||
Recherche naturelle puis réservation architecturale locale si nécessaire ;
|
||||
les villes des continents restent conditionnelles.
|
||||
- Grande piscine couverte avec bassin de 50 × 25 × 4 blocs, promenades et
|
||||
marches. Parkings profonds de 3 à 8 plateformes, grande salle sèche et égouts
|
||||
ouverts vers l’extérieur avec écoulements natifs. Ces équipements restent
|
||||
conditionnés par le relief, leurs fondations et un budget de recherche borné.
|
||||
- Plans d’étages alternés, sanitaires clos et lits contre les murs, avec
|
||||
circulation, orientations et appuis du mobilier contrôlés.
|
||||
- Nouveau codec `sanctuary:island_v16`, journal16 et pièces natives propres.
|
||||
Les générations publiées conservent leurs calculs, rendus et futurs chunks.
|
||||
Un nouveau monde est nécessaire pour les nouveautés ; aucune conversion.
|
||||
|
||||
`check build assemblePack` passe, ainsi que création/réouverture : 71 pièces,
|
||||
5 coffres et 74 appuis conservés. Piscine retenue et deux écoulements extérieurs
|
||||
vérifiés après 120 ticks réels. Six reliefs contrôlés, avec piscines dans trois
|
||||
cas et villes dans les six. Compatibilité 15 vérifiée contre ses empreintes
|
||||
publiées. Release et canal packwiz publiés ; même instance Prism synchronisée
|
||||
deux fois, avec les 767 fichiers personnels et réglages suivis conservés. Voir
|
||||
[les preuves et limites alpha.16](docs/testing-alpha16.md).
|
||||
|
||||
## 0.1.0-alpha.15 — 2026-09-09
|
||||
|
||||
- Six familles de bâtiments reprennent leurs intérieurs : vraies pièces et
|
||||
portes, seuils, vestibules, couloirs, escaliers, sols, plafonds et mobilier
|
||||
adapté. Les parcours et les approches des lits sont vérifiés.
|
||||
- Réseaux souterrains d’égouts, passages, garages et pool rooms ; descentes
|
||||
de surface, bassins contenus et appuis courts ancrés lorsque la roche manque.
|
||||
- Petite ville compacte, rues et carrefours jointifs, terrasses dégagées et
|
||||
ponts intégrés au réseau. Correction des charnières, sièges et appuis des
|
||||
lanternes ; grande salle sèche dans les tunnels pour une future machine.
|
||||
- Codec `sanctuary:island_v15`, journal15 et pièces natives versionnées.
|
||||
Les anciennes générations gardent leurs plans, rendus et sauvegardes.
|
||||
Le contrat de reprise certifiée du vide reste strictement lié à sa génération.
|
||||
|
||||
`check build assemblePack`, les huit tests moteur et les scénarios de création
|
||||
et réouverture passent : 76 pièces natives et cinq coffres conservés, rues et
|
||||
intérieurs accessibles, bassins contenus. Release et canal packwiz publiés ;
|
||||
même instance Prism synchronisée deux fois, 767 fichiers personnels et réglages
|
||||
suivis conservés. Le JAR d’essai anticipé est sauvegardé séparément. Voir
|
||||
[les preuves et limites alpha.15](docs/testing-alpha15.md).
|
||||
|
||||
## 0.1.0-alpha.14 — 2026-09-09
|
||||
|
||||
- Biome Lost City et quartiers procéduraux contemporains : poches sur l’île
|
||||
initiale et profil `lost_city` pour les nouvelles expansions. Dix-sept types
|
||||
de parcelles, dont quatre parcs, ponts, parkings souterrains conditionnels,
|
||||
fouilles, piscines, casernes, halls, hôtels, bibliothèques et cuisines.
|
||||
- Palette dominée par la pierre, la pierre lisse et les briques de pierre,
|
||||
intérieurs accessibles, ruines déterministes et coffres à butin vanilla
|
||||
différé. Zombies vanilla par spawn naturel ; aucun spawner ni zombie spécial.
|
||||
- Nouveau codec public `sanctuary:island_v14` et journal propre de génération
|
||||
14. Les générations 12/13 restent chargeables sans conversion. Les nouveautés
|
||||
demandent un nouveau monde ; aucune sauvegarde personnelle n’est modifiée.
|
||||
- Expansions 64/128/256/512/1 024 ; `quick` sans taille tire selon les poids
|
||||
40/30/18/9/3, avec résultat stable pour la graine et l’identifiant réservés.
|
||||
Recherche proche dans le secteur demandé, séparation circulaire des emprises
|
||||
de terrain et de décoration, sans réserver tout le voisinage de lecture.
|
||||
- Contrat de reprise différée du vide : uniquement les chunks FULL prouvés
|
||||
vierges et vides d’un nouveau monde 14, certificat lié à un jeton immuable du
|
||||
journal, puis réouverture et nouvelle vérification. Une présence de terrain,
|
||||
d’entité, de POI, de ticks ou une preuve manquante arrête cette reprise.
|
||||
|
||||
`check build assemblePack`, les huit tests moteur, la création et la réouverture
|
||||
de Lost City passent. La reprise du vide conserve 154 chunks FULL éclairés
|
||||
correctement et un bloc témoin après réouvertures. JAR, MRpack et amorçage Prism
|
||||
publiés et vérifiés, canal packwiz avancé. Deux synchronisations isolées puis
|
||||
deux dans la même instance Prism Mac passent, avec 713 fichiers personnels et
|
||||
réglages suivis conservés. Aucun essai Windows natif effectué. Voir les
|
||||
[preuves et limites alpha.14](docs/testing-alpha14.md).
|
||||
|
||||
## 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
|
||||
les expansions sans blocs de commande ni économie préalable.
|
||||
- Îles de 64, 128, 256 ou 512 blocs de diamètre nominal ; parent et direction
|
||||
enregistrés, climat libre parmi douze profils. La boussole climatique reste
|
||||
un raccourci facultatif ; les branches peuvent composer plusieurs couronnes.
|
||||
- Nouveaux reliefs hauts : naturel, pics, canyon, volcan. Le relief naturel est
|
||||
aussi testé sur l’île initiale du laboratoire. Palette volcanique et petites
|
||||
poches facultatives de fluides, sans ajout de fondations artificielles.
|
||||
- 34 biomes vanilla déclarés dès le chargement, avec surface et poches souterraines.
|
||||
Sélection de structures vanilla selon biomes, emprise et fondations : villages,
|
||||
temples de désert/jungle, cabanes de marais et camps abandonnés.
|
||||
- Journal serveur v1 atomique, paramètres immuables, reprise des réservations,
|
||||
refus des chunks existants ou en cours de chargement/déchargement, vide compris.
|
||||
Aucun changement des anciens générateurs ni migration d’une sauvegarde.
|
||||
|
||||
Validation locale : `check build assemblePack`, douze tests moteur Grand de
|
||||
graine 0, smokes de journal/relief, expansions et second processus de rechargement
|
||||
sur la graine 42 avec camp et butin natifs. Les essais restent sans client ni
|
||||
joueurs connectés. Release, MRpack et canal packwiz publiés ; la même instance
|
||||
Prism est synchronisée deux fois avec 466 fichiers personnels et réglages
|
||||
suivis conservés. Recherche collective,
|
||||
boîtes postales, Void, hydrologie étendue et autres structures restent à construire.
|
||||
Voir [le contrat du laboratoire](docs/alpha12-laboratory.md).
|
||||
|
||||
## 0.1.0-alpha.11 — 2026-09-09
|
||||
|
||||
- Deux choix à la création : **Petit** (512 blocs de diamètre nominal)
|
||||
et **Grand** (environ 724 blocs), Grand par défaut. Leur aire nominale varie
|
||||
d’un facteur deux, sur une hauteur de 384 blocs. Les repères internes 5/10
|
||||
ne désignent pas un nombre garanti de joueurs.
|
||||
- Nouveau réglage `sanctuary:population_10` pour Grand. Les anciens formats
|
||||
20/100 disparaissent des choix publics, mais restent chargeables avec leurs
|
||||
identifiants et leur relief enregistrés. Aucune île existante n’est réduite.
|
||||
- Cache des coins d’interpolation conservant l’arithmétique exacte du moteur,
|
||||
borné à 192 Kio par thread. Les plans régionaux déjà calculés restent lisibles
|
||||
pendant le calcul d’une nouvelle région ; le sampler est compilé une seule
|
||||
fois par plan.
|
||||
- À géométrie 20 identique, création et 289 chunks : 407,43 → 62,54 s
|
||||
(−84,65 %). Exploration de 80 chunks : 103,14 → 14,05 s. Le nouveau Grand
|
||||
atteint 62,67 s sur le protocole de départ. Une seule mesure par version,
|
||||
graine 0, M1/8 Gio : ce sont des temps serveur jusqu’à FULL, pas des FPS
|
||||
ni une garantie de délai en jeu.
|
||||
|
||||
Validation : `check build assemblePack`, douze tests moteur pour Petit et
|
||||
Grand, 1 800 ticks de fluides, persistance des quatre références et
|
||||
3 183 804 comparaisons bit à bit de densités réelles, plus 122 528 synthétiques.
|
||||
Les sources historiques de forme et les anciens réglages sont conservés.
|
||||
Voir [Validation](docs/testing.md) et [Distribution](docs/packwiz.md).
|
||||
Le canal stable est publié et la même instance Prism est synchronisée deux fois,
|
||||
avec les 364 fichiers personnels et réglages suivis conservés.
|
||||
|
||||
## 0.1.0-alpha.10 — 2026-09-09
|
||||
|
||||
- Trois tailles de départ : **Sanctuary (20 joueurs)** par défaut, plus les
|
||||
variantes 5 et 100 joueurs. Diamètres nominaux de 512, 1 024 et environ
|
||||
2 290 blocs, hauteur de 384 blocs ; aucune limite de connexions modifiée.
|
||||
- Nouvelles générations `sanctuary:population_5`, `population_20` et
|
||||
`population_100`. Forêts, cavités, décorations et plans d’eau/lave couvrent
|
||||
la nouvelle emprise par régions locales ; une structure hydraulique qui
|
||||
dépasse son domaine est écartée en entier.
|
||||
- Capacité persistante sur disque, ancien réglage alpha.9 conservé ;
|
||||
212 anciens fichiers de production inchangés et huit fichiers de liaison
|
||||
ou d’interface adaptés. Aucune ancienne île agrandie ou régénérée.
|
||||
|
||||
Validation : `check build assemblePack` réussi, puis onze tests moteur
|
||||
réussis pour chacun des profils 5/20/100 sur la graine `0`, avec 1 800 ticks
|
||||
réels. Le MRpack et le ZIP d’amorçage sont vérifiés localement. Les mesures
|
||||
sont limitées aux chunks inspectés ; elles ne constituent ni un inventaire
|
||||
global ni une simulation de cent joueurs. Le premier plan local reste coûteux
|
||||
et la génération en masse reste à optimiser. Publication et synchronisations
|
||||
du canal sont consignées séparément dans [Distribution](docs/packwiz.md).
|
||||
|
||||
## 0.1.0-alpha.9 — 2026-09-09
|
||||
|
||||
- Cavités luxuriantes éclairées par leur végétation, secteurs à spéléothèmes
|
||||
et géodes d’améthyste recherchés sous l’île, sur des supports naturels.
|
||||
- Province de soufre limitée dans les profondeurs ; geysers utilisant
|
||||
les blocs et conditions de fonctionnement de Minecraft 26.3-pre-2.
|
||||
- Forêts et rivière de surface conservées ; recherche de lacs plus profonds
|
||||
et volumineux et de groupes facultatifs de bassins en terrasses reliés par
|
||||
des cascades. Les « rizières » désignent le relief aquatique, sans culture
|
||||
de riz ni mécanique d’ascenseur ajoutée au joueur.
|
||||
- Nouvelle clé `sanctuary:sanctuary_cavern`, réservée aux nouveaux mondes.
|
||||
Les générations jusqu’à l’alpha.8 restent séparées ; aucune sauvegarde
|
||||
existante n’est convertie ou régénérée.
|
||||
|
||||
Validation : au moins dix tests requis réussis sur chacune des graines `0`,
|
||||
`42` et `8675309`, avec 1 800 ticks de fluides ; `check build assemblePack`
|
||||
réussi. Les preuves cumulées comprennent deux bassins en terrasses reliés,
|
||||
un geyser dormant puis éruptif, des baies lumineuses, des spéléothèmes et de
|
||||
l’améthyste bourgeonnante. Les mesures et leurs périmètres figurent dans
|
||||
[Validation](docs/testing.md). Release et canal packwiz publiés ; double
|
||||
synchronisation isolée puis dans la même instance Prism réussie, avec 260
|
||||
fichiers personnels et réglages conservés. Aucun site ni stock n’est garanti
|
||||
sur chaque graine.
|
||||
|
||||
## 0.1.0-alpha.8 — 2026-09-09
|
||||
|
||||
- Forêts de surface conservées, avec de nouvelles failles courbes qui retirent
|
||||
de la roche pour ouvrir l’intérieur de l’île.
|
||||
- Nappes de tuf, cobblestone, pierre moussue et boue compactée dans les étages
|
||||
humides ; ardoise des abîmes, roche noire et basalte plus bas. Les poches de
|
||||
soufre et de cinabre peuvent apparaître à des altitudes plus accessibles.
|
||||
- Davantage d’essais de petits filons de charbon, fer et cuivre, y compris
|
||||
au contact de l’air. Ajout de l’émeraude et adaptation des huit familles de
|
||||
minerais aux nouvelles roches, sans quota de remplissage.
|
||||
- Décoration des corniches revue avec de gros champignons, des petits
|
||||
champignons plus dispersés et des sols de mousse, podzol et mycélium.
|
||||
- Bassins plus grands et recherche d’une
|
||||
longue rivière calme au tracé arrondi, de largeur variable, soutenue par la
|
||||
roche, avec bassins terminaux, berges progressives et source en paroi.
|
||||
- Recherche prioritaire de la poche de lave couverte entre Y=64 et Y=160,
|
||||
avec recherche plus haute si aucun site profond ne convient. Les sources
|
||||
profondes supplémentaires produisent leurs coulées par les ticks vanilla.
|
||||
- Nouvelle clé `sanctuary:sanctuary_rift` ; les anciennes générations jusqu’à
|
||||
l’alpha.7 restent séparées. Cet incrément nécessite un **nouveau monde Sanctuary**.
|
||||
|
||||
Validation : tests serveur réussis sur chacune des graines `0`, `42` et
|
||||
`8675309`, avec les bassins terminaux, la continuité des champignons et
|
||||
1 800 ticks réels de fluides. `check build assemblePack` réussi sur la graine
|
||||
`42`. Les mesures et leurs limites figurent dans [Validation](docs/testing.md).
|
||||
Publication immuable et canal packwiz vérifiés, avec deux synchronisations
|
||||
dans un dossier neuf puis deux dans la même instance Prism ; sauvegardes et
|
||||
réglages conservés.
|
||||
Aucune rivière, coulée ou réserve de ressources n’est garantie pour chaque graine.
|
||||
|
||||
## 0.1.0-alpha.7 — 2026-09-08
|
||||
|
||||
- Forêts de chênes plus denses, y compris au cœur et sur les hauteurs de l’île,
|
||||
avec bouleaux, clairières fleuries et bois plus secs.
|
||||
- Vrais chênes noirs, champignons, podzol et mycélium sur les corniches intérieures
|
||||
humides ; mousse plus ponctuelle et rares poches de jungle/bambou.
|
||||
- Un emplacement facultatif pour un arbre remarquable (cerisier favorisé,
|
||||
épicéa, jungle, acacia, palétuvier ou chêne pâle), sans terrain ajouté.
|
||||
- Nappes de pierre, andésite, diorite et granite sur les affleurements ; dépôts
|
||||
épais de sable, gravier et argile associés aux bassins. Rares matières de
|
||||
soufre dans les profondeurs, sans grands bassins souterrains.
|
||||
- Petits filons de charbon, fer et cuivre autorisés à affleurer, avec des essais
|
||||
sur les roches de surface et les corniches pour les rendre repérables.
|
||||
- Nouvelle clé `sanctuary:sanctuary_woodland`, avec formes, hauteur, ciel et
|
||||
plans d’eau conservés. Créer un **nouveau monde Sanctuary** ; les sauvegardes
|
||||
alpha.6 et antérieures conservent leur génération.
|
||||
|
||||
Validation : dix tests serveur réussis sur chacune des graines `0`, `42` et
|
||||
`8675309`, arbres et minerais réellement observés, 1 800 ticks de fluides,
|
||||
et `check build assemblePack` réussi.
|
||||
|
||||
## 0.1.0-alpha.6 — 2026-09-08
|
||||
|
||||
- Palette tempérée plus douce, avec des couleurs communes pour l’herbe, le
|
||||
feuillage et l’eau. Dappled Forest quitte l’île initiale et reste réservé
|
||||
aux futurs continents ; les sauvegardes alpha.5 le conservent.
|
||||
- Prairies fleuries et affleurements rocheux plus étendus, avec une répartition
|
||||
des biomes selon l’altitude. Les corniches inférieures peuvent porter de la
|
||||
mousse, des fougères, des fleurs et des petits arbustes.
|
||||
- Sources recherchées aussi dans les parois des strates inférieures. Les
|
||||
cascades peuvent rejoindre un palier ou descendre dans le vide.
|
||||
- Retrait des courts ruisseaux à niveau constant : l’île conserve ses étangs
|
||||
et petits lacs. La grande rivière avec source, chute et bassin reste à
|
||||
développer ; elle n’est pas activée dans cette livraison.
|
||||
- Nouvelle clé `sanctuary:sanctuary_layered`, avec les anciennes générations
|
||||
conservées. Créer un **nouveau monde Sanctuary** pour cet incrément.
|
||||
|
||||
Validation : dix tests serveur réussis sur chacune des graines `0`, `42` et
|
||||
`8675309`, avec 1 800 ticks de fluides, contrôles des corniches et respect des
|
||||
modifications ultérieures aux sources. `check build assemblePack` réussi.
|
||||
|
||||
## 0.1.0-alpha.5 — 2026-09-08
|
||||
|
||||
- Plages plus larges et irrégulières, en nappes de sable, gravier et roche, avec
|
||||
des transitions herbeuses. Dépôts de 3 à 5 blocs au lieu d’une seule couche.
|
||||
- Étangs et lacs moins creusés, rives variées et petites terrasses ; la canne
|
||||
à sucre reste soumise à ses conditions vanilla.
|
||||
- Quelques sources sortent de niches rocheuses naturelles à différentes
|
||||
altitudes. Leurs cascades peuvent descendre dans le vide et servir à circuler
|
||||
verticalement ; seuls les blocs sources sont posés par la génération.
|
||||
- Petite réserve de lave dans une niche rocheuse accessible lorsque le relief
|
||||
fournit un fond, des parois et une voûte adaptés.
|
||||
- Mosaïque de cinq biomes tempérés, dont Dappled Forest de Minecraft 26.3.
|
||||
- Petits filons de charbon, fer, cuivre, or, redstone, lapis et diamant à des
|
||||
altitudes adaptées à l’île. Aucun quota ajouté après comptage des ressources.
|
||||
- Nouvelle clé de génération `sanctuary:sanctuary_natural` ; anciennes versions
|
||||
de terrain et d’hydrologie conservées pour les sauvegardes existantes.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour tester cette génération. La hauteur
|
||||
et le ciel validés en alpha.3/4 sont conservés. L’accès à l’End, les expansions
|
||||
et les grandes rivières en pente restent à développer.
|
||||
|
||||
Validation : dix tests serveur réussis sur chacune des graines 0, 42 et 8675309,
|
||||
avec 1 800 ticks réels de fluides. Les bassins et les réserves de lave restent
|
||||
stables ; les neuf cascades atteignent Y=0. `check build assemblePack` et les
|
||||
contrôles des couches de sédiments, des anciens générateurs et du pack réussissent.
|
||||
|
||||
## 0.1.0-alpha.4 — 2026-09-08
|
||||
|
||||
- Première hydrologie de surface : étangs, petits lacs et cours d’eau calmes
|
||||
adaptés au relief, avec vérification du fond et des berges.
|
||||
- Fonds de sable, gravier et argile, berges sableuses et canne à sucre
|
||||
placée après la végétation lorsque ses conditions vanilla sont réunies.
|
||||
- Hauteur, ciel et densité du terrain alpha.3 conservés ; nouveaux paramètres
|
||||
dédiés pour préserver la génération des anciennes sauvegardes.
|
||||
- Plan déterministe indépendant de l’ordre de génération des chunks, sans
|
||||
digue ajoutée, remplissage de cavité souterraine ni mer globale.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour tester l’hydrologie. Le pack continue
|
||||
à se mettre à jour dans la même instance Prism. Les grandes rivières en pente,
|
||||
cascades, océans et eaux des futurs continents restent à développer.
|
||||
|
||||
Validation : 10/10 tests serveur réussis sur chacune des graines 0, 42 et
|
||||
8675309, avec eau contenue après simulation réelle, continuité du cours d’eau
|
||||
et canne à sucre valide. Tests de forme et de déterminisme également réussis.
|
||||
Les cartes de blocs sont inspectées ; l’aspect dans le client reste à tester.
|
||||
|
||||
## 0.1.0-alpha.3 — 2026-09-08
|
||||
|
||||
- Nouveaux mondes Sanctuary sur 384 blocs de hauteur, avec l'île relevée de
|
||||
|
||||
@@ -13,29 +13,286 @@ infrastructures qui permettront progressivement d'ouvrir de nouveaux continents.
|
||||
> through the action of its players.
|
||||
|
||||
Ce dépôt démarre la nouvelle base Fabric. La [vision complète](docs/vision.md)
|
||||
conserve les intentions ; le [backlog](docs/backlog.md) prépare les premiers
|
||||
tickets. Les systèmes d'expansion, d'économie, de progression et les dimensions
|
||||
décrits dans la vision ne sont pas encore implémentés.
|
||||
conserve les intentions ; le [backlog](docs/backlog.md) suit les
|
||||
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.16 est publiée** : une ville sur l’île initiale, de grandes
|
||||
piscines, des parkings profonds et des égouts ouverts. Le canal packwiz et la
|
||||
même instance Prism sont à jour ; voir [Distribution](docs/packwiz.md).
|
||||
|
||||
## Premier incrément
|
||||
## Alpha.17 — prototype des réseaux oubliés
|
||||
|
||||
La nouvelle génération expérimentale recherche trois petits réseaux sur l’île
|
||||
initiale : mines et ateliers, salles étranges, installations techniques
|
||||
abandonnées. Chaque réseau possède une entrée de surface et des salles reliées,
|
||||
avec mobilier, outils vanilla et traces de travaux interrompus. Les continents
|
||||
conservent leur génération urbaine sans recevoir ces réseaux supplémentaires.
|
||||
|
||||
Dans **Personnaliser**, l’option **Structures expérimentales** accompagne les
|
||||
trois tailles habituelles. Elle est enregistrée avec chaque nouveau monde ;
|
||||
elle permet de comparer un monde avec les nouveaux lieux et un autre avec le
|
||||
socle urbain seul. La présence des réseaux dépend des emplacements admissibles
|
||||
trouvés dans un budget borné. Les connexions entre familles restent à construire.
|
||||
|
||||
Le prototype demande un **nouveau monde**. Les mondes 16 et antérieurs gardent
|
||||
leur génération. Revenir au binaire 16 ne permet pas d’ouvrir un monde 17 : les
|
||||
mondes d’essai restent séparés. Voir [le contrat alpha.17](docs/testing-alpha17.md).
|
||||
Le réacteur, la progression et les reliques restent prévus pour la suite.
|
||||
|
||||
`check build assemblePack`, création/réouverture et option désactivée passent.
|
||||
Les empreintes natives de la génération 16 restent identiques aux témoins publiés.
|
||||
Les artefacts 17 sont vérifiés ; publication et mise à jour du canal en cours.
|
||||
|
||||
## Alpha.16 — Sanctuary habitée avant nous
|
||||
|
||||
Une ville compacte apparaît sur la nouvelle île de départ lorsque les structures
|
||||
sont activées. Les étages varient leurs plans ; les sanitaires sont clos et les
|
||||
lits adossés aux murs. Sous la ville, la génération recherche une piscine couverte
|
||||
de **50 × 25 blocs**, des parkings de **3 à 8 plateformes**, une grande salle
|
||||
sèche et des égouts qui s’ouvrent vers l’extérieur avec de vrais écoulements.
|
||||
Chaque équipement dépend du relief et des appuis : les six îles testées ont une
|
||||
ville, mais seules trois accueillent la grande piscine. Les continents conservent
|
||||
leurs villes conditionnelles.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la génération 16. Les mondes publiés
|
||||
jusqu’à l’alpha.15 gardent leur génération, y compris dans leurs futurs chunks.
|
||||
Le bouton natif **Personnaliser** conserve Petit 512, Moyen 724 par défaut et
|
||||
Grand 1 024. Le [protocole alpha.16](docs/testing-alpha16.md) donne les preuves,
|
||||
les limites et les coordonnées du monde **Moyen, graine 0**, qui contient la
|
||||
piscine, huit niveaux de parking, la salle et l’égout ouvert.
|
||||
|
||||
`check build assemblePack`, la création et la réouverture natives passent.
|
||||
La compatibilité 15 est vérifiée contre les empreintes de la version publiée.
|
||||
La [release alpha.16](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.16)
|
||||
est disponible ; deux synchronisations de la même instance Prism conservent
|
||||
les 767 fichiers personnels et réglages suivis.
|
||||
Les futurs donjons formeront plusieurs réseaux parfois connectés, avec des
|
||||
installations encore utilisables et des chantiers interrompus. Le réacteur,
|
||||
les reliques et leurs récompenses restent à construire.
|
||||
|
||||
## Alpha.15 — ville compacte et réseaux
|
||||
|
||||
Les bâtiments de Lost City reçoivent des plans intérieurs détaillés : pièces,
|
||||
portes, vestibules, couloirs, escaliers, chambres, cuisines et salles d’eau.
|
||||
Les égouts relient des salles et équipements souterrains, avec garages, pool
|
||||
rooms et accès depuis la surface. Les bâtiments se regroupent en une
|
||||
petite ville compacte avec rues, carrefours et entrées reliés. Les ponts et
|
||||
viaducs prolongent le réseau ; le terrain est dégagé dans l’emprise urbaine.
|
||||
Une grande salle souterraine prépare la future machine d’expansion.
|
||||
|
||||
Le biome Lost City est conservé car il pilote réellement les biomes urbains
|
||||
et leurs apparitions de zombies vanilla. La nouvelle génération **15** demande
|
||||
un **nouveau monde Sanctuary** ; les mondes 12/13/14 gardent leur génération.
|
||||
Les tailles et le bouton natif **Personnaliser** restent identiques. Le
|
||||
[protocole alpha.15](docs/testing-alpha15.md) consigne les preuves et les limites. Les tests de création et de réouverture
|
||||
passent, ainsi que `check build assemblePack`. La
|
||||
[release alpha.15](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.15)
|
||||
est publiée ; la même instance Prism est synchronisée deux fois, avec 767
|
||||
fichiers personnels et réglages suivis conservés. Créer un **nouveau monde**,
|
||||
y compris après le JAR d’essai anticipé15. La machine d’expansion et ses cartes
|
||||
préremplies restent prévues pour un prochain ticket.
|
||||
|
||||
## Alpha.14 — Lost City
|
||||
|
||||
Lost City est un biome de quartiers contemporains abandonnés, composé par du
|
||||
code Sanctuary original. Des poches urbaines peuvent apparaître sur l’île de
|
||||
départ ; le profil d’expansion `lost_city` permet d’en rechercher dans les
|
||||
nouveaux continents. Le relief et ses fondations décident des emplacements :
|
||||
un quartier ou un type de bâtiment n’est pas garanti sur chaque île.
|
||||
|
||||
Les 17 types comprennent rues, ponts, quatre parcs (boisé, jardin, aire de jeux,
|
||||
mémorial), places, marchés, parkings, excavations, piscines abandonnées,
|
||||
immeubles d’appartements, hôtels, bibliothèques, restaurants avec cuisine,
|
||||
casernes et halls. **Pierre, pierre lisse et briques de pierre** forment la
|
||||
palette principale, avec des variantes fissurées ou moussues, du verre et
|
||||
quelques détails de fer et de chêne noir. Les étages disposent d’escaliers et
|
||||
les pièces d’aménagements propres à leur usage.
|
||||
|
||||
Les parkings souterrains, fouilles et piscines demandent une épaisseur de roche
|
||||
vérifiée sur toute leur emprise. Les piscines ont un fond, des parois continues
|
||||
et des marches ; les ponts enjambent de courtes failles entre deux berges
|
||||
supportées. Les monstres naturels du biome sont des zombies vanilla, soumis
|
||||
aux règles de lumière et de difficulté du jeu. Aucun spawner ou zombie spécial
|
||||
n’est ajouté ; les variantes de zombies et la progression restent pour la bêta.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la génération 14. Le bouton natif
|
||||
**Personnaliser** conserve Petit 512, Moyen 724 par défaut et Grand 1 024.
|
||||
Les mondes 12/13 gardent leurs codecs, journaux, paramètres et chunks.
|
||||
|
||||
Dans un nouveau monde de génération 14, les outils opérateur acceptent des
|
||||
expansions de 64, 128, 256, 512 ou 1 024 blocs. `quick` sans taille effectue un
|
||||
tirage reproductible favorisant les petites îles. Les emplacements suivent des
|
||||
secteurs directionnels autour du parent, avec séparation des emprises écrites.
|
||||
La reprise éventuelle de chunks de vide déjà explorés exige un certificat
|
||||
strict lié au journal et une réouverture du monde ; aucun terrain occupé n’est
|
||||
admis dans ce mécanisme.
|
||||
|
||||
Le [contrat et protocole alpha.14](docs/testing-alpha14.md) précisent les
|
||||
commandes, la protection des sauvegardes et les vérifications réussies :
|
||||
`check build assemblePack`, création et réouverture des quartiers, reprise du
|
||||
vide certifié et validation des artefacts de distribution.
|
||||
La [release alpha.14](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.14),
|
||||
le MRpack et le canal packwiz sont publiés. Deux installations isolées puis deux
|
||||
synchronisations de la même instance Prism Mac passent ; les 713 fichiers
|
||||
personnels et réglages suivis conservent leurs hashes. Aucun monde personnel
|
||||
n’a été ouvert ou régénéré. La validation native Windows reste à effectuer.
|
||||
|
||||
## 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).
|
||||
|
||||
La [release alpha.13.1](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.13.1)
|
||||
et le canal packwiz sont publiés. Deux synchronisations isolées et deux
|
||||
synchronisations de l’instance Mac existante passent ; les 713 fichiers personnels
|
||||
et réglages suivis restent identiques. Aucun accès à l’instance Windows n’a eu lieu.
|
||||
|
||||
## 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).
|
||||
|
||||
À la livraison alpha.13, le canal packwiz, le MRpack et la même instance Prism
|
||||
avaient été mis à jour, avec les 601 fichiers personnels et réglages alors suivis conservés.
|
||||
Voir [Distribution](docs/packwiz.md).
|
||||
|
||||
## Socle livré jusqu’à l’alpha.8
|
||||
|
||||
- Mod `sanctuary` indépendant et pack Sanctuary construit avec packwiz.
|
||||
- Preset **Sanctuary** sélectionnable à la création d'un monde, avec une île
|
||||
flottante finie, un terrain naturel et un extérieur vide.
|
||||
- Forêt de départ avec végétation, minerais et animaux vanilla. Les formes de
|
||||
terrain reprennent l'approche du générateur 26.2, adaptée à l'API 26.3.
|
||||
- Île tempérée aux teintes douces : forêts de chênes et de bouleaux, clairières
|
||||
fleuries et affleurements de pierre, andésite, diorite et granite. Des failles
|
||||
courbes ouvrent le terrain flottant issu du générateur 26.2. Les corniches
|
||||
humides accueillent chênes noirs et champignons ; les essences rares restent
|
||||
ponctuelles.
|
||||
- Géologie enrichie en profondeur : tuf, pierres moussues et boue compactée
|
||||
dans les intérieurs humides, puis ardoise des abîmes, roche noire, basalte et
|
||||
poches de soufre. Les volumes existants fournissent leur support.
|
||||
- Petits filons de charbon, fer, cuivre, or, redstone, lapis, diamant et émeraude
|
||||
à des altitudes adaptées au terrain. Les essais de charbon, fer et cuivre
|
||||
exposés sont plus fréquents. Les stocks se constatent après génération.
|
||||
- Spawn recherché sur le sol de l'île lors de la création. Les mondes ordinaires
|
||||
et les choix ultérieurs de spawn ne sont pas remplacés.
|
||||
- Nouveaux mondes sur 384 blocs (`Y=0..383`), île relevée de 64 blocs,
|
||||
dessous sculpté et nuages au-dessus du terrain. Les anciennes sauvegardes
|
||||
conservent leurs paramètres de génération.
|
||||
- Étangs, petits lacs, plages plus larges et dépôts de
|
||||
trois à cinq blocs de sable, gravier, pierre ou terre enherbée. La canne à
|
||||
sucre apparaît lorsque les rives respectent ses règles vanilla.
|
||||
- Recherche de bassins plus grands et d’une longue rivière calme au tracé
|
||||
arrondi, avec largeur variable et berges progressives. La liaison de deux
|
||||
bassins, sa source et sa cascade entrante sont contrôlées dans les mondes
|
||||
de test de l’alpha.8.
|
||||
- Quelques sources dans des niches rocheuses naturelles, y compris aux étages
|
||||
inférieurs : Minecraft fait couler leur eau en cascades, qui peuvent descendre jusque dans le vide.
|
||||
- Une petite poche de lave couverte est recherchée en priorité sur une corniche
|
||||
profonde, puis plus haut si nécessaire. Une à deux sources profondes
|
||||
supplémentaires peuvent former des coulées, vérifiées par les ticks du moteur.
|
||||
|
||||
L'île a une emprise nominale de 512 blocs de diamètre, avec une bordure sculptée
|
||||
Jusqu’à l’alpha.9, l’île a une emprise nominale de 512 blocs de diamètre, avec une bordure sculptée
|
||||
par des bruits liés à la seed. Aucune masse centrale n'est ajoutée pour forcer
|
||||
le terrain : le spawn cherche un emplacement naturel. Le catalogue
|
||||
TerraMix, les continents déverrouillables, les lacs et rivières, les ruines et
|
||||
TerraMix, les continents déverrouillables, les océans, les ruines et
|
||||
les autres fonctionnalités restent à construire par tickets.
|
||||
|
||||
La génération alpha.8, `sanctuary:sanctuary_rift`, passe les contrôles moteur
|
||||
sur les trois graines de référence, avec 1 800 ticks de fluides. Ces résultats
|
||||
ne valident pas par avance l’incrément suivant. Les observations et leurs
|
||||
limites figurent dans [Validation](docs/testing.md).
|
||||
|
||||
## 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
|
||||
envahissantes, avec de vrais geysers de soufre lorsque leurs conditions sont
|
||||
réunies. Les forêts de surface sont conservées ; la recherche de lacs plus
|
||||
profonds et volumineux accompagne des groupes facultatifs de bassins naturels
|
||||
étagés. Leurs cascades relient les paliers et permettent de circuler par la nage.
|
||||
L’image des « rizières » décrit ce paysage d’eau en terrasses : aucune culture
|
||||
de riz n’est ajoutée.
|
||||
|
||||
**La validation moteur alpha.9 passe sur les trois graines de référence.**
|
||||
Les preuves de terrasses, geysers et décor souterrain sont cumulées sur ces
|
||||
trois mondes, sans imposer chaque élément à chacun. La version est publiée
|
||||
et la même instance Prism est synchronisée ; voir [Distribution](docs/packwiz.md). Le
|
||||
nouveau réglage `sanctuary:sanctuary_cavern` nécessite de créer un **nouveau
|
||||
monde Sanctuary**. Les anciennes générations jusqu’à l’alpha.8 gardent leurs
|
||||
paramètres ; aucune sauvegarde n’est régénérée. Les cavités restent dans
|
||||
l’Overworld, sans créer la dimension des cavernes prévue par la vision.
|
||||
Dappled Forest reste réservé aux futurs continents et présent dans les
|
||||
anciennes sauvegardes alpha.5. Aucun lac, groupe de terrasses, geyser ou stock
|
||||
de ressources n’est garanti sur chaque graine. Voir le ticket local
|
||||
[WG-10](docs/backlog.md).
|
||||
|
||||
## Alpha.10, historique : îles pour 5, 20 ou 100 joueurs
|
||||
|
||||
L’alpha.10 proposait trois tailles, avec **20 joueurs par défaut**.
|
||||
Leur aire nominale est proportionnelle à la capacité choisie, sur la base
|
||||
de l’île de 512 blocs pour cinq joueurs. La hauteur reste de 384 blocs.
|
||||
|
||||
| Choix à la création | Identifiant du preset | Capacité | Diamètre nominal |
|
||||
| --- | --- | --- | --- |
|
||||
| Sanctuary 5 joueurs | `sanctuary:sanctuary_5` | 5 | 512 blocs |
|
||||
| Sanctuary, choix par défaut | `sanctuary:sanctuary` | 20 | 1 024 blocs |
|
||||
| Sanctuary 100 joueurs | `sanctuary:sanctuary_100` | 100 | Environ 2 290 blocs |
|
||||
|
||||
Ces capacités dimensionnent l’île ; elles ne fixent pas la limite de
|
||||
connexions du serveur. Le contour réel dépend du relief et de la graine,
|
||||
et le stock de ressources reste celui de la génération. La taille se choisit
|
||||
pour un **nouveau monde** : aucune ancienne île n’est agrandie et aucune
|
||||
sauvegarde n’est régénérée lors de la mise à jour.
|
||||
Les paramètres sauvegardés sont distincts : `sanctuary:population_5`,
|
||||
`sanctuary:population_20` et `sanctuary:population_100`.
|
||||
|
||||
**Les trois profils passent les onze tests moteur sur la graine `0`, avec
|
||||
1 800 ticks réels de fluides.** Le choix de capacité persiste après écriture
|
||||
et relecture sur disque. La vérification conserve 212 anciens fichiers de
|
||||
production strictement identiques ; huit fichiers d’intégration ou d’interface
|
||||
accueillent les nouveaux choix. La première planification locale reste coûteuse,
|
||||
environ 83–88 secondes pour les premiers essais 5/20 sur cette configuration.
|
||||
La génération en masse reste à optimiser. Ces essais ne simulent pas
|
||||
100 connexions et ne donnent aucune garantie de performances multijoueurs.
|
||||
Les diamètres nominaux ne permettent pas d’extrapoler les stocks ou l’aire
|
||||
réellement habitable. La version est publiée et la même instance Prism a été
|
||||
synchronisée deux fois, avec 363 fichiers personnels et réglages suivis conservés.
|
||||
Voir [WG-11](docs/backlog.md), [Validation](docs/testing.md) et
|
||||
[Distribution](docs/packwiz.md).
|
||||
|
||||
Le premier champ de recherche climatique est défini : nord froid, sud chaud,
|
||||
ouest sec et est humide, avec leurs combinaisons diagonales. Un outil de
|
||||
développement relève les ressources après génération ; le système d'expansion
|
||||
@@ -44,6 +301,95 @@ en jeu reste à construire. Voir [Expansion et ressources](docs/expansion.md).
|
||||
Les détails du terrain et de ses limites sont dans [Génération](docs/worldgen.md),
|
||||
et la provenance dans [Audit 26.2](docs/migration-26.2.md).
|
||||
|
||||
## Alpha.11, historique : Petit, Grand et génération plus rapide
|
||||
|
||||
Les anciens formats sont trop grands et éloignent les constructions. Le
|
||||
nouveau choix de départ se limite à **Petit** et **Grand**, avec **Grand par
|
||||
défaut**. Petit garde un diamètre nominal de 512 blocs ; Grand mesure environ
|
||||
724 blocs, soit deux fois l’aire nominale de Petit. La hauteur reste de
|
||||
384 blocs. Les repères internes 5 et 10 servent au calcul de surface : ils
|
||||
ne sont ni les noms des choix ni une promesse de capacité multijoueur.
|
||||
|
||||
Grand utilise le nouveau réglage `sanctuary:population_10`. Les anciennes
|
||||
parties `population_5`, `population_20` et `population_100` gardent leurs
|
||||
paramètres ; les formats 20/100 sont retirés des choix publics de création,
|
||||
sans réduire les mondes déjà enregistrés. Aucun monde personnel n’est ouvert
|
||||
pour les essais.
|
||||
|
||||
Le ticket [WG-12](docs/backlog.md) réduit les calculs répétés de terrain et
|
||||
permet de lire un plan régional déjà prêt pendant le calcul du suivant.
|
||||
À géométrie 20 identique, la création puis 289 chunks autour du spawn passent
|
||||
de 407,43 à 62,54 secondes (−84,65 %) ; 80 chunks d’exploration passent de
|
||||
103,14 à 14,05 secondes. Le nouveau Grand atteint 62,67 secondes sur le même
|
||||
protocole de départ. Ces essais serveur sur une graine et une machine ne
|
||||
mesurent pas le rendu du client ni une charge multijoueur.
|
||||
|
||||
`check build assemblePack` passe, ainsi que douze tests moteur pour chacun
|
||||
des deux choix, avec 1 800 ticks de fluides. Plus de trois millions de
|
||||
comparaisons de densité confirment l’équivalence de calcul. Les mesures et
|
||||
limites sont dans [Validation](docs/testing.md) ; publication et synchronisation
|
||||
de la même instance sont consignées dans [Distribution](docs/packwiz.md).
|
||||
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, 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
|
||||
Grand gardent leur génération alpha.11. Le laboratoire possède sa propre île
|
||||
initiale de 512 blocs, avec un relief supérieur légèrement déformé et des biomes
|
||||
vanilla. Les nouvelles îles choisissent séparément leur parent, direction,
|
||||
climat, diamètre nominal (64/128/256/512) et relief (naturel, pics, canyon, volcan).
|
||||
|
||||
Avec les commandes autorisées ou le statut opérateur :
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick north autumn 128 peaks
|
||||
/sanctuary expansion list
|
||||
/sanctuary expansion visit island_1
|
||||
/sanctuary expansion create oasis island_1 east arid 64 natural
|
||||
```
|
||||
|
||||
Le premier exemple ouvre une forêt automnale au nord ; le second continent
|
||||
part vers l’est depuis cette île. `quick north` seul emploie la convention froide,
|
||||
avec 128 blocs et un relief naturel. `create` permet de choisir explicitement un
|
||||
identifiant et un parent. `preview` propose l’emplacement sans l’activer ;
|
||||
`resume <id>` reprend une réservation interrompue. L’autocomplétion liste les
|
||||
directions, douze profils climatiques et quatre reliefs.
|
||||
|
||||
Les anciens chunks, même vides, sont refusés dans l’emprise d’une nouvelle île.
|
||||
Le registre persiste côté serveur ; la préparation est progressive et la visite
|
||||
attend un terrain prêt. Villages, temples, cabanes de marais et camps abandonnés
|
||||
vanilla peuvent être retenus selon leur biome et leurs fondations. Les autres
|
||||
structures, la grande hydrologie des continents, les recherches, les boîtes de
|
||||
dépôt et le refuge Void restent des étapes suivantes. Un biome humide ne promet
|
||||
pas encore un lagon ou un océan. Les règles, limites et essais figurent dans le
|
||||
[guide du laboratoire](docs/alpha12-laboratory.md).
|
||||
|
||||
`check build assemblePack` et les essais d’expansion/rechargement de graine 42
|
||||
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
|
||||
@@ -58,7 +404,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes.
|
||||
| Fabric API | 0.160.0+26.3 |
|
||||
| Fabric Loom | 1.17.20 |
|
||||
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
|
||||
| Sanctuary / pack | 0.1.0-alpha.3 |
|
||||
| Sanctuary / pack | 0.1.0-alpha.17 |
|
||||
|
||||
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
|
||||
@@ -79,13 +425,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.3.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.17.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).
|
||||
@@ -103,7 +451,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
|
||||
|
||||
@@ -7,9 +7,15 @@ provenance détaillée et le périmètre du portage.
|
||||
Minecraft appartient à Mojang Studios / Microsoft. Sanctuary est un projet
|
||||
communautaire indépendant. Le dépôt ne redistribue pas le jeu.
|
||||
|
||||
Le biome de départ reprend la configuration de la forêt vanilla 26.3-pre-2,
|
||||
adaptée pour une île finie. Les références de blocs, végétation et minerais
|
||||
sont résolues par Minecraft ; leurs textures et modèles ne sont pas inclus.
|
||||
Les biomes de départ reprennent les configurations de forêt, plaine, forêt de
|
||||
bouleaux, forêt fleurie et Dappled Forest de Minecraft 26.3-pre-2, adaptées à
|
||||
une île finie. Les variantes alpha.6 réutilisent les végétations vanilla pour
|
||||
les prairies fleuries et les bosquets ; Dappled Forest reste disponible pour
|
||||
les sauvegardes alpha.5. L’alpha.7 réemploie les arbres vanilla (dont chêne noir,
|
||||
cerisier et essences rares), les champignons, le bambou et les blocs de soufre
|
||||
de cette version, avec des conditions propres à l’île. Les règles des petits minerais sont adaptées à partir des
|
||||
features vanilla de cette même version. Les références de blocs, végétation et
|
||||
minerais sont résolues par Minecraft ; leurs textures et modèles ne sont pas inclus.
|
||||
|
||||
Fabric Loader : Apache-2.0. Fabric API : Apache-2.0. Fabric Loom et le wrapper
|
||||
Gradle : Apache-2.0. Ces projets conservent leurs auteurs, notices et licences.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Alpha.12 — laboratoire d’expansion
|
||||
|
||||
Ticket local **WG-13**, branche `codex/expansion-lab`. Contrat du laboratoire et guide
|
||||
d’essai. Cette livraison reste une alpha ; le canal distribué est suivi séparément
|
||||
dans [Distribution](packwiz.md).
|
||||
|
||||
## Décisions de conception
|
||||
|
||||
La direction géographique et le climat deviennent deux choix distincts. La
|
||||
boussole nord froid / sud chaud / ouest sec / est humide reste un raccourci
|
||||
facultatif. Une île peut prolonger le climat de son parent ou ouvrir une branche
|
||||
vers un autre climat. Les parents et les coordonnées forment un graphe persistant,
|
||||
permettant plusieurs couronnes autour de Sanctuary. Les recherches, leurs coûts,
|
||||
les boîtes aux lettres et les boîtes de dépôt sont planifiés pour plus tard.
|
||||
|
||||
Cette alpha fournit des commandes opérateur : créer, suivre, lister et visiter
|
||||
les expansions. Les tailles nominales des nouvelles îles sont 64, 128, 256 et
|
||||
512 blocs. Les reliefs de laboratoire s’appliquent aussi à l’île initiale, dans
|
||||
un nouveau preset expérimental. Les structures vanilla compatibles sont admises
|
||||
après contrôle de leur emprise et de leur support. Une absence reste possible.
|
||||
|
||||
L’alpha sert à éprouver les outils et les paysages. Le passage en bêta se fera
|
||||
après définition du périmètre de la vision et de la progression jouable ; ce
|
||||
numéro ne désigne pas une progression collective déjà livrée.
|
||||
|
||||
## Contrat de sauvegarde et d’ouverture v1
|
||||
|
||||
- Activation exclusivement dans un **nouveau monde du laboratoire alpha.12**.
|
||||
Les anciens presets, leurs paramètres, leurs chunks et leurs sauvegardes ne
|
||||
sont pas convertis. Il n’existe pas de commande de migration ou de régénération.
|
||||
- Un journal serveur propre au monde enregistre graine, version, origine,
|
||||
identifiant, parent, direction, climat, relief, diamètre, centre et statut.
|
||||
Les paramètres d’une île sont immuables après réservation.
|
||||
- Toute la réservation inclut terrain, décorations et halo moteur. Les régions
|
||||
déjà présentes sur disque, chargées ou en cours de déchargement sont refusées,
|
||||
même si leurs blocs sont tous vides. Une erreur de lecture interdit l’ouverture.
|
||||
- Le journal est écrit atomiquement et relu avant de publier le nouveau plan au
|
||||
générateur. Un journal existant absent ou corrompu n’est jamais remis à zéro.
|
||||
- La génération travaille uniquement dans la région nouvelle. Les chunks voisins
|
||||
nécessaires au moteur restent dans son halo. Aucun bloc d’une ancienne région
|
||||
n’est remplacé. Les références et le placement des structures utilisent le moteur.
|
||||
- Une réservation interrompue conserve son identité et sa graine. La préparation
|
||||
peut reprendre, sans double île ni nouveau tirage, jusqu’au statut prêt.
|
||||
- Les commandes exigent la permission opérateur. Être en créatif seul ne confère
|
||||
aucun droit sur le serveur. Une opération ne téléporte personne implicitement.
|
||||
- Le refuge Void et le déplacement collectif des joueurs restent un système
|
||||
distinct à concevoir. Ils ne servent pas de mécanisme de protection des chunks.
|
||||
|
||||
Le halo impose une distance supplémentaire dans cette première version. Le placer
|
||||
au plus proche ne permet pas de réutiliser silencieusement du vide déjà généré.
|
||||
Les points d’origine enregistrés ne suivent pas les changements de lit ou de spawn.
|
||||
|
||||
## Commandes et palette
|
||||
|
||||
Créer un **nouveau monde**, type **Sanctuary — Laboratoire**, avec les commandes
|
||||
activées. Pour un serveur neuf, utiliser `level-type=sanctuary:expansion_lab`
|
||||
et un nouveau `level-name`. La graine `42` fournit un cas reproductible observé
|
||||
avec un camp abandonné ; aucune structure n’est garantie sur chaque graine.
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick north autumn 128 peaks
|
||||
/sanctuary expansion list
|
||||
/sanctuary expansion visit island_1
|
||||
/sanctuary expansion create oasis island_1 east arid 64 natural
|
||||
/sanctuary expansion preview glacier sanctuary south glacial 256 canyon
|
||||
```
|
||||
|
||||
`quick` part toujours de Sanctuary et attribue un identifiant `island_N`.
|
||||
`create <id> <parent> <direction> <climate> <diameter> <relief> [distance]` ouvre une branche
|
||||
nommée depuis 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 |
|
||||
| `boreal` | Taïga, bosquets et neige en hauteur |
|
||||
| `glacial` | Plaines enneigées, pics gelés et pointes de glace |
|
||||
| `cold_humid` | Vieilles taïgas et bosquets froids |
|
||||
| `humid` | Forêt sombre, marécages et Pale Garden ponctuel |
|
||||
| `tropical` | Jungle, bambous et mangrove basse |
|
||||
| `warm` | Savane et plateaux |
|
||||
| `arid` | Désert, badlands et cheminées érodées |
|
||||
| `dry` | Prairies exposées et reliefs pierreux |
|
||||
| `cold_dry` | Neige, gravier et sommets gelés |
|
||||
| `autumn` | Dappled Forest, prairies et bouleaux ponctuels |
|
||||
| `cherry` | Cerisiers, prairies et bosquets en hauteur |
|
||||
|
||||
La sélection dépend de la graine, de l’altitude et de variations locales ; une
|
||||
petite île n’affiche pas nécessairement tous les biomes de son profil. Des poches
|
||||
luxuriantes, à spéléothèmes ou soufrées diversifient les étages inférieurs.
|
||||
Les reliefs `natural`, `peaks`, `canyon` et `volcano` sont indépendants du climat.
|
||||
Le volcan déforme et creuse la matière existante ; il ne garantit pas un cône
|
||||
plein ni un lac de lave lorsque la géométrie centrale est ajourée.
|
||||
|
||||
Les valeurs par défaut de `quick` suivent la boussole décrite dans
|
||||
[Expansion](expansion.md), avec diamètre 128 et relief naturel. Les directions
|
||||
acceptées sont `north`, `northeast`, `east`, `southeast`, `south`, `southwest`,
|
||||
`west`, `northwest`. L’autocomplétion fournit les paramètres disponibles.
|
||||
|
||||
## Portée technique et limites
|
||||
|
||||
Le codec `sanctuary:expansion_lab` enregistre le générateur ; le journal conserve
|
||||
les descripteurs de génération **12**. Chaque île emploie sa propre graine dérivée
|
||||
de la graine du monde et de son identifiant. Changer d’identifiant change donc
|
||||
le tirage. Les instances de bruit sont propres aux îles, y compris après reload.
|
||||
Les 34 biomes vanilla sont déclarés avant la première expansion.
|
||||
|
||||
La densité flottante existante sert de base. Le laboratoire ajoute les
|
||||
transformations des reliefs hauts et utilise les matériaux et décorations natifs.
|
||||
Ses carvers vanilla sont désactivés pour conserver les supports prospectés.
|
||||
Les grandes rivières et terrasses hydrologiques alpha.11 ne sont pas transférées
|
||||
dans ce prototype : les anciens presets les conservent. Le laboratoire essaie
|
||||
seulement de petites sources facultatives sur support rocheux fermé.
|
||||
|
||||
Les structures autorisées sont les villages (diamètre au moins 256), pyramides
|
||||
du désert et de jungle, cabanes de marais et camps abandonnés. Les starts natifs
|
||||
sont acceptés après vérification de leur emprise et de leurs fondations ; le
|
||||
moteur place les pièces, références et butins. Leur grille de sélection diffère
|
||||
de celle de `/locate` vanilla : cette commande n’est pas un outil fiable pour
|
||||
les retrouver dans le laboratoire. La preuve actuelle porte sur un camp ; elle
|
||||
ne valide pas visuellement chaque type de village ou de temple.
|
||||
|
||||
La réservation ajoute 32 blocs de décoration et 176 blocs de halo moteur autour
|
||||
de 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
|
||||
é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
|
||||
suivante avance chunk par chunk.
|
||||
|
||||
Les bateaux volants, montgolfières, panthéons, îles uniques en béton coloré,
|
||||
océans coralliens, recherches, coûts et boîtes de dépôt restent dans la vision.
|
||||
Aucun command block, transfert collectif au Void ou reset global n’est nécessaire
|
||||
pour utiliser ces commandes.
|
||||
|
||||
## Observations reproductibles
|
||||
|
||||
Sur la graine **42**, le scénario moteur a ouvert une île tropicale de 64 blocs
|
||||
au nord, puis une île aride de 64 blocs à l’est de celle-ci. Chaque préparation
|
||||
a atteint 100 chunks FULL. Les biomes sauvegardés, les points d’arrivée sur sol
|
||||
réel, les permissions du dispatcher, le refus des doublons incohérents, la
|
||||
conservation d’un bloc témoin sur Sanctuary et le refus du vide déjà chargé
|
||||
ont été vérifiés. Un camp abandonné possède un tonneau placé avec sa table de
|
||||
butin vanilla. Sur la graine **0**, ces expansions fonctionnent mais aucun des
|
||||
11 sites de structure candidats n’a passé les conditions de terrain ; le test
|
||||
exigeant une preuve de butin a donc échoué sur cette graine.
|
||||
|
||||
Un second processus Java a rouvert le monde de développement de graine 42 et
|
||||
retrouvé les trois régions prêtes, le bloc d’or écrit avant l’arrêt, les colonnes
|
||||
de blocs, biomes, densités et le tonneau. Le codec sauvegardé a aussi été décodé.
|
||||
Le framework GameTest reconstruit ses réglages depuis le preset : ce contrôle
|
||||
ne remplace pas un essai du parcours normal de chargement dans le client.
|
||||
La reprise d’un journal réservé est contrôlée séparément par le smoke ; un
|
||||
crash forcé en pleine génération, les téléportations de vrais joueurs et la
|
||||
charge multijoueur ne sont pas simulés.
|
||||
|
||||
Les smokes parcourent cinq graines, les quatre tailles et les quatre reliefs :
|
||||
transformation verticale monotone, dessous de l’origine conservé, canyon retirant
|
||||
de la matière et couronne volcanique. Ce sont des contrôles géométriques ; les
|
||||
profils 128/256/512 et les quatre reliefs demandent encore une appréciation
|
||||
visuelle en jeu. Voir [Validation](testing.md) pour les commandes et preuves.
|
||||
@@ -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).
|
||||
+563
-2
@@ -10,7 +10,14 @@ Le [document de vision](vision.md) conserve les idées à long terme. Ce backlog
|
||||
commit. Le prototype WG-02 et l'initialisation du spawn WG-03 sont implémentés
|
||||
et passent les tests serveur décrits dans [Validation](testing.md). Les essais
|
||||
visuels, multijoueurs et de redémarrage indiqués dans ce document restent à
|
||||
faire avant de clôturer tous leurs critères. WG-04 à WG-07 restent proposés.
|
||||
faire avant de clôturer tous leurs critères. WG-04 à WG-07 restent ouverts,
|
||||
avec les incréments de l’île initiale décrits ci-dessous. WG-08 est validé ;
|
||||
WG-09 est livré en alpha.8, avec mesures moteur et mise à jour Prism vérifiées.
|
||||
WG-10 est livré en alpha.9, avec mesures moteur et synchronisation Prism vérifiées.
|
||||
WG-11 est livré en alpha.10 : tailles 5/20/100 validées sur la graine 0,
|
||||
release publiée et même instance Prism synchronisée.
|
||||
WG-12 est livré en alpha.11 : Petit/Grand, génération accélérée et même instance
|
||||
Prism synchronisée deux fois ; voir [Distribution](packwiz.md).
|
||||
|
||||
Résultat visé : un client et un serveur Fabric compatibles peuvent ouvrir un monde Sanctuary, plusieurs joueurs y arrivent sur une même île sûre et les chunks extérieurs restent vides. La génération est reproductible et ses limites sont documentées.
|
||||
|
||||
@@ -118,10 +125,34 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
|
||||
- Pour l'île initiale tempérée et légèrement humide : privilégier petits étangs,
|
||||
lacs de surface, rivière et berges propices à la canne à sucre, avec quelques
|
||||
plages plus sèches. Pas de grands bassins souterrains remplis d'eau.
|
||||
- L'eau ne se répand pas de manière incontrôlée dans le vide lors du chargement et des mises à jour de blocs du scénario testé.
|
||||
- Les étangs et les lacs retiennent leur eau après les mises à jour de blocs.
|
||||
Des sources rocheuses déclarées peuvent former des cascades jusqu’au vide ;
|
||||
leurs écoulements restent attribuables à ces sources, sans inondation globale.
|
||||
- Les profils du dessous et les bords du continent sont inspectés visuellement depuis les airs.
|
||||
- La génération est mesurée sur une emprise et une machine indiquées ; les valeurs observées sont consignées, sans annoncer un objectif de performance non mesuré.
|
||||
|
||||
**Incrément île initiale — alpha.4 :** première hydrologie de surface retenue,
|
||||
conservée pour les sauvegardes de cette version.
|
||||
|
||||
**Incrément île initiale — alpha.5 :** plages plus larges,
|
||||
dépôts de trois à cinq couches sur support naturel, excavation limitée,
|
||||
rives variées et sources rocheuses pouvant former des cascades jusque dans le
|
||||
vide. Le contrat et l’isolation des anciennes générations sont décrits dans
|
||||
[Génération](worldgen.md). Ce travail prépare les eaux des continents sans clore
|
||||
WG-06 : océans, grandes rivières et ouverture des continents restent à développer.
|
||||
|
||||
**Incrément île initiale — alpha.6 :** retrait des courts ruisseaux, conservation
|
||||
des bassins, présence de roche élargie et sources de paroi dans les strates
|
||||
inférieures. Les écoulements sont contrôlés dans le moteur et les anciennes
|
||||
générations sont préservées.
|
||||
|
||||
**Suite suivie dans WG-09 — grande rivière facultative :** chercher un long parcours qui
|
||||
épouse l’île, avec source, chute et bassin, rives de sable, gravier et argile.
|
||||
Le démontrer sur la vraie densité Minecraft puis après décoration et ticks de
|
||||
fluide, avec une graine et des coordonnées reproductibles. Les essais du
|
||||
prototype alpha.6 n’ont fourni aucun parcours retenu sur les graines de référence ;
|
||||
ce prototype n’est pas distribué. Ne pas imposer de rivière si le relief ne s’y prête pas.
|
||||
|
||||
### WG-07 — Introduire un premier biome distinct et une structure
|
||||
|
||||
**Dépendance :** WG-06.
|
||||
@@ -136,6 +167,536 @@ Ce jalon fournit un outil de développement du terrain. L'interface d'expansion
|
||||
- La reprise du catalogue TerraMix natif d'Another World 26.2 est évaluée séparément ; « plus de cent biomes » reste une ambition tant que son adaptation n'est pas vérifiée.
|
||||
- Les limites des futures zones Lost Cities et des structures uniques sont identifiées sans imposer leur livraison dans ce ticket.
|
||||
|
||||
**Incrément île initiale — alpha.5 :** cinq variantes
|
||||
tempérées à dominante forestière, dont Dappled Forest vanilla 26.3, et petits
|
||||
filons de sept minerais à des altitudes adaptées. Leurs stocks sont observés
|
||||
après génération. Cela ne clôture pas WG-07 : aucune structure de référence ni
|
||||
reprise du catalogue TerraMix n’est encore fournie. La survie initiale doit
|
||||
permettre de produire et progresser au-delà du bois et de la pierre ; l’accès
|
||||
à toute la progression Minecraft n’est pas encore garanti pour chaque seed.
|
||||
|
||||
**Ticket palette — alpha.6 :** réserver les biomes très contrastés de la 26.3,
|
||||
dont Dappled Forest, aux futurs continents. L’île combine prairies fleuries,
|
||||
bosquets et zones rocheuses, avec des teintes cohérentes et une végétation
|
||||
effective sur les corniches inférieures. Cela ne livre pas encore les continents.
|
||||
|
||||
### WG-08 — Forêts de survie et minerais visibles — alpha.7
|
||||
|
||||
**Branche :** `codex/woodland-canopy`.
|
||||
|
||||
**But :** conserver les formes rocheuses appréciées en alpha.6 tout en donnant
|
||||
à l’île de vraies forêts de surface et des ressources de départ repérables.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Chênes majoritaires parmi les arbres de surface observés, bouleaux secondaires,
|
||||
avec des arbres au cœur de l’île et des clairières fleuries.
|
||||
- Chênes noirs réels et champignons sur des corniches intérieures naturelles ;
|
||||
jungle/bambou et matières de soufre rares et localisés.
|
||||
- Un éventuel arbre remarquable, choisi parmi les essences rares, sans ajout
|
||||
de terrain ni obligation de fournir chaque essence par graine.
|
||||
- Affleurements de pierre, andésite, diorite et granite ; sable et gravier
|
||||
associés aux eaux, avec des couches soutenues.
|
||||
- Charbon, fer et cuivre visibles au contact de l’air sur la roche, avec des
|
||||
petits filons sans quota corrigé après génération.
|
||||
- Démonstration dans le moteur, maintien des eaux après 1 800 ticks et
|
||||
conservation des anciennes générations. Publication packwiz et mise à jour
|
||||
de l’instance Prism existante en conservant ses données personnelles.
|
||||
|
||||
**Vérification alpha.7 :** les trois graines de référence passent les tests
|
||||
du moteur, dont les forêts, les minerais exposés et les eaux après 1 800 ticks.
|
||||
Les observations et leurs limites sont consignées dans [Validation](testing.md).
|
||||
|
||||
### WG-09 — Failles, rivière et profondeurs — alpha.8
|
||||
|
||||
**Branche :** `codex/rifts-rivers-and-depths`.
|
||||
|
||||
**But :** ouvrir l’intérieur de l’île, enrichir sa géologie et ses ressources
|
||||
de survie, tout en conservant les forêts de surface appréciées en alpha.7.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Une à trois traces de failles courbes, de longueur nominale 84 à 160 blocs
|
||||
et de largeur variable, retirent uniquement de la matière du relief initial.
|
||||
Leur intersection réelle avec la roche est montrée sur les graines testées.
|
||||
- Les forêts de chênes et de bouleaux restent présentes en surface. Des gros
|
||||
champignons, des petits champignons dispersés et des tapis de mousse, podzol
|
||||
et mycélium occupent les corniches compatibles de l’intérieur humide.
|
||||
- Les matières changent avec la profondeur, avec des nappes de tuf, cobblestone,
|
||||
pierre moussue et boue compactée, puis ardoise des abîmes, roche noire et basalte.
|
||||
Des poches de soufre réellement accessibles sont observées sur le terrain.
|
||||
- Charbon, fer et cuivre exposés sont plus faciles à repérer dans les relevés.
|
||||
L’émeraude est ajoutée ; or, diamant, redstone et lapis sont cherchés aux
|
||||
altitudes présentes. Aucun stock fixé n’est réinjecté après comptage.
|
||||
- Des bassins plus grands sont retenus. Une rivière facultative est démontrée
|
||||
par un chemin d’eau continu d’au moins 140 blocs, reliant deux bassins au
|
||||
même niveau avec source rocheuse et cascade entrante. Le tracé est arrondi
|
||||
à l’échelle de 16 à 32 blocs, avec largeur de lit variable d’environ 5 à 7
|
||||
blocs. L’incision du lit reste bornée à 16 blocs ; les berges de rivière
|
||||
s’étagent progressivement avec au plus 12 blocs retirés, contre 2 pour les
|
||||
autres rives. Le fond et les dépôts reposent sur le terrain existant.
|
||||
- La poche de lave couverte est recherchée en priorité sur une corniche basse
|
||||
entre Y=64 et Y=160 ; la recherche plus haute n’intervient que si aucun
|
||||
emplacement profond compatible n’est trouvé.
|
||||
- Une à deux sources profondes de lave sont recherchées lorsque le site est
|
||||
compatible ; leurs coulées viennent des ticks vanilla. Les supports, les
|
||||
eaux voisines et la végétation sont contrôlés après simulation réelle.
|
||||
- La nouvelle clé `sanctuary:sanctuary_rift` n’active ces traitements que pour
|
||||
les nouveaux mondes. Les données et comportements historiques restent séparés.
|
||||
- Les graines `0`, `42` et `8675309` disposent de mesures moteur explicites,
|
||||
avec fluides actifs pendant 1 800 ticks. Une absence de rivière ou de source
|
||||
est rapportée honnêtement ; un bassin isolé ne valide pas une rivière.
|
||||
- Après validation : build et pack vérifiés, publication immuable, deux
|
||||
synchronisations du canal packwiz et conservation de la même instance Prism,
|
||||
de ses sauvegardes et de ses réglages.
|
||||
|
||||
**Vérification moteur alpha.8 — 9 septembre 2026 :** les trois graines de
|
||||
référence passent les contrôles finaux, dont les deux bassins terminaux,
|
||||
les champignons connectés et 1 800 ticks de fluides. Les stocks et leurs
|
||||
périmètres sont consignés dans [Validation](testing.md). Le build complet est
|
||||
réussi. L’alpha.8 est publiée sur le canal stable et la même instance Prism
|
||||
est synchronisée deux fois, avec sauvegardes et réglages conservés. Le ticket
|
||||
est livré ; l’appréciation visuelle et l’équilibrage en partie restent à
|
||||
éprouver. Ce ticket local n’est pas une issue distante publiée.
|
||||
|
||||
### WG-10 — Cavités luxuriantes et terrasses d’eau — alpha.9
|
||||
|
||||
**Branche :** `codex/lush-caverns-and-water-terraces`.
|
||||
|
||||
**But :** rendre les cavités sous Sanctuary plus vivantes et lumineuses,
|
||||
diversifier leurs ressources et créer des eaux plus volumineuses, sans
|
||||
remplacer les forêts et la rivière de surface appréciées en alpha.8.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Des cavités luxuriantes portent une végétation réellement lumineuse et
|
||||
enracinée dans les surfaces et plafonds existants. Les observations doivent
|
||||
distinguer un biome déclaré, ses blocs décoratifs et leur lumière réelle.
|
||||
- Des secteurs à spéléothèmes et des géodes d’améthyste sont recherchés dans
|
||||
la roche compatible ; leurs supports, leurs volumes et leur accessibilité
|
||||
sont contrôlés dans les vrais chunks, sans géodes suspendues dans le vide.
|
||||
- Le soufre occupe moins de roche qu’en alpha.8, sur un périmètre de comparaison
|
||||
explicite. Les geysers éventuels utilisent le soufre actif et les conditions
|
||||
exactes de Minecraft 26.3-pre-2 ; leur fonctionnement doit être démontré,
|
||||
sans confondre un bloc jaune avec un geyser actif.
|
||||
- Les forêts, la rivière et les bassins de surface restent présents lorsque
|
||||
le relief le permet. Les lacs plus profonds et volumineux sont mesurés en
|
||||
blocs d’eau réels, avec fond et parois retenus après simulation.
|
||||
- Des groupes facultatifs de deux bassins naturels en terrasses
|
||||
peuvent relier plusieurs niveaux par des cascades déclarées. Chaque palier
|
||||
doit avoir ses supports et son propre volume retenu ; une cascade isolée
|
||||
ou des plans d’eau sans liaison ne valident pas un groupe de terrasses.
|
||||
Le second bassin peut avoir une sortie terminale vers les roches ou le vide,
|
||||
sans créer un troisième palier.
|
||||
- Ces cascades offrent un passage vertical selon les règles de nage de
|
||||
Minecraft. « Rizières » désigne seulement leur disposition paysagère :
|
||||
aucune culture de riz ni téléportation n’entre dans ce ticket.
|
||||
- Les sorties d’eau sont contrôlées après décoration et après 1 800 ticks,
|
||||
y compris aux frontières de chunks. Leur finition initiale doit préserver
|
||||
les aménagements du joueur aux rechargements suivants.
|
||||
- Le nouveau réglage `sanctuary:sanctuary_cavern` s’applique aux nouveaux
|
||||
mondes. Les identifiants et traitements alpha.1 à alpha.8 restent séparés ;
|
||||
aucune sauvegarde personnelle n’est modifiée par les essais.
|
||||
- Les graines `0`, `42` et `8675309` disposent de mesures identifiées par
|
||||
version. Les sites absents sont signalés ; aucun quota de ressources ni
|
||||
bassin de secours ne remplit un relief incompatible.
|
||||
- Après validation : `check build assemblePack`, artefacts immuables vérifiés,
|
||||
deux synchronisations isolées puis deux dans la même instance Prism,
|
||||
sauvegardes et réglages conservés.
|
||||
|
||||
**Validation moteur au 9 septembre 2026 :** les trois graines de référence
|
||||
passent les contrôles finaux. Les preuves cumulées démontrent un groupe de
|
||||
deux paliers reliés, un geyser actif et les décors souterrains demandés ; les
|
||||
absences locales et périmètres de mesure figurent dans [Validation](testing.md).
|
||||
Le build est réussi ; la release et le canal packwiz sont publiés. Les deux
|
||||
synchronisations isolées puis les deux passages dans la même instance Prism
|
||||
réussissent, avec 260 fichiers personnels et réglages conservés. Ce ticket
|
||||
local n’est pas une issue distante publiée ; l’essai visuel et l’équilibrage
|
||||
restent ouverts.
|
||||
|
||||
### WG-11 — Tailles d’île selon la capacité — alpha.10 livrée
|
||||
|
||||
**Branche :** `codex/player-capacity-presets`.
|
||||
|
||||
**But :** choisir une île initiale adaptée à un groupe de 5, 20 ou 100 joueurs,
|
||||
avec 20 joueurs par défaut, tout en conservant l’ambiance et les anciennes parties.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- La création d’un nouveau monde propose trois choix identifiables en FR/EN :
|
||||
Sanctuary par défaut pour 20 joueurs et des variantes pour 5 et 100 joueurs.
|
||||
Les presets `sanctuary:sanctuary_5`, `sanctuary:sanctuary` et
|
||||
`sanctuary:sanctuary_100` sélectionnent respectivement les paramètres
|
||||
`sanctuary:population_5`, `sanctuary:population_20` et
|
||||
`sanctuary:population_100`, qui doivent persister dans le monde sauvegardé.
|
||||
- L’aire nominale suit le rapport capacité/5. Les diamètres de référence sont
|
||||
512, 1 024 et environ 2 290 blocs ; la hauteur reste de 384 blocs. Le contour
|
||||
sculpté dépend de la graine et n’est pas remplacé par un cercle ou un cylindre.
|
||||
- Le choix dimensionne la génération, sans modifier `max-players`, imposer
|
||||
un quota de ressources ni redimensionner l’île lorsque des joueurs arrivent.
|
||||
- Les tailles conservent un extérieur vide, un spawn naturel, les forêts et
|
||||
l’écologie des cavités. Les eaux et les décors doivent être contrôlés dans
|
||||
les vrais chunks de la taille testée, pas seulement extrapolés depuis l’alpha.9.
|
||||
- Les réglages et identifiants des générations précédentes restent séparés.
|
||||
Une mise à jour du pack n’agrandit pas un ancien monde, ne convertit pas son
|
||||
générateur et ne régénère aucun chunk.
|
||||
- La validation progresse de 5 à 20 puis 100 joueurs dans des mondes de
|
||||
développement neufs. Chaque résultat précise capacité, graine, emprise
|
||||
réellement inspectée et durée observée. Un petit échantillon ne valide pas
|
||||
l’intégralité d’une grande île.
|
||||
- Les tests de génération ne sont pas présentés comme des connexions de
|
||||
joueurs simulées ni comme une promesse de performances multijoueurs.
|
||||
- Après validation : `check build assemblePack`, artefacts immuables vérifiés,
|
||||
deux synchronisations isolées puis deux dans la même instance Prism,
|
||||
avec sauvegardes et réglages conservés.
|
||||
|
||||
**État au 9 septembre 2026 :** implémentation et validation moteur terminées
|
||||
sur la graine `0` pour les profils 5, 20 et 100. Les onze tests requis passent
|
||||
dans chaque monde, avec les fluides pendant 1 800 ticks et la persistance réelle
|
||||
des réglages sur disque. Le build et les artefacts sont vérifiés ; la release
|
||||
et le canal sont publiés. Deux synchronisations isolées puis deux dans la même
|
||||
instance Prism réussissent, avec 363 fichiers personnels et réglages conservés.
|
||||
Les références figurent dans [Distribution](packwiz.md).
|
||||
Les anciens mondes restent inchangés. Les observations ne couvrent pas toute
|
||||
l’île de 100 joueurs, et le coût initial d’un plan régional reste notable.
|
||||
Ce ticket local n’est pas une issue distante publiée.
|
||||
|
||||
### WG-12 — Petit, Grand et génération plus rapide — alpha.11 livrée
|
||||
|
||||
**Branche :** `codex/worldgen-performance`.
|
||||
|
||||
**Problème :** les grands formats éloignent trop les constructions. Les essais
|
||||
signalent aussi plusieurs minutes de chargement puis des chunks difficiles à
|
||||
utiliser. Le ticket rapproche les deux choix de départ et optimise les calculs,
|
||||
avec des preuves distinctes pour la taille et la vitesse.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Proposer seulement **Petit** et **Grand** dans la création, avec **Grand par
|
||||
défaut**, et libellés FR/EN. Petit garde le diamètre nominal de 512 blocs ;
|
||||
Grand vise environ 724 blocs, soit une aire nominale deux fois supérieure.
|
||||
La hauteur reste de 384 blocs. Les nombres 5/10 ne servent que de repères
|
||||
internes de surface, sans capacité de joueurs garantie.
|
||||
- Associer Petit à `sanctuary:sanctuary_5` / `sanctuary:population_5` et Grand
|
||||
à `sanctuary:sanctuary` / nouveau réglage `sanctuary:population_10`.
|
||||
Garder les anciens réglages `population_20` et `population_100` chargeables,
|
||||
tout en cachant leurs formats dans les choix publics de création.
|
||||
- Ne pas modifier le relief d’une sauvegarde existante, son identifiant ou
|
||||
son format. Les changements du défaut de création ne convertissent pas
|
||||
un monde. Aucune sauvegarde personnelle n’est ouverte pendant les essais.
|
||||
- Pour l’optimisation, **conserver les résultats à paramètres identiques** :
|
||||
densités, plans et blocs utiles, ordre déterministe et protections de l’eau,
|
||||
des terrasses et de la lave. Le nouveau relief Grand a une clé distincte ;
|
||||
la réduction de surface ne compte pas comme gain de calcul à géométrie égale.
|
||||
- Mesurer séparément démarrage initial, planification et génération d’un lot
|
||||
explicite de chunks. Fixer machine, Java, mémoire, graine, paramètres,
|
||||
coordonnées et état des caches pour comparer alpha.10 et alpha.11.
|
||||
- Viser **120 secondes au plus de démarrage initial** pour les choix publics
|
||||
et **au moins 30 % de gain** sur les phases comparées à géométrie identique.
|
||||
Un temps de planificateur seul ne prouve pas l’entrée dans un monde jouable.
|
||||
Préciser les bornes de chronométrage et ne pas promettre ces seuils pour
|
||||
tout matériel ou pour une charge de cent joueurs.
|
||||
- Valider Petit et Grand, plus un témoin de compatibilité et de performance
|
||||
sur l’ancien profil 20. Les anciennes mesures 100 restent historiques ;
|
||||
ce format n’est plus un choix public ni le centre du plan de validation.
|
||||
Examiner le premier accès, le cache réutilisé et les chunks d’exploration.
|
||||
- Conserver les contrôles moteur de fluides et de réentrée sans marqueur,
|
||||
puis exécuter `check build assemblePack` sur les sources finales. Garder
|
||||
visibles les limites et toute cible de performance non atteinte.
|
||||
- Après validation seulement : artefacts immuables, canal packwiz stable,
|
||||
deux synchronisations isolées puis deux dans la même instance Prism. Les
|
||||
mondes, réglages et mods personnels, même désactivés, doivent être préservés.
|
||||
|
||||
**État :** livré. Artefacts immuables publiés, canal stable avancé, deux
|
||||
synchronisations isolées et deux dans la même instance Prism, avec 364 fichiers
|
||||
personnels et réglages suivis conservés. Tailles et code validés. `check build assemblePack` passe, ainsi
|
||||
que douze tests moteur sur Petit et Grand (graine 0, 1 800 ticks), les contrôles
|
||||
de persistance et l’équivalence bit à bit. Le benchmark ancien20 comparable
|
||||
baisse de 84,65 % au départ et de 86,38 % en exploration. Grand atteint
|
||||
62,67 secondes pour création + 289 chunks FULL. Cette mesure serveur ne
|
||||
valide pas le rendu ni un temps de chargement universel. Voir
|
||||
[Validation](testing.md), [résultats](benchmarks/alpha11-generation.json) et
|
||||
[Distribution](packwiz.md) pour le reçu de publication et de synchronisation.
|
||||
Ce ticket local n’est pas une issue distante publiée.
|
||||
|
||||
### WG-13 — Laboratoire d’expansion à branches — alpha.12
|
||||
|
||||
**Branche :** `codex/expansion-lab`.
|
||||
|
||||
**But :** tester en opérateur de nouvelles îles, des climats alternatifs, les
|
||||
reliefs hauts et les structures vanilla avant de développer leur progression.
|
||||
|
||||
**Critères d’acceptation :**
|
||||
|
||||
- Nouveau preset laboratoire ; Petit/Grand et anciennes parties conservés.
|
||||
- Commandes pour préparer, créer, suivre, reprendre et visiter une île ;
|
||||
opérateurs uniquement, raccourcis et retours FR/EN.
|
||||
- Parent et direction indépendants du climat, avec tailles nominales bornées
|
||||
64/128/256/512 et reliefs versionnés. Une branche peut changer de climat.
|
||||
- Application du relief haut aussi à l’île initiale expérimentale ; dessous
|
||||
historique et sources anciennes préservés.
|
||||
- Structures vanilla sélectionnées par biome avec contrôles d’emprise/support ;
|
||||
preuve moteur d’une structure réellement placée et de son butin natif.
|
||||
- Journal atomique et repris après interruption, demandes répétées sans doublon,
|
||||
refus des chunks existants y compris vides, conservation d’un bloc témoin.
|
||||
- Contrat de réservation explicite avant toute activation. Aucun ancien monde
|
||||
converti ; aucune suppression de chunks ni commande de régénération.
|
||||
- `check build assemblePack` et tests moteur sur monde jetable, avec limites
|
||||
documentées. Les recherches, prix, boîtes postales et le Void restent planifiés.
|
||||
|
||||
**État :** implémenté et vérifié localement : build/pack, douze tests historiques,
|
||||
expansions et rechargement moteur de graine 42. Release et MRpack publiés,
|
||||
canal packwiz avancé et même instance Prism synchronisée deux fois ; 466 fichiers
|
||||
personnels et réglages suivis conservés. Validation visuelle et multijoueur
|
||||
encore ouverte. Contrat et portée des preuves
|
||||
dans [Laboratoire alpha.12](alpha12-laboratory.md).
|
||||
Ce ticket ne clôture pas par avance les océans, toute la palette de structures
|
||||
ou la progression collective prévus par WG-04 à WG-07 et la vision.
|
||||
|
||||
### WG-14 — Distance et recherche 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. Correctif publié depuis `b19e93d`, canal
|
||||
stable avancé et deux synchronisations isolées puis deux dans la même instance
|
||||
Prism Mac réussies, avec 713 fichiers personnels et réglages suivis conservés.
|
||||
Preuves dans [Validation](testing.md) et [Distribution](packwiz.md).
|
||||
|
||||
### WG-17 — Lost City, territoires urbains en ruine — alpha.14
|
||||
|
||||
**Branche :** `codex/lost-city-biome`.
|
||||
|
||||
**Contrat :** créer un biome Lost City avec quartiers contemporains explorables,
|
||||
rues, ponts, parcs boisés, jardins, aires de jeux, mémoriaux, places, marchés,
|
||||
parkings, excavations, piscines et bâtiments variés : appartements, hôtels,
|
||||
bibliothèques, restaurants/cuisines, casernes et grands halls. Les
|
||||
palettes reposent sur la pierre, la pierre lisse et les briques de pierre avec
|
||||
leurs variantes usées. Les zombies vanilla occupent les lieux par les règles
|
||||
de spawn ; aucun spawner ni nouveau zombie spécial dans cette étape.
|
||||
|
||||
- Des quartiers localisés peuvent apparaître sur l’île initiale ; conserver
|
||||
une majorité de terrain naturel et les systèmes hydrologiques/cavités.
|
||||
- Profil d’expansion `lost_city` pour tester des territoires urbains plus vastes.
|
||||
Bâtiments, circulations et ponts s’appuient sur le relief, sans remplir le vide.
|
||||
- Structures natives persistantes, génération déterministe par graine,
|
||||
décoration bornée par chunk, intérieurs accessibles et butin limité.
|
||||
- Dix-sept types de parcelles aux aménagements distincts. Parkings souterrains,
|
||||
piscines et fouilles conditionnés à une roche continue sur toute l’emprise ;
|
||||
garage en surface ou parc de repli sinon. Parois et marches des piscines,
|
||||
escaliers et barrières des fouilles, ponts sans remplissage du vide.
|
||||
- Expansions 64/128/256/512/1 024 dans les nouveaux mondes 14 ; `quick` sans
|
||||
taille tire selon les poids 40/30/18/9/3, avec résultat reproductible par graine
|
||||
et identifiant. Placement proche dans un secteur directionnel, séparation
|
||||
des emprises circulaires écrites et garde aux coins de chunks.
|
||||
- Nouvelle génération **14**, codec `sanctuary:island_v14`, journal propre
|
||||
`data/sanctuary-world-v14/expansions.json` de schéma 1. Ce contrat s’applique
|
||||
uniquement aux nouveaux mondes et à leurs expansions autorisées par commande.
|
||||
Les codecs 12/13, leurs journaux et les chunks existants gardent leur génération.
|
||||
Aucun monde personnel n’est ouvert, modifié, converti ou régénéré.
|
||||
- Contrat particulier de reprise du vide, limité aux mondes 14 : certificat
|
||||
explicite pour des chunks FULL entièrement vides et vierges, lié au jeton
|
||||
immuable de la réservation. Réouverture obligatoire avant préparation et
|
||||
recontrôle de la preuve ; aucune régénération de terrain occupé. L’absence
|
||||
d’une preuve complète, des entités, POI, ticks ou événements bloc empêchent
|
||||
l’admission. Le [contrat détaillé](testing-alpha14.md) fait partie du ticket.
|
||||
- Vérifier structures et biomes dans le moteur, persistance après redémarrage,
|
||||
fondations, circulation, absence de spawners/villageois, héritage Windows,
|
||||
build et pack. Livrer dans le canal stable et la même instance Prism.
|
||||
|
||||
**État : terminé et livré en alpha.14.** Build et tests moteur réussis,
|
||||
release et canal packwiz publiés, deux synchronisations isolées puis deux dans
|
||||
la même instance Prism Mac réussies, 713 fichiers personnels et réglages suivis
|
||||
conservés. Aucun essai Windows natif effectué. Les résultats sont suivis dans
|
||||
[Validation alpha.14](testing-alpha14.md). Les alpha.15/.16 poursuivront la validation de génération
|
||||
avant la bêta consacrée aux systèmes et à la progression. Les infectés, spitters,
|
||||
boomers, hunters, chargeurs et autres variantes restent réservés à la bêta.
|
||||
|
||||
### WG-18 — Lost City, ville compacte et réseaux souterrains — alpha.15
|
||||
|
||||
**Branche :** `codex/lost-city-interiors`.
|
||||
|
||||
**Contrat :** reprendre les bâtiments avec des pièces aménagées et une
|
||||
circulation lisible, puis rendre les équipements souterrains et franchissements
|
||||
réellement découvrables dans une petite ville dense et reliée.
|
||||
|
||||
- Premier chantier : plans intérieurs propres aux appartements, hôtels,
|
||||
bibliothèques, restaurants, casernes et halls ; seuil, portes, vestibule,
|
||||
couloirs, escaliers, sols, plafonds, chambres et détails d'usage.
|
||||
- Deuxième chantier en parallèle : égouts procéduraux, pool rooms et garages
|
||||
reliés par des passages, avec accès depuis la surface. Les portions exposées
|
||||
au vide exigent des chaînes ou des pylônes ancrés dans la roche, de longueur
|
||||
bornée. Préserver les eaux naturelles et contenir les bassins construits.
|
||||
- Troisième chantier : une grille urbaine compacte, avec lots, rues et carrefours
|
||||
jointifs. Les lots sans accès au réseau sont refusés ; les terrasses et
|
||||
dégagements restent dans l’emprise urbaine certifiée. Les ponts et viaducs
|
||||
prolongent ce réseau. Aucun équipement ne doit flotter sans appui.
|
||||
- Retour de l’essai anticipé : corriger les charnières, les dossiers des sièges
|
||||
et les lanternes ; contrôler les directions après rotation et leur appui sur
|
||||
des blocs complets. Une grande salle sèche accessible dans les tunnels prépare
|
||||
la future machine d’expansion, sans l’activer dans cet incrément.
|
||||
- Le biome `sanctuary:lost_city` est conservé : il est effectivement utilisé
|
||||
pour les parcelles urbaines, la décoration et les apparitions naturelles.
|
||||
- Nouvelle génération **15**, codec `sanctuary:island_v15`, journal propre
|
||||
`data/sanctuary-world-v15/expansions.json`. Les mondes 12/13/14 conservent leurs
|
||||
codecs, plans et rendus historiques, y compris les chunks encore non générés.
|
||||
Les nouvelles pièces ont un identifiant et des paramètres persistants propres.
|
||||
Aucun monde personnel n'est ouvert, converti ou régénéré pour cette livraison.
|
||||
- La reprise certifiée du vide conserve le contrat WG-17 dans le journal15,
|
||||
avec une identité de génération stricte ; aucun certificat14 n'autorise15.
|
||||
- Vérifier parcours, portes et lits, géométrie/supports, eau après ticks, témoins
|
||||
natifs des équipements, biomes et persistance après réouverture. Exécuter
|
||||
`check build assemblePack`, publier un artefact immuable, avancer packwiz et
|
||||
synchroniser deux fois la même instance Prism avec conservation des réglages.
|
||||
|
||||
**État : livré et vérifié.** Les tests détaillés et les scénarios natifs de
|
||||
création/réouverture passent, avec 76 pièces conservées. `check build assemblePack`
|
||||
final passe. Release, canal packwiz et même instance Prism à jour ; deux
|
||||
synchronisations réussies et 767 fichiers personnels/réglages suivis conservés.
|
||||
Voir [les preuves](testing-alpha15.md) et [le reçu](packwiz.md).
|
||||
|
||||
### WG-19 — Sanctuary habitée avant nous — alpha.16
|
||||
|
||||
**Branche :** `codex/sanctuary-ruins-alpha16`.
|
||||
|
||||
**Contrat :** conserver l’alpha.15 publiée et corriger les ruines pour que
|
||||
Sanctuary Island possède un quartier identifiable et des infrastructures
|
||||
profondes qui donnent l’impression d’une occupation antérieure.
|
||||
|
||||
- Une zone de ville est obligatoire sur la nouvelle île initiale ; les
|
||||
continents restent soumis à leur profil et au terrain. Garder une ville
|
||||
compacte, ses rues continues et la salle souterraine existante.
|
||||
- Remplacer les minuscules bassins des pool rooms par de grandes piscines
|
||||
souterraines, avec volumes lisibles, allées et accès découvrables.
|
||||
- Prolonger des égouts jusqu’à de vraies ouvertures extérieures, avec sources
|
||||
et écoulements natifs contrôlés ; les fuites vers le vide sont souhaitées.
|
||||
- Ajouter sous la ville des plateformes de parking profondes, des ouvertures
|
||||
et des circulations en descente. Les volumes, supports et coûts de génération
|
||||
restent bornés ; ne pas fabriquer une masse cylindrique sous l’île.
|
||||
- Varier les plans et usages entre étages ; sanitaires clos et raccordés aux
|
||||
cloisons, lits adossés à un mur, orientations et passages cohérents.
|
||||
- Nouvelle génération16, codecs et pièces propres : les mondes publiés jusqu’à15
|
||||
gardent leurs plans, rendus, journaux et chunks futurs. Aucun monde personnel
|
||||
n’est converti ou régénéré. Tests dans de nouveaux mondes de développement.
|
||||
- Le réacteur, les nouvelles familles de donjons, les reliques et leurs règles
|
||||
de butin restent à concevoir par questions à partir de la vision. Ne pas
|
||||
implémenter le réacteur ni activer la progression dans cet incrément.
|
||||
- Valider la présence et l’accès des piscines, les exutoires après ticks réels,
|
||||
les parcours, la variation des étages, la persistance et la compatibilité15.
|
||||
Exécuter `check build assemblePack`, publier l’alpha.16 et synchroniser la même
|
||||
instance Prism selon le canal packwiz et ses sauvegardes ciblées.
|
||||
|
||||
**État : livré et vérifié.** Création et
|
||||
réouverture natives réussies, 71 pièces conservées, piscine de 50 × 25 et
|
||||
parking à huit niveaux démontrés. Les six reliefs contrôlés ont une ville ; les
|
||||
équipements restent conditionnels. `check build assemblePack` passe et la
|
||||
compatibilité15 conserve les empreintes publiées. Voir [les preuves](testing-alpha16.md).
|
||||
Release et canal packwiz publiés ; même instance Prism synchronisée deux fois,
|
||||
avec les 767 fichiers personnels et réglages suivis conservés. Voir [le reçu](packwiz.md).
|
||||
Les choix des futurs réseaux et donjons sont consignés dans la vision.
|
||||
|
||||
### WG-20 — Réseaux oubliés, prototype réversible — alpha.17
|
||||
|
||||
**Branche :** `codex/structures-experiment-alpha17`.
|
||||
|
||||
**Contrat :** rendre explorables les trois familles validées : mines et ateliers,
|
||||
lieux étranges physiques dans l’île, infrastructures abandonnées. Les traces
|
||||
privilégient des installations utilisables et des chantiers interrompus.
|
||||
|
||||
- Nouveau générateur 17 et option « Structures expérimentales » dans le bouton
|
||||
natif Personnaliser. La taille et l’option sont enregistrées avec le nouveau
|
||||
monde ; aucune activation rétroactive ou suppression de structures existantes.
|
||||
- Conserver les codecs, pièces, plans et futurs chunks des générations publiées,
|
||||
dont 16. La ville 16 reste le socle de 17 ; les nouveaux réseaux disposent de
|
||||
leurs pièces natives, emprises protégées et calculs bornés propres.
|
||||
- Trois petits réseaux recherchés sur l’île initiale, avec accès de surface,
|
||||
salles, galeries et mobilier. Les continents ne reçoivent pas ce prototype.
|
||||
Les connexions entre réseaux seront évaluées après les premières implantations.
|
||||
- Ne pas implémenter de réacteur, progression, reliques ou règles de récompenses
|
||||
personnelles. Les coffres éventuels utilisent des tables vanilla différées.
|
||||
- Tester terrain/supports, connexions internes, accès, réglage sauvegardé,
|
||||
placement natif et réouverture. Livrer le pack et synchroniser l’instance
|
||||
existante selon le protocole packwiz. Donner graines et coordonnées d’essai.
|
||||
|
||||
**État : code et artefacts vérifiés, publication en cours.** Création/réouverture,
|
||||
option désactivée, compatibilité 16 et `check build assemblePack` passent.
|
||||
Retour arrière par branche/artefact et option désactivée
|
||||
pour un autre nouveau monde. Aucun retour binaire vers 16 dans une sauvegarde 17
|
||||
n’est déclaré compatible ; les mondes d’essai restent séparés.
|
||||
|
||||
## 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,654 @@
|
||||
{
|
||||
"ticket": "WG-12",
|
||||
"version": "0.1.0-alpha.11",
|
||||
"date": "2026-09-09",
|
||||
"protocol": {
|
||||
"machine": "Apple M1, 8 GiB RAM, macOS",
|
||||
"minecraft": "26.3-pre-2",
|
||||
"fabric_loader": "0.19.5",
|
||||
"fabric_api": "0.160.0+26.3",
|
||||
"baseline_source_commit": "a26b72f",
|
||||
"production_files_changed_in_baseline": 0,
|
||||
"runs_per_configuration": 1,
|
||||
"world": "fresh ignored development world per run",
|
||||
"workload": "289 synchronous FULL chunks in concentric rings around natural spawn, then 80 FULL chunks in an eastward corridor. Minecraft world creation timed separately from startup log.",
|
||||
"scope": "Server generation up to FULL. Does not establish ticking readiness, postprocessing completion, client rendering, FPS, multiplayer capacity or a guaranteed user loading time.",
|
||||
"baseline_scope_correction": "The raw alpha.10 report incorrectly says postprocessing is included; FULL alone does not guarantee this. Workload code is identical; alpha.11 corrects only that explanatory string.",
|
||||
"build_time_excluded": true,
|
||||
"comparison": "Only legacy20 versus legacy20 measures the algorithm gain. Grand10 changes geometry; its exploration timing cannot be used for that comparison.",
|
||||
"frozen_runtime_files": 272,
|
||||
"frozen_runtime_manifest_sha256": "1c2b433417a5d9e6a219e9ee19781574538d2167c774e14ed2bf254d277d4482",
|
||||
"baseline_runtime_manifest_sha256": "d4860072d1896f0dfd784a5876f1253367b7818a2023f1910ba1612b6f0e7103",
|
||||
"benchmark_source_sha256": "408711c97d65de63af4e0a1eac90e6edb5bf2df2b70c43772a3fdef7347d75d1"
|
||||
},
|
||||
"runs": {
|
||||
"alpha10_legacy20": {
|
||||
"version": "0.1.0-alpha.10",
|
||||
"players": 20,
|
||||
"seed": 0,
|
||||
"spawn": [
|
||||
0,
|
||||
252,
|
||||
0
|
||||
],
|
||||
"java": "25.0.3",
|
||||
"processors": 8,
|
||||
"max_heap_bytes": 2147483648,
|
||||
"spawn_area_ms": 310664.163542,
|
||||
"spawn_area_full_chunks": 289,
|
||||
"spawn_area_nonempty_chunks": 289,
|
||||
"rings": [
|
||||
{
|
||||
"elapsed_ms": 2.609,
|
||||
"cumulative_chunks": 1,
|
||||
"radius": 0
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 5468.709333,
|
||||
"cumulative_chunks": 9,
|
||||
"radius": 1
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 17601.560917,
|
||||
"cumulative_chunks": 25,
|
||||
"radius": 2
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 27397.566458,
|
||||
"cumulative_chunks": 49,
|
||||
"radius": 3
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 36562.853333,
|
||||
"cumulative_chunks": 81,
|
||||
"radius": 4
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 44462.775334,
|
||||
"cumulative_chunks": 121,
|
||||
"radius": 5
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 52423.80275,
|
||||
"cumulative_chunks": 169,
|
||||
"radius": 6
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 60416.705916,
|
||||
"cumulative_chunks": 225,
|
||||
"radius": 7
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 66303.128125,
|
||||
"cumulative_chunks": 289,
|
||||
"radius": 8
|
||||
}
|
||||
],
|
||||
"spawn_area_chunk_latency_ms": {
|
||||
"median": 993.3335,
|
||||
"p95": 1392.158958,
|
||||
"max": 2560.16575
|
||||
},
|
||||
"exploration_ms": 103141.834334,
|
||||
"exploration_full_chunks": 80,
|
||||
"exploration_steps": [
|
||||
{
|
||||
"elapsed_ms": 5146.88675,
|
||||
"chunk_x": 9
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 5087.495583,
|
||||
"chunk_x": 10
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 5229.782834,
|
||||
"chunk_x": 11
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 5046.559,
|
||||
"chunk_x": 12
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4810.5415,
|
||||
"chunk_x": 13
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 28755.95025,
|
||||
"chunk_x": 14
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4763.415834,
|
||||
"chunk_x": 15
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4772.209166,
|
||||
"chunk_x": 16
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4826.460208,
|
||||
"chunk_x": 17
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4857.054416,
|
||||
"chunk_x": 18
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4947.188167,
|
||||
"chunk_x": 19
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 5168.714916,
|
||||
"chunk_x": 20
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4897.048167,
|
||||
"chunk_x": 21
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 5286.23025,
|
||||
"chunk_x": 22
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4865.694458,
|
||||
"chunk_x": 23
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 4661.793875,
|
||||
"chunk_x": 24
|
||||
}
|
||||
],
|
||||
"exploration_chunk_latency_ms": {
|
||||
"median": 925.461875,
|
||||
"p95": 1273.829625,
|
||||
"max": 25131.068417
|
||||
},
|
||||
"regions_cached": 2,
|
||||
"passed": true,
|
||||
"world_creation_ms": 96766,
|
||||
"world_creation_plus_spawn_area_ms": 407430.163542,
|
||||
"raw_report_sha256": "d70c4ac49f37470d4db15b9200e26da8ac9d4d3b1b008e7ec03e67deafcc0f93",
|
||||
"log_sha256": "5b1df55f7a167003c089e80fe1fb824d087c137e3662bac84af3ae0b325b8392"
|
||||
},
|
||||
"alpha11_legacy20": {
|
||||
"version": "0.1.0-alpha.11",
|
||||
"players": 20,
|
||||
"seed": 0,
|
||||
"spawn": [
|
||||
0,
|
||||
252,
|
||||
0
|
||||
],
|
||||
"java": "25.0.3",
|
||||
"processors": 8,
|
||||
"max_heap_bytes": 2147483648,
|
||||
"spawn_area_ms": 43647.054375,
|
||||
"spawn_area_full_chunks": 289,
|
||||
"spawn_area_nonempty_chunks": 289,
|
||||
"rings": [
|
||||
{
|
||||
"cumulative_chunks": 1,
|
||||
"elapsed_ms": 1.965667,
|
||||
"radius": 0
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 9,
|
||||
"elapsed_ms": 870.4725,
|
||||
"radius": 1
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 25,
|
||||
"elapsed_ms": 2972.902334,
|
||||
"radius": 2
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 49,
|
||||
"elapsed_ms": 4480.355667,
|
||||
"radius": 3
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 81,
|
||||
"elapsed_ms": 5120.230375,
|
||||
"radius": 4
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 121,
|
||||
"elapsed_ms": 6290.425208,
|
||||
"radius": 5
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 169,
|
||||
"elapsed_ms": 7381.384417,
|
||||
"radius": 6
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 225,
|
||||
"elapsed_ms": 7839.425333,
|
||||
"radius": 7
|
||||
},
|
||||
{
|
||||
"cumulative_chunks": 289,
|
||||
"elapsed_ms": 8677.269209,
|
||||
"radius": 8
|
||||
}
|
||||
],
|
||||
"spawn_area_chunk_latency_ms": {
|
||||
"p95": 280.6,
|
||||
"median": 135.289625,
|
||||
"max": 699.612708
|
||||
},
|
||||
"exploration_ms": 14051.250167,
|
||||
"exploration_full_chunks": 80,
|
||||
"exploration_steps": [
|
||||
{
|
||||
"chunk_x": 9,
|
||||
"elapsed_ms": 750.152875
|
||||
},
|
||||
{
|
||||
"chunk_x": 10,
|
||||
"elapsed_ms": 680.650584
|
||||
},
|
||||
{
|
||||
"chunk_x": 11,
|
||||
"elapsed_ms": 729.180958
|
||||
},
|
||||
{
|
||||
"chunk_x": 12,
|
||||
"elapsed_ms": 725.223
|
||||
},
|
||||
{
|
||||
"chunk_x": 13,
|
||||
"elapsed_ms": 540.200125
|
||||
},
|
||||
{
|
||||
"chunk_x": 14,
|
||||
"elapsed_ms": 5554.177667
|
||||
},
|
||||
{
|
||||
"chunk_x": 15,
|
||||
"elapsed_ms": 473.982208
|
||||
},
|
||||
{
|
||||
"chunk_x": 16,
|
||||
"elapsed_ms": 471.05575
|
||||
},
|
||||
{
|
||||
"chunk_x": 17,
|
||||
"elapsed_ms": 513.109667
|
||||
},
|
||||
{
|
||||
"chunk_x": 18,
|
||||
"elapsed_ms": 533.284292
|
||||
},
|
||||
{
|
||||
"chunk_x": 19,
|
||||
"elapsed_ms": 503.983917
|
||||
},
|
||||
{
|
||||
"chunk_x": 20,
|
||||
"elapsed_ms": 513.126292
|
||||
},
|
||||
{
|
||||
"chunk_x": 21,
|
||||
"elapsed_ms": 508.7555
|
||||
},
|
||||
{
|
||||
"chunk_x": 22,
|
||||
"elapsed_ms": 587.331416
|
||||
},
|
||||
{
|
||||
"chunk_x": 23,
|
||||
"elapsed_ms": 467.628834
|
||||
},
|
||||
{
|
||||
"chunk_x": 24,
|
||||
"elapsed_ms": 494.771375
|
||||
}
|
||||
],
|
||||
"exploration_chunk_latency_ms": {
|
||||
"p95": 186.482333,
|
||||
"median": 103.648708,
|
||||
"max": 5153.6295
|
||||
},
|
||||
"regions_cached": 2,
|
||||
"passed": true,
|
||||
"world_creation_ms": 18888,
|
||||
"world_creation_plus_spawn_area_ms": 62535.054375,
|
||||
"raw_report_sha256": "4799180bd61ec4ffc93d6a1a7ccb5440eb880f7dc08a345429b77db10cc17ab0",
|
||||
"log_sha256": "a9347297608528e719d6780f3dd2f2588bb516e06ab771ca6da6ffdda404f7a4"
|
||||
},
|
||||
"alpha11_grand10": {
|
||||
"version": "0.1.0-alpha.11",
|
||||
"players": 10,
|
||||
"seed": 0,
|
||||
"spawn": [
|
||||
0,
|
||||
252,
|
||||
0
|
||||
],
|
||||
"java": "25.0.3",
|
||||
"processors": 8,
|
||||
"max_heap_bytes": 2147483648,
|
||||
"spawn_area_ms": 44291.159875,
|
||||
"spawn_area_full_chunks": 289,
|
||||
"spawn_area_nonempty_chunks": 289,
|
||||
"rings": [
|
||||
{
|
||||
"radius": 0,
|
||||
"cumulative_chunks": 1,
|
||||
"elapsed_ms": 42.387459
|
||||
},
|
||||
{
|
||||
"radius": 1,
|
||||
"cumulative_chunks": 9,
|
||||
"elapsed_ms": 1474.290584
|
||||
},
|
||||
{
|
||||
"radius": 2,
|
||||
"cumulative_chunks": 25,
|
||||
"elapsed_ms": 3180.279791
|
||||
},
|
||||
{
|
||||
"radius": 3,
|
||||
"cumulative_chunks": 49,
|
||||
"elapsed_ms": 4474.062208
|
||||
},
|
||||
{
|
||||
"radius": 4,
|
||||
"cumulative_chunks": 81,
|
||||
"elapsed_ms": 5043.704042
|
||||
},
|
||||
{
|
||||
"radius": 5,
|
||||
"cumulative_chunks": 121,
|
||||
"elapsed_ms": 6367.417708
|
||||
},
|
||||
{
|
||||
"radius": 6,
|
||||
"cumulative_chunks": 169,
|
||||
"elapsed_ms": 7285.252542
|
||||
},
|
||||
{
|
||||
"radius": 7,
|
||||
"cumulative_chunks": 225,
|
||||
"elapsed_ms": 7824.965875
|
||||
},
|
||||
{
|
||||
"radius": 8,
|
||||
"cumulative_chunks": 289,
|
||||
"elapsed_ms": 8577.641208
|
||||
}
|
||||
],
|
||||
"spawn_area_chunk_latency_ms": {
|
||||
"max": 665.612875,
|
||||
"p95": 274.9215,
|
||||
"median": 136.067375
|
||||
},
|
||||
"exploration_ms": 7684.81575,
|
||||
"exploration_full_chunks": 80,
|
||||
"exploration_steps": [
|
||||
{
|
||||
"elapsed_ms": 739.863,
|
||||
"chunk_x": 9
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 724.49625,
|
||||
"chunk_x": 10
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 720.754584,
|
||||
"chunk_x": 11
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 695.608958,
|
||||
"chunk_x": 12
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 522.1695,
|
||||
"chunk_x": 13
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 1112.798042,
|
||||
"chunk_x": 14
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 430.820667,
|
||||
"chunk_x": 15
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 416.082834,
|
||||
"chunk_x": 16
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 400.90425,
|
||||
"chunk_x": 17
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 414.387708,
|
||||
"chunk_x": 18
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 394.878458,
|
||||
"chunk_x": 19
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 393.643834,
|
||||
"chunk_x": 20
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 391.553625,
|
||||
"chunk_x": 21
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 279.306334,
|
||||
"chunk_x": 22
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 25.634541,
|
||||
"chunk_x": 23
|
||||
},
|
||||
{
|
||||
"elapsed_ms": 18.210167,
|
||||
"chunk_x": 24
|
||||
}
|
||||
],
|
||||
"exploration_chunk_latency_ms": {
|
||||
"max": 760.323917,
|
||||
"p95": 198.353916,
|
||||
"median": 85.88425
|
||||
},
|
||||
"regions_cached": 2,
|
||||
"passed": true,
|
||||
"world_creation_ms": 18376,
|
||||
"world_creation_plus_spawn_area_ms": 62667.159875,
|
||||
"raw_report_sha256": "5284e0ef88217f01d51f9040bf13f652de8276dc3f9844dc14569848b227d05a",
|
||||
"log_sha256": "3a4c84bb6ea1614f8fc9c85f472612750149996e2f7658c83afe398b43afe5f6"
|
||||
}
|
||||
},
|
||||
"same_geometry_reduction_percent": {
|
||||
"world_creation_plus_spawn_area_ms": 84.65134396742975,
|
||||
"spawn_area_ms": 85.95040577665497,
|
||||
"exploration_ms": 86.37676917641545
|
||||
},
|
||||
"targets": {
|
||||
"minimum_reduction_percent": 30,
|
||||
"grand_creation_plus_spawn_area_max_ms": 120000,
|
||||
"same_geometry_reduction_passed": true,
|
||||
"grand_generation_proxy_passed": true
|
||||
},
|
||||
"density_equivalence": {
|
||||
"comparison": "Float.floatToRawIntBits; no tolerance",
|
||||
"reference_commit": "a26b72f",
|
||||
"scope": "Real compiled scalar/bulk densities, 4 capacities x 3 seeds (5/20/100 compatibility and new10 using the same parameterized algorithm), signed coordinates, region/coast/height boundaries, context switches and concurrency; no block changes",
|
||||
"reference_source_sha256": "fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1",
|
||||
"profiles": [
|
||||
{
|
||||
"seed": 0,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 5,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "28ec59cba36174fd"
|
||||
},
|
||||
{
|
||||
"seed": 42,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 5,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "efd1e8084800ed5c"
|
||||
},
|
||||
{
|
||||
"seed": 8675309,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 5,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "91bea1db755d9aca"
|
||||
},
|
||||
{
|
||||
"seed": 0,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 10,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "b7bab667baf73361"
|
||||
},
|
||||
{
|
||||
"seed": 42,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 10,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "42aeaf160360e41c"
|
||||
},
|
||||
{
|
||||
"seed": 8675309,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 10,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "cb85f95333e040c0"
|
||||
},
|
||||
{
|
||||
"seed": 0,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 20,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "175524360755dd88"
|
||||
},
|
||||
{
|
||||
"seed": 42,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 20,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "4cc8aec467a2d164"
|
||||
},
|
||||
{
|
||||
"seed": 8675309,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 20,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "5a3f16e426e5a64e"
|
||||
},
|
||||
{
|
||||
"seed": 0,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 100,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "83cda17336772fa7"
|
||||
},
|
||||
{
|
||||
"seed": 42,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 100,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "e7aa7f52022cc6aa"
|
||||
},
|
||||
{
|
||||
"seed": 8675309,
|
||||
"bitwise_comparisons": 255678,
|
||||
"players": 100,
|
||||
"optimized_path_supported": true,
|
||||
"reference_density_digest": "de4b8f52d249a9bd"
|
||||
}
|
||||
],
|
||||
"concurrent_workers": 3,
|
||||
"concurrent_comparisons": 115668,
|
||||
"passed": true,
|
||||
"adversarial": {
|
||||
"aligned_scalar_path_preserved": true,
|
||||
"passed": true,
|
||||
"bitwise_comparisons": 122528,
|
||||
"concurrent_workers": 3,
|
||||
"repeated_cell_corner_batches": 1
|
||||
}
|
||||
},
|
||||
"persistence": {
|
||||
"default_players": 10,
|
||||
"profiles": [
|
||||
{
|
||||
"restored_settings": "sanctuary:population_5",
|
||||
"players": 5,
|
||||
"disk_round_trip": true,
|
||||
"preset": "sanctuary:sanctuary_5"
|
||||
},
|
||||
{
|
||||
"restored_settings": "sanctuary:population_10",
|
||||
"players": 10,
|
||||
"disk_round_trip": true,
|
||||
"preset": "sanctuary:sanctuary"
|
||||
},
|
||||
{
|
||||
"restored_settings": "sanctuary:population_20",
|
||||
"players": 20,
|
||||
"disk_round_trip": true,
|
||||
"preset": "sanctuary:sanctuary_20"
|
||||
},
|
||||
{
|
||||
"restored_settings": "sanctuary:population_100",
|
||||
"players": 100,
|
||||
"disk_round_trip": true,
|
||||
"preset": "sanctuary:sanctuary_100"
|
||||
}
|
||||
],
|
||||
"passed": true,
|
||||
"visible_presets": [
|
||||
"sanctuary:sanctuary_5",
|
||||
"sanctuary:sanctuary"
|
||||
],
|
||||
"seed": 0,
|
||||
"legacy_settings_preserved": "sanctuary:sanctuary_cavern",
|
||||
"omitted_density_capacity_preserved": 20
|
||||
},
|
||||
"limits": [
|
||||
"Single seed and one timed run per configuration; repeat to estimate variance.",
|
||||
"A new cold region still has a synchronous planning cost; observed legacy20 maximum exploration chunk latency is about 5.15 s.",
|
||||
"No personal saved world was opened or regenerated."
|
||||
],
|
||||
"engine_checks": {
|
||||
"5": {
|
||||
"mod_version": "0.1.0-alpha.11",
|
||||
"seed": 0,
|
||||
"settings": "sanctuary:population_5",
|
||||
"inspected_full_chunks": 110,
|
||||
"retained_water_blocks": 5656,
|
||||
"fluid_ticks": 1800,
|
||||
"tested_springs": 1,
|
||||
"tested_spills": 1,
|
||||
"tested_lava_falls": 1,
|
||||
"postprocessing_reentry_witnesses": 3,
|
||||
"passed": true,
|
||||
"scope": "Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test",
|
||||
"raw_report_sha256": "cfaa0a532b50399ab28808f51a871363b198a67d8c096ac13f7a011bd87ff9d0"
|
||||
},
|
||||
"10": {
|
||||
"mod_version": "0.1.0-alpha.11",
|
||||
"seed": 0,
|
||||
"settings": "sanctuary:population_10",
|
||||
"inspected_full_chunks": 125,
|
||||
"retained_water_blocks": 5923,
|
||||
"fluid_ticks": 1800,
|
||||
"tested_springs": 1,
|
||||
"tested_spills": 1,
|
||||
"tested_lava_falls": 1,
|
||||
"postprocessing_reentry_witnesses": 3,
|
||||
"passed": true,
|
||||
"scope": "Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test",
|
||||
"raw_report_sha256": "aaed5b38821197c479a8acb7f2a201c64f290666c039d0b15047340e403e028c"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"version": "0.1.0-alpha.12",
|
||||
"date": "2026-09-09",
|
||||
"minecraft": "26.3-pre-2",
|
||||
"java": 25,
|
||||
"generation": 12,
|
||||
"source_base_commit": "f816ffc6402fe31789f9677c2ee94328b3181329",
|
||||
"scope": "Local development tests only; no personal worlds, client rendering, live player teleport or multiplayer load.",
|
||||
"normalization": "world_directory fields replaced with <development_game_test_world>; all other diagnostic fields retained.",
|
||||
"artifact": {
|
||||
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.jar",
|
||||
"sha256": "f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9",
|
||||
"staged_pack_jar_identical": true,
|
||||
"contains_gametest_classes": false
|
||||
},
|
||||
"build": {
|
||||
"command": "./gradlew check build assemblePack",
|
||||
"passed": true,
|
||||
"legacy_profile": 10,
|
||||
"legacy_seed": 0,
|
||||
"required_engine_tests": 12,
|
||||
"fluid_ticks": 1800,
|
||||
"log_sha256": "dfedc75558596ce4413bd0466ec03645b2fa6853165510e9b8db0406d0958eb8"
|
||||
},
|
||||
"first_process_diagnostic_sha256": "90872c801cb04e3f0c78b47e0d38a0b45dd5d3e6d1e33ccc53c4359e4b407238",
|
||||
"reload_diagnostic_sha256": "1efe055e1179ce60741a0d8546618807fc136c0de77f9aff6a979cf88b2b1cdd",
|
||||
"fresh_world": {
|
||||
"seed": 42,
|
||||
"process_id": 2892,
|
||||
"generator_version": 12,
|
||||
"islands": [
|
||||
{
|
||||
"upper_biome": "minecraft:jungle",
|
||||
"climate": "tropical",
|
||||
"landing": [
|
||||
0,
|
||||
202,
|
||||
-800
|
||||
],
|
||||
"landing_biome": "minecraft:jungle",
|
||||
"parent": "sanctuary",
|
||||
"id": "alpha",
|
||||
"centre": [
|
||||
0,
|
||||
-800
|
||||
],
|
||||
"diameter": 64
|
||||
},
|
||||
{
|
||||
"upper_biome": "minecraft:desert",
|
||||
"climate": "arid",
|
||||
"landing": [
|
||||
544,
|
||||
198,
|
||||
-800
|
||||
],
|
||||
"landing_biome": "minecraft:desert",
|
||||
"parent": "alpha",
|
||||
"id": "beta",
|
||||
"centre": [
|
||||
544,
|
||||
-800
|
||||
],
|
||||
"diameter": 64
|
||||
}
|
||||
],
|
||||
"scope": "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.",
|
||||
"registered_command_permission_checks": true,
|
||||
"parent_witness": [
|
||||
-16,
|
||||
248,
|
||||
-16
|
||||
],
|
||||
"root_landing": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"journal_reloaded_regions": 3,
|
||||
"visited_void_refusal": {
|
||||
"chunk": [
|
||||
-50,
|
||||
50
|
||||
],
|
||||
"reason": "Expansion intersects a loaded or unloading chunk: [-61, 39]"
|
||||
},
|
||||
"native_structure": {
|
||||
"full_chunks": 12,
|
||||
"placed_block": "Block{minecraft:barrel}[facing=south,open=false]",
|
||||
"structure": "minecraft:abandoned_camp_forest",
|
||||
"mode": "natural candidate accepted by production ExpansionStructures, native decoration",
|
||||
"container": [
|
||||
28,
|
||||
247,
|
||||
73
|
||||
],
|
||||
"loot_table": "minecraft:barrels/abandoned_camp_barrel"
|
||||
},
|
||||
"restart_snapshot": {
|
||||
"world_directory": "<development_game_test_world>",
|
||||
"gold_witness": [
|
||||
544,
|
||||
204,
|
||||
-800
|
||||
],
|
||||
"journal_sha256": "d7de79838744624e98ce26997ddc1daecb7bd3334bd9bf0e1ad31aa5eb7e27c3",
|
||||
"islands": [
|
||||
{
|
||||
"id": "sanctuary",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "temperate",
|
||||
"diameter": 512,
|
||||
"center_x": 0,
|
||||
"center_z": 0,
|
||||
"seed": 42,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "alpha",
|
||||
"parent": "sanctuary",
|
||||
"direction": "north",
|
||||
"climate": "tropical",
|
||||
"diameter": 64,
|
||||
"center_x": 0,
|
||||
"center_z": -800,
|
||||
"seed": 390175152143173586,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
},
|
||||
{
|
||||
"id": "beta",
|
||||
"parent": "alpha",
|
||||
"direction": "east",
|
||||
"climate": "arid",
|
||||
"diameter": 64,
|
||||
"center_x": 544,
|
||||
"center_z": -800,
|
||||
"seed": 6281303726624369227,
|
||||
"relief": "natural",
|
||||
"status": "ready",
|
||||
"generation": 12
|
||||
}
|
||||
],
|
||||
"generator_codec": {
|
||||
"biome_source": {
|
||||
"biomes": [
|
||||
"minecraft:the_void",
|
||||
"minecraft:forest",
|
||||
"minecraft:birch_forest",
|
||||
"minecraft:flower_forest",
|
||||
"minecraft:plains",
|
||||
"minecraft:meadow",
|
||||
"minecraft:taiga",
|
||||
"minecraft:snowy_taiga",
|
||||
"minecraft:snowy_plains",
|
||||
"minecraft:grove",
|
||||
"minecraft:ice_spikes",
|
||||
"minecraft:frozen_peaks",
|
||||
"minecraft:old_growth_spruce_taiga",
|
||||
"minecraft:dark_forest",
|
||||
"minecraft:swamp",
|
||||
"minecraft:pale_garden",
|
||||
"minecraft:jungle",
|
||||
"minecraft:sparse_jungle",
|
||||
"minecraft:bamboo_jungle",
|
||||
"minecraft:mangrove_swamp",
|
||||
"minecraft:savanna",
|
||||
"minecraft:savanna_plateau",
|
||||
"minecraft:desert",
|
||||
"minecraft:badlands",
|
||||
"minecraft:eroded_badlands",
|
||||
"minecraft:wooded_badlands",
|
||||
"minecraft:windswept_hills",
|
||||
"minecraft:windswept_gravelly_hills",
|
||||
"minecraft:stony_peaks",
|
||||
"minecraft:dappled_forest",
|
||||
"minecraft:cherry_grove",
|
||||
"minecraft:lush_caves",
|
||||
"minecraft:dripstone_caves",
|
||||
"minecraft:sulfur_caves"
|
||||
]
|
||||
},
|
||||
"type": "sanctuary:expansion_lab"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"point": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"id": "sanctuary",
|
||||
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
|
||||
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
|
||||
"base_height": 242
|
||||
},
|
||||
{
|
||||
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"point": [
|
||||
0,
|
||||
202,
|
||||
-800
|
||||
],
|
||||
"id": "alpha",
|
||||
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
|
||||
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
|
||||
"base_height": 202
|
||||
},
|
||||
{
|
||||
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"point": [
|
||||
544,
|
||||
198,
|
||||
-800
|
||||
],
|
||||
"id": "beta",
|
||||
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
|
||||
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
|
||||
"base_height": 198
|
||||
}
|
||||
],
|
||||
"world_flushed_before_shutdown": true
|
||||
},
|
||||
"native_structure_starts_seen": 1,
|
||||
"structure_start_chunks_inspected": 106,
|
||||
"ticks": 1390,
|
||||
"elapsed_ms": 43123.278916,
|
||||
"passed": true
|
||||
},
|
||||
"reloaded_world": {
|
||||
"seed": 42,
|
||||
"process_id": 2975,
|
||||
"scope": "A second Java process opens the same saved development chunks and expansion journal, and verifies a gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client world-selection test or a multiplayer/teleportation test.",
|
||||
"previous_process_id": 2892,
|
||||
"world_directory": "<development_game_test_world>",
|
||||
"gold_witness": [
|
||||
544,
|
||||
204,
|
||||
-800
|
||||
],
|
||||
"samples": [
|
||||
{
|
||||
"point": [
|
||||
-16,
|
||||
242,
|
||||
-16
|
||||
],
|
||||
"id": "sanctuary",
|
||||
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
|
||||
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
|
||||
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
|
||||
"base_height": 242,
|
||||
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485"
|
||||
},
|
||||
{
|
||||
"point": [
|
||||
0,
|
||||
202,
|
||||
-800
|
||||
],
|
||||
"id": "alpha",
|
||||
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
|
||||
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
|
||||
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
|
||||
"base_height": 202,
|
||||
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b"
|
||||
},
|
||||
{
|
||||
"point": [
|
||||
544,
|
||||
198,
|
||||
-800
|
||||
],
|
||||
"id": "beta",
|
||||
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
|
||||
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
|
||||
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
|
||||
"base_height": 198,
|
||||
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60"
|
||||
}
|
||||
],
|
||||
"regions": 3,
|
||||
"native_container": [
|
||||
28,
|
||||
247,
|
||||
73
|
||||
],
|
||||
"saved_generator_codec_decoded": true,
|
||||
"passed": true
|
||||
},
|
||||
"earlier_seed_0_attempt": {
|
||||
"scope": "Earlier development run before final cache and error-message fixes. Expansion phases completed; positive loot witness failed.",
|
||||
"native_candidate_sites": 11,
|
||||
"no_surface": 9,
|
||||
"insufficient_foundation": 2,
|
||||
"accepted_structures": 0,
|
||||
"structure_proof_passed": false
|
||||
},
|
||||
"unverified": [
|
||||
"Forced crash during chunk preparation",
|
||||
"Normal client world selection and rendering",
|
||||
"Real player teleports and multiplayer",
|
||||
"Visual quality of every climate/relief and 128/256/512 formats",
|
||||
"Every village and temple variant"
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
+132
-38
@@ -1,15 +1,53 @@
|
||||
# Climats, expansion et ressources
|
||||
|
||||
Ce document fixe les décisions de conception pour la suite de Sanctuary. Le
|
||||
registre persistant des continents, leur déblocage et leur génération dans les
|
||||
huit directions restent à implémenter. Le relevé de ressources décrit plus bas
|
||||
est un outil de développement séparé, exécuté dans le monde jetable des GameTests.
|
||||
Ce document distingue les outils de génération et la progression collective
|
||||
encore à construire. 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é.
|
||||
|
||||
## Les huit directions climatiques
|
||||
## Taille initiale et extensions — alpha.13
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Sanctuary Island constitue le point de départ **tempéré, légèrement humide**.
|
||||
Le nord conduit vers le froid, le sud vers le chaud, l'ouest vers le sec et
|
||||
l'est vers l'humide. Les diagonales combinent ces tendances.
|
||||
La boussole climatique est une convention facultative : nord froid, sud chaud,
|
||||
ouest sec et est humide. Les diagonales combinent ces tendances. **La direction
|
||||
géographique ne contraint plus le climat.** Les joueurs pourront choisir de
|
||||
prolonger une voie climatique ou de bifurquer vers d’autres découvertes.
|
||||
|
||||
| Direction | Axe Minecraft depuis Sanctuary | Tendance climatique |
|
||||
| --- | --- | --- |
|
||||
@@ -22,7 +60,11 @@ l'est vers l'humide. Les diagonales combinent ces tendances.
|
||||
| Ouest | `−X` | Sec |
|
||||
| Nord-ouest | `−X, −Z` | Froid et sec |
|
||||
|
||||
Ces directions définissent des familles de climats, pas huit biomes uniformes.
|
||||
Ces associations sont les raccourcis par défaut, pas une loi du monde.
|
||||
Une branche conserve son parent, sa direction et son climat explicite. Plusieurs
|
||||
couronnes peuvent donc proposer des biomes nouveaux au nord comme au sud.
|
||||
Le point d’origine reste enregistré même si un joueur déplace son lit ou le spawn.
|
||||
Les familles de climats ne sont pas des biomes uniformes.
|
||||
Le relief, l'altitude, les variations locales et la graine doivent conserver
|
||||
des géographies distinctes à l'intérieur de chaque continent. Les amplitudes
|
||||
de température et d'humidité, leur évolution avec la distance et les seuils
|
||||
@@ -30,18 +72,35 @@ de sélection des biomes feront partie du ticket de génération des continents.
|
||||
Les ressources et cultures futures, notamment celles d'It's Alive !, pourront
|
||||
ainsi demander l'exploration de plusieurs climats.
|
||||
|
||||
L'île initiale utilise encore un biome de départ spécifique. Une température
|
||||
ou une humidité du biome ne constitue pas à elle seule une carte climatique
|
||||
directionnelle : cette carte et la sélection des biomes des continents restent
|
||||
des travaux distincts.
|
||||
L’alpha.5 proposait cinq biomes tempérés dont Dappled Forest. L’alpha.6 réserve
|
||||
ce dernier aux futurs continents et donne à l’île une palette plus douce de
|
||||
prairies, prairies fleuries, bosquets, corniches moussues et zones rocheuses.
|
||||
La répartition tient compte de l’altitude, y compris dans les strates inférieures.
|
||||
La variation locale et l’humidité dépendent de la graine ; elles ne
|
||||
constituent pas encore le champ de recherche directionnel des continents.
|
||||
|
||||
L’alpha.7 densifie les forêts de chênes et de bouleaux en surface et donne
|
||||
aux corniches intérieures humides des chênes noirs et des champignons.
|
||||
Des champignons vanilla peuvent aussi se rencontrer en surface. Les
|
||||
clairières fleuries et les affleurements restent présents. Les essences rares
|
||||
et un éventuel arbre remarquable dépendent du terrain ; chaque graine n’est
|
||||
pas tenue de fournir tous les bois. Les minerais de départ peuvent affleurer,
|
||||
avec des petits filons et sans correction des stocks après comptage.
|
||||
|
||||
## Eau de surface et exploration
|
||||
|
||||
L'île de départ doit pouvoir accueillir naturellement des étangs et lacs de
|
||||
surface, des berges propices à la canne à sucre et, lorsque le relief s'y prête,
|
||||
des rivières. L'eau s'inscrit dans les creux et les chemins du terrain existant.
|
||||
Les rivières flottantes et les cascades doivent tenir compte de la continuité
|
||||
de leur lit et du vide sous les îles.
|
||||
Les bassins gardent un fond et des berges fermés. Une grande rivière reste
|
||||
facultative : elle doit suivre le relief et former un parcours lisible avec
|
||||
source, chute et bassin. Ses exutoires peuvent laisser couler l’eau dans le vide.
|
||||
Sans parcours suffisamment long, l’île conserve seulement ses petits bassins.
|
||||
L’alpha.6 livre cette variante à bassins seuls ; la grande rivière reste à développer.
|
||||
L’alpha.5 ajoute séparément quelques sources sortant d’une niche rocheuse
|
||||
naturelle : leurs cascades peuvent descendre dans le vide et servir à remonter
|
||||
verticalement. Cet écoulement est volontaire ; il ne doit pas entraîner des
|
||||
fuites sur toutes les rives.
|
||||
|
||||
Le contrat ne demande ni un bassin souterrain imposé pour obtenir un stock
|
||||
d'eau, ni une cuvette ajoutée systématiquement à une position fixe. Une surface
|
||||
@@ -76,12 +135,31 @@ rapports de continents devront conserver leur propre graine, version, emprise
|
||||
et état de génération, afin d'additionner seulement des régions connues et
|
||||
disjointes lorsque le besoin économique sera défini.
|
||||
|
||||
## Relevé de développement disponible
|
||||
## Ressources de départ — depuis l’alpha.5
|
||||
|
||||
L’île vise les premières étapes de survie, les outils, l’enchantement et la
|
||||
redstone. Les sept minerais (charbon, fer, cuivre, or, redstone, lapis et diamant)
|
||||
utilisent des petites veines à des altitudes compatibles avec le terrain relevé.
|
||||
Le charbon et le fer ne reprennent plus leurs fréquences vanilla beaucoup trop
|
||||
généreuses dans cette géométrie. Une petite réserve de lave peut apparaître dans une niche rocheuse accessible
|
||||
pour les premières productions et l’accès au Nether. Le bois, la pierre, les animaux et les cultures
|
||||
renouvelables conservent leur fonctionnement Minecraft : ce réglage des minerais
|
||||
ne constitue pas une interdiction des grandes constructions.
|
||||
|
||||
Les placements restent probabilistes, sans quota de rattrapage après comptage.
|
||||
Une graine peut donc manquer d’une ressource ; la présence de tout le parcours
|
||||
Minecraft n’est pas garantie. Les structures donnant accès à l’End et leur lien
|
||||
avec l’expansion restent à concevoir.
|
||||
|
||||
## 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
|
||||
@@ -92,26 +170,42 @@ n'est inclus dans ces comptes de blocs.
|
||||
|
||||
```sh
|
||||
# Contrôle standard : neuf chunks inspectés, total local uniquement.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=sample --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryResourceSurvey=sample --console=plain
|
||||
|
||||
# Relevé de toute l'enveloppe déclarée de Sanctuary, sur un monde jetable neuf.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
|
||||
|
||||
# Désactiver seulement le relevé pour un autre diagnostic.
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=off --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryResourceSurvey=off --console=plain
|
||||
```
|
||||
|
||||
Les modes sont transmis à la propriété JVM de test
|
||||
`sanctuary.test.resourceSurvey`. Le mode `island` couvre **1 444 chunks** :
|
||||
`X` et `Z` de **−304 inclus à 304 exclu**, correspondant à l'enveloppe de terrain
|
||||
de rayon 288 et une marge de décoration de 16 blocs. Un halo d'un chunk porte
|
||||
la préparation à **1 600 chunks FULL** ; seuls les 1 444 chunks demandés sont
|
||||
comptés. Ce relevé complet est plus coûteux et ne s'exécute pas par défaut.
|
||||
La marge devra être revue si une future génération peut produire des structures
|
||||
ou décorations au-delà de cette enveloppe.
|
||||
`sanctuary.test.resourceSurvey`. La propriété Gradle `-PsanctuaryTestPlayers`
|
||||
choisit le profil interne. L’alpha.11 utilise `5` pour Petit et `10` pour Grand,
|
||||
ce dernier par défaut ; `20` et `100` servent à la compatibilité historique.
|
||||
Le mode `island` calcule 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 l’ancien Grand alpha.11 :
|
||||
|
||||
| Profil | Chunks comptés par `island` | Chunks FULL préparés avec halo |
|
||||
| --- | --- | --- |
|
||||
| 5 | 1 444 | 1 600 |
|
||||
| 20 | 4 900 | 5 184 |
|
||||
| 100 | 22 500 | 23 104 |
|
||||
|
||||
Un relevé de toute l’emprise peut être très coûteux et ne s’exécute pas par
|
||||
défaut. La marge devra être revue si une future génération produit des
|
||||
structures ou décorations qui la dépassent. Les diagnostics alpha.10 courants
|
||||
restent des inspections ciblées ; leurs nombres de minerais ne sont pas ceux
|
||||
d’un relevé `island` complet.
|
||||
|
||||
Les nouveaux fichiers sont nommés
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/resource-survey-players-<capacité>-seed-<graine>-<mode>.json`.
|
||||
Sur un générateur historique, le nom émis par l’outil actuel porte `legacy`.
|
||||
Les anciens chemins `resource-survey-seed-…` cités plus bas désignent uniquement
|
||||
les relevés archivés de leurs versions respectives.
|
||||
|
||||
Le JSON est écrit dans
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/resource-survey-seed-<graine>-<mode>.json`.
|
||||
Il contient :
|
||||
|
||||
- La graine, la version Minecraft, le preset et les réglages de génération,
|
||||
@@ -187,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.
|
||||
|
||||
+278
-3
@@ -13,13 +13,242 @@ 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.16 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.16](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.16)
|
||||
est publiée depuis le commit source `98fc13c`. Le canal stable utilise cette
|
||||
version, au commit `927c862`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.16/Sanctuary-0.1.0-alpha.16.mrpack)
|
||||
et ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
5504018c46300accf01478dbc8bfb8e216395a5435cf7d7f501005cbc65b8e43
|
||||
```
|
||||
|
||||
`check build assemblePack`, les huit GameTests et les scénarios natifs de
|
||||
création/réouverture passent. Six reliefs contrôlés possèdent une ville ; les
|
||||
grands équipements souterrains restent conditionnés par les emplacements
|
||||
admissibles trouvés dans le budget de génération. Le témoin Moyen, graine 0,
|
||||
contient la piscine de 50 × 25, huit niveaux de parking, la grande salle sèche
|
||||
et un égout ouvert avec écoulement vérifié. Voir [les preuves et coordonnées](testing-alpha16.md).
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism Mac
|
||||
réussissent. Un seul JAR Sanctuary alpha.16 est actif ; les **767 fichiers
|
||||
personnels et réglages suivis** conservent leurs hashes. Aucune sauvegarde
|
||||
personnelle n’a été ouverte, convertie ou régénérée. Le dossier reste
|
||||
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
|
||||
|
||||
Le JAR15 et le suivi packwiz sont sauvegardés dans
|
||||
`sanctuary-backups/before-0.1.0-alpha.16/`. Les reçus locaux ignorés sont
|
||||
`build/alpha16-isolated-validation.json` et `build/alpha16-prism-validation.json` ;
|
||||
les empreintes avant/après figurent aussi dans la sauvegarde ciblée de l’instance.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour les nouveautés16. Les mondes publiés
|
||||
jusqu’à15 gardent leur génération, y compris leurs futurs chunks. La machine
|
||||
d’expansion, le réacteur et les nouvelles familles de donjons restent prévus.
|
||||
Aucun accès à l’instance Windows n’a eu lieu pour cette livraison.
|
||||
|
||||
## Alpha.15 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.15](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.15)
|
||||
est publiée depuis le commit source `dd9c795`. Le canal stable utilisait alors cette
|
||||
version, au commit `6cf54ee`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.15/Sanctuary-0.1.0-alpha.15.mrpack)
|
||||
et ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
52253f386d30530cc718d4b90ac6953cd4790b2125c78b74599eebff944824f2
|
||||
```
|
||||
|
||||
`check build assemblePack`, les huit GameTests et les scénarios natifs de
|
||||
création/réouverture passent. Deux synchronisations isolées puis deux dans la
|
||||
même instance Prism Mac réussissent. Un seul JAR Sanctuary alpha.15 est actif ;
|
||||
les **767 fichiers personnels et réglages suivis** conservent leurs hashes.
|
||||
Aucune sauvegarde personnelle n’a été ouverte, convertie ou régénérée.
|
||||
Le dossier reste `Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
|
||||
|
||||
Le JAR d’essai15 ajouté manuellement avant publication est conservé dans
|
||||
`sanctuary-backups/alpha15-manual-preview/`, hors de `mods/`. Le JAR14 et le
|
||||
suivi packwiz sont sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.15/`.
|
||||
Les reçus locaux ignorés sont `build/alpha15-isolated-validation.json` et
|
||||
`build/alpha15-prism-validation.json` ; les empreintes avant/après figurent
|
||||
aussi dans la sauvegarde ciblée de l’instance.
|
||||
|
||||
Créer un **nouveau monde Sanctuary** pour la ville compacte et ses sous-sols,
|
||||
y compris après l’essai anticipé15. Les mondes publiés12/13/14 gardent leurs
|
||||
générateurs. La salle d’expansion est présente lorsque le terrain le permet ;
|
||||
sa future machine et les cartes préremplies ne sont pas implémentées.
|
||||
Voir [les preuves, coordonnées et limites](testing-alpha15.md).
|
||||
Aucun accès à l’instance Windows n’a eu lieu pour cette livraison.
|
||||
|
||||
## Alpha.14 publiée — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.14](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.14)
|
||||
est publiée depuis le commit source `1c5c2c2`. Le canal stable utilisait alors cette
|
||||
version, au commit `dabf57e`. Le JAR, le
|
||||
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.14/Sanctuary-0.1.0-alpha.14.mrpack)
|
||||
et le ZIP d’amorçage Prism ont été vérifiés par téléchargement public.
|
||||
SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
1c517a13e1151501c6f4974d1bc00faedf67aaecd9bf58f34ac4a655d09dc6ef
|
||||
```
|
||||
|
||||
`check build assemblePack` et les huit GameTests passent, ainsi que les
|
||||
scénarios natifs Lost City et reprise du vide après réouverture. Deux
|
||||
synchronisations isolées puis deux dans la même instance Prism Mac ont réussi.
|
||||
Le second passage conserve les fichiers gérés à l’identique ; un seul JAR
|
||||
Sanctuary alpha.14 est actif. Les **713 fichiers personnels et réglages suivis**
|
||||
conservent leurs hashes. Aucune sauvegarde personnelle n’a été ouverte,
|
||||
convertie ou régénérée. Le dossier reste `Sanctuary-0.1.0-alpha.1`.
|
||||
|
||||
Sauvegarde ciblée dans cette instance :
|
||||
`sanctuary-backups/before-0.1.0-alpha.14/`. Reçus locaux ignorés :
|
||||
`build/alpha14-isolated-validation.json` et `build/alpha14-prism-validation.json`.
|
||||
Voir le [contrat de génération et les preuves](testing-alpha14.md).
|
||||
La machine Windows n’a pas été accessible pour un essai natif ou une installation.
|
||||
|
||||
La livraison conserve la même instance et ses sauvegardes. Pour les nouveautés
|
||||
Lost City, créer ensuite un **nouveau monde Sanctuary** de génération 14 ;
|
||||
mettre à jour le pack ne convertit pas les mondes 12/13. Dans ce nouveau monde
|
||||
uniquement, une expansion réservée sur du vide certifié peut demander de fermer
|
||||
et rouvrir le monde avant sa préparation. Ce mécanisme ne fait pas partie de
|
||||
l’installateur packwiz et n’autorise pas à supprimer un monde ou son journal.
|
||||
|
||||
## Correctif alpha.13.1 publié — 9 septembre 2026
|
||||
|
||||
[Sanctuary 0.1.0-alpha.13.1](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.13.1)
|
||||
est publié depuis le commit source `b19e93d`. Le canal stable utilisait alors cette
|
||||
version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.13.1/Sanctuary-0.1.0-alpha.13.1.mrpack)
|
||||
et le ZIP d’amorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
b340dfc58f3d2d1a2a375dc527ac6721c61430600622046db26228e0da46061b
|
||||
```
|
||||
|
||||
Deux synchronisations isolées puis deux dans la même instance Prism Mac ont
|
||||
réussi. Le second passage conserve les fichiers gérés à l’identique ; un seul
|
||||
JAR Sanctuary alpha.13.1 est actif. Les **713 fichiers personnels et réglages
|
||||
suivis** conservent leurs hashes. Aucune sauvegarde personnelle n’a été ouverte,
|
||||
convertie ou régénérée. Dossier de l’instance conservé : `Sanctuary-0.1.0-alpha.1`.
|
||||
Sauvegarde ciblée : `sanctuary-backups/before-0.1.0-alpha.13.1/` ; reçus locaux
|
||||
`build/alpha131-isolated-validation.json` et `build/alpha131-prism-validation.json`.
|
||||
|
||||
Le correctif évite la synchronisation de dossier non prise en charge sous
|
||||
Windows. **Aucun nouveau monde requis :** fermer Minecraft, mettre l’instance
|
||||
à jour puis rouvrir le même monde. Les réservations enregistrées reprennent
|
||||
après vérification. La machine Windows n’a pas été accessible pour installation
|
||||
ou essai natif : le [guide](alpha13-worldgen.md#correctif-alpha131--expansions-sous-windows)
|
||||
décrit aussi le remplacement du seul JAR Sanctuary pour une instance issue du
|
||||
MRpack sans auto-update. Voir les [preuves et limites](testing.md#correctif-alpha131--journal-dexpansion-sous-windows).
|
||||
|
||||
## 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 utilisait alors
|
||||
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 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 :
|
||||
|
||||
```text
|
||||
f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9
|
||||
```
|
||||
|
||||
Le véritable installateur packwiz a réussi deux passages dans un dossier de
|
||||
développement neuf, puis deux dans la même instance Sanctuary Beta. Le second
|
||||
passage conserve les hashes des fichiers gérés. Un seul JAR Sanctuary alpha.12
|
||||
est actif ; les **466 fichiers personnels et réglages suivis** conservent
|
||||
leurs hashes, y compris les sauvegardes, mods tiers et configurations. Aucun
|
||||
monde n’a été ouvert, converti ou régénéré par cette livraison.
|
||||
|
||||
La sauvegarde ciblée est dans `sanctuary-backups/before-0.1.0-alpha.12/` de
|
||||
l’instance existante. Les reçus locaux ignorés sont
|
||||
`build/alpha12-isolated-validation.json` et `build/alpha12-prism-validation.json`.
|
||||
Le [guide](alpha12-laboratory.md) décrit le nouveau preset **Sanctuary — Laboratoire** :
|
||||
créer un nouveau monde de ce type pour essayer les expansions. Les anciennes
|
||||
parties et les presets Petit/Grand gardent leur génération.
|
||||
|
||||
## Livrer une mise à jour
|
||||
|
||||
Livraison précédente, vérifiée le 9 septembre 2026 :
|
||||
[Sanctuary 0.1.0-alpha.11](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.11),
|
||||
depuis le commit source `f30a350`. Le JAR, le `.mrpack` et le ZIP d’amorçage
|
||||
Prism sont publiés et leurs téléchargements publics vérifiés. Le canal stable
|
||||
pointait alors sur cette version. SHA-256 du JAR :
|
||||
|
||||
```text
|
||||
f075ad8a0b97cd53e4891273adbee5f79f04ab3984442950d5d85f58d2ded589
|
||||
```
|
||||
|
||||
Le véritable installateur packwiz a réussi deux fois dans un dossier de
|
||||
développement neuf, puis deux fois dans l’instance Sanctuary Beta existante.
|
||||
Le second passage laisse les fichiers gérés identiques. Un seul JAR Sanctuary
|
||||
alpha.11 est actif ; les 364 fichiers personnels et réglages suivis conservent
|
||||
leurs hashes, y compris les fichiers des mods tiers et leurs configurations.
|
||||
L’ancien JAR et les manifestes locaux sont sauvegardés hors de `mods/`, dans
|
||||
`sanctuary-backups/before-0.1.0-alpha.11/` de cette même instance.
|
||||
Aucun monde existant n’a été ouvert, converti ou régénéré par la livraison.
|
||||
Les reçus locaux ignorés sont `build/alpha11-isolated-validation.json` et
|
||||
`build/alpha11-prism-validation.json` ; les listes de hashes avant et après
|
||||
figurent dans le dossier de sauvegarde. Les preuves du build, des deux tailles
|
||||
et de performance figurent dans [Validation](testing.md).
|
||||
|
||||
Après les vérifications, le commit et le push de la branche du ticket, le script
|
||||
de publication réalise les étapes de release et de canal ci-dessous :
|
||||
|
||||
```sh
|
||||
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.3.mrpack
|
||||
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.12.mrpack
|
||||
```
|
||||
|
||||
Les options sont facultatives. Ce script **publie** sur le Git configuré dans
|
||||
@@ -38,7 +267,7 @@ La procédure complète, également utilisable manuellement :
|
||||
3. Préparer les métadonnées de la livraison, avec l'URL exacte du futur JAR :
|
||||
|
||||
```sh
|
||||
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.3/sanctuary-0.1.0-alpha.3.jar
|
||||
python3 scripts/pack.py release https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12/sanctuary-0.1.0-alpha.12.jar
|
||||
```
|
||||
|
||||
4. Pousser le commit source vérifié. Créer une release Gitea correspondant à ce
|
||||
@@ -115,10 +344,56 @@ n'autorisent pas à supprimer ou régénérer un monde.
|
||||
|
||||
La hauteur de 384 blocs, le dessous sculpté et le ciel de l'alpha.3 se testent
|
||||
dans un **nouveau monde** avec le preset **Sanctuary**. Les anciens mondes gardent
|
||||
leurs identifiants de génération et leurs hauteurs. Les détails des versions de génération sont dans
|
||||
leurs identifiants de génération et leurs hauteurs. L’hydrologie de l’alpha.4
|
||||
nécessite elle aussi un nouveau monde Sanctuary ; la mise à jour de l’instance
|
||||
n’ajoute pas d’eau aux sauvegardes existantes. Les nouvelles plages, cascades,
|
||||
biomes et petits filons de l’alpha.5 utilisent à leur tour un identifiant
|
||||
séparé : créer un nouveau monde Sanctuary pour les découvrir. Les détails des versions de génération sont dans
|
||||
[Génération](worldgen.md). Les changements de version Minecraft ou de format
|
||||
de sauvegarde demandent leur propre ticket et contrat de migration.
|
||||
|
||||
L’alpha.6 utilise à son tour `sanctuary:sanctuary_layered` pour ses biomes par
|
||||
altitude et sa nouvelle hydrologie. Créer un nouveau monde Sanctuary ; les
|
||||
sauvegardes alpha.5 conservent notamment leur palette avec Dappled Forest.
|
||||
|
||||
L’alpha.7 sélectionne `sanctuary:sanctuary_woodland` pour ses forêts, ses
|
||||
roches variées et ses minerais affleurants. Les mondes alpha.6 gardent
|
||||
`sanctuary:sanctuary_layered` ; aucune régénération de chunks n’est effectuée.
|
||||
|
||||
L’alpha.8 sélectionne `sanctuary:sanctuary_rift` pour les failles, les grandes
|
||||
rivières, les champignons géants et la géologie des profondeurs. Créer un
|
||||
nouveau monde Sanctuary pour les découvrir. La même instance Prism reçoit
|
||||
le nouveau JAR par le canal stable ; ses sauvegardes et réglages sont conservés.
|
||||
Les mondes alpha.7 et antérieurs gardent leurs paramètres de génération.
|
||||
|
||||
L’alpha.9 utilise `sanctuary:sanctuary_cavern` pour les cavités luxuriantes,
|
||||
les géodes, les geysers et les terrasses d’eau. Un **nouveau monde Sanctuary**
|
||||
est nécessaire pour essayer cet incrément. Les mondes
|
||||
alpha.8 conservent `sanctuary:sanctuary_rift` ; leur contenu n’est pas retouché
|
||||
par une mise à jour de la même instance Prism. Les anciennes sauvegardes et
|
||||
les réglages ont été conservés lors des deux synchronisations de livraison.
|
||||
|
||||
L’alpha.10 propose des choix de taille pour 5, 20 et 100 joueurs, avec
|
||||
20 par défaut. Ils s’appliquent à un **nouveau monde**. Les profils sauvegardés `sanctuary:population_5`,
|
||||
`sanctuary:population_20` et `sanctuary:population_100` sont séparés des
|
||||
anciens réglages, notamment `sanctuary:sanctuary_cavern` de l’alpha.9.
|
||||
La mise à jour de la même instance Prism n’agrandit aucune île
|
||||
existante, ne modifie pas les paramètres de génération enregistrés et ne
|
||||
nécessite pas de réimporter le ZIP d’amorçage. Les fichiers de sauvegarde,
|
||||
captures, packs graphiques et réglages personnels suivis ont conservé leurs
|
||||
hashes lors des deux synchronisations de livraison.
|
||||
|
||||
L’alpha.11 propose deux choix : **Petit** et **Grand**, Grand par défaut.
|
||||
Le preset `sanctuary:sanctuary` crée un nouveau monde `population_10` ;
|
||||
Petit garde `sanctuary:sanctuary_5` / `population_5`. Les anciens mondes
|
||||
20/100 restent chargeables à leur taille enregistrée, alors que leurs formats
|
||||
sont cachés de la création. Les formats de sauvegarde sont inchangés.
|
||||
|
||||
L’optimisation conserve les résultats pour un même réglage. La livraison a
|
||||
préservé les fichiers personnels suivis, les mods tiers et leurs configurations.
|
||||
L’instance n’a pas été réimportée et aucun mod tiers n’a été retiré. Les
|
||||
sauvegardes personnelles sont restées fermées pendant les essais et la synchronisation.
|
||||
|
||||
Références officielles : [installation packwiz](https://packwiz.infra.link/tutorials/installing/packwiz-installer/),
|
||||
[commandes Prism](https://prismlauncher.org/wiki/help-pages/custom-commands/),
|
||||
[bootstrap v0.0.3](https://github.com/packwiz/packwiz-installer-bootstrap/releases/tag/v0.0.3).
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# Alpha.14 — Lost City et expansions
|
||||
|
||||
**État : alpha.14 vérifiée, publiée et installée dans la même instance Prism Mac.**
|
||||
`check build assemblePack` passe sur les sources finales avec les **8 GameTests
|
||||
requis**, le 9 septembre 2026 à **13 h 57 min 48 s**. La création de quartiers,
|
||||
leur réouverture et la reprise différée du vide passent également dans leurs
|
||||
mondes de développement distincts. Le JAR, le MRpack et le ZIP Prism sont
|
||||
vérifiés. La release immuable correspond au commit source `1c5c2c2`, le canal
|
||||
packwiz au commit `dabf57e`. Deux synchronisations isolées puis deux dans la même
|
||||
instance Prism passent, avec **713 fichiers personnels et réglages suivis
|
||||
conservés**. Le [reçu de livraison](packwiz.md#alpha14-publiée--9-septembre-2026)
|
||||
consigne les artefacts, leur empreinte et la sauvegarde ciblée.
|
||||
Les résultats des alpha.12, 13 et 13.1 restent historiques. Ticket [WG-17](backlog.md).
|
||||
|
||||
Les smokes des sources finales passent sous Gradle et Java 25 :
|
||||
`LostCityRendererSmoke` pour les 17 types, dont confinement
|
||||
et accès des piscines dans quatre orientations, parcs distincts et sous-sols ;
|
||||
`ExpansionJournalSmoke`, dont l’immuabilité du jeton de reprise du vide ; et
|
||||
le contrôle de placement spatial de génération 14. `LostCityPlannerSmoke`
|
||||
passe également. Ses
|
||||
32 graines sur terrain contrôlé couvrent tous les types de lots ; un témoin
|
||||
distinct de faille couvre le pont, soit les 17 types. Le test de pente ascendante
|
||||
et descendante protège la sélection des lots contre le rejet asymétrique des
|
||||
terrains montants. Sur l’ancien planificateur compilé, ce nouveau test échouait
|
||||
avec **0 contre 9 lots** ; le planificateur corrigé satisfait l’égalité des
|
||||
deux sens et le minimum de cinq lots demandé par le test. Cette comparaison
|
||||
provient de la sortie d’exécution ; aucun journal séparé n’a été conservé.
|
||||
|
||||
`:sanctuary:expansionVoidCertificateSmoke` passe aussi sous Gradle :
|
||||
champs NBT natifs obligatoires, types stricts, refus des contenus, preuves de
|
||||
chunks absents et stabilité du certificat face aux seuls compteurs volatils.
|
||||
Ces contrôles unitaires sont complétés par les essais moteur consignés plus
|
||||
bas.
|
||||
|
||||
## Périmètre et création d’un monde
|
||||
|
||||
Minecraft **26.3-pre-2**, Fabric et Java **25**, selon les versions épinglées dans
|
||||
`gradle.properties`. La livraison cible `0.1.0-alpha.14` pour le mod et le pack.
|
||||
|
||||
Créer un **nouveau monde**, type **Sanctuary**, puis le bouton natif
|
||||
**Personnaliser** : Petit 512, Moyen 724 par défaut ou Grand 1 024 blocs de
|
||||
diamètre nominal. La hauteur reste de 384 blocs. Le codec public est
|
||||
`sanctuary:island_v14` ; les racines et les extensions de ces mondes portent la
|
||||
génération **14**. Le journal est séparé :
|
||||
`data/sanctuary-world-v14/expansions.json`, schéma 1.
|
||||
|
||||
Les mondes de générations 12 et 13 conservent leurs codecs, paramètres et
|
||||
journaux. Ils ne reçoivent pas Lost City par conversion et ne changent pas de
|
||||
générateur au chargement. Aucune sauvegarde personnelle ne doit être utilisée
|
||||
pour les essais ci-dessous.
|
||||
|
||||
## Lost City
|
||||
|
||||
Les quartiers sont composés par du code Sanctuary original. Une poche urbaine
|
||||
peut être retenue sur l’île initiale ; les extensions de profil `lost_city`
|
||||
recherchent également des emplacements. Le biome suit les parcelles acceptées
|
||||
en surface et laisse les biomes de cavités profonds. Une île peut ne fournir
|
||||
aucun site convenable : la ville et ses différents types ne sont pas garantis.
|
||||
|
||||
| Type | Aménagement distinctif |
|
||||
| --- | --- |
|
||||
| Rue | Chaussée, bordures de pierre lisse et variations de hauteur accessibles |
|
||||
| Pont | Tablier et garde-corps au-dessus d’une courte faille, appuis naturels aux deux extrémités |
|
||||
| Parc boisé | Chemins croisés, arbres et bancs |
|
||||
| Jardin | Haies basses, parterres fleuris et allées |
|
||||
| Aire de jeux | Sable, portique de balançoire, plate-forme et petit toboggan |
|
||||
| Parc mémorial | Cour, monument de pierre et bancs |
|
||||
| Place | Pavage et fontaine sèche |
|
||||
| Marché | Étals, auvents sobres et réserves |
|
||||
| Parking | Places marquées, poteaux, garage souterrain si la roche le permet, sinon en surface |
|
||||
| Excavation | Fouille ouverte, fondations inachevées, barrières et escalier |
|
||||
| Piscine abandonnée | Bassin de trois couches d’eau, marches immergées et plage minérale |
|
||||
| Immeuble d’appartements | Étages, chambres, coin cuisine et escaliers |
|
||||
| Hôtel | Réception, couloir central et chambres séparées |
|
||||
| Bibliothèque | Rayonnages, tables de lecture et pupitre |
|
||||
| Restaurant | Cuisine équipée et tables, espace de service à l’étage |
|
||||
| Caserne | Dortoirs et réserves |
|
||||
| Hall | Grande salle, estrade, rangées de sièges et balcon |
|
||||
|
||||
Pierre, pierre lisse et briques de pierre dominent ; briques fissurées ou
|
||||
moussues, andésite, vitres, détails de fer et intérieurs en chêne noir complètent
|
||||
la palette. L’usure est déterministe. Les accès et les escaliers restent
|
||||
distincts des brèches de façade et de toiture. Le butin des coffres utilise des
|
||||
tables vanilla différées ; aucune génération de spawner ou de villageois.
|
||||
|
||||
Le spawn naturel de monstres du biome et des pièces de structure est limité aux
|
||||
zombies vanilla. Lumière, difficulté, limites de population et distance aux
|
||||
joueurs continuent de s’appliquer ; une ruine n’implique pas la présence immédiate
|
||||
d’un groupe de zombies. Les classes de zombies spécifiques restent pour la bêta.
|
||||
|
||||
Le placement vérifie la roche sous toute l’emprise. Un sous-sol, une piscine ou
|
||||
une excavation exige une continuité jusqu’à huit blocs sous le plancher de
|
||||
référence. À défaut, le parking reste en surface et piscine ou excavation se
|
||||
replient en parc. Les piscines gardent un fond et des parois continus ; les
|
||||
ponts ne construisent pas de piliers jusqu’au vide. Les volumes protégés de
|
||||
l’hydrologie et les sites insuffisamment supportés sont écartés.
|
||||
|
||||
## Commandes d’essai et placement des expansions
|
||||
|
||||
Les commandes restent des outils opérateur, accessibles avec les permissions
|
||||
adéquates. Elles n’utilisent pas encore la progression ou les ressources.
|
||||
|
||||
```text
|
||||
/sanctuary expansion quick east lost_city 256 natural
|
||||
/sanctuary expansion list
|
||||
/sanctuary expansion visit island_1
|
||||
/sanctuary expansion preview quartier sanctuary northeast lost_city 128 natural
|
||||
/sanctuary expansion create quartier sanctuary northeast lost_city 128 natural
|
||||
/locate structure sanctuary:lost_city
|
||||
```
|
||||
|
||||
`island_1` est l’exemple du premier identifiant libre créé par `quick` ; utiliser
|
||||
l’identifiant réellement indiqué par la commande. `visit` attend une île prête.
|
||||
`preview` examine un emplacement sans l’activer. `resume <id>` sert à reprendre
|
||||
une réservation interrompue. `create` choisit aussi son parent explicitement.
|
||||
La recherche exacte `/locate structure sanctuary:lost_city` retrouve les
|
||||
quartiers Sanctuary admissibles ; elle peut échouer si aucun quartier ne se
|
||||
trouve dans sa portée de recherche. Un profil `lost_city` ne garantit pas un
|
||||
terrain suffisamment adapté pour accepter un quartier.
|
||||
|
||||
Les diamètres d’expansion possibles dans les mondes 14 sont **64, 128, 256,
|
||||
512 et 1 024**. Avec `quick east` ou `quick east lost_city`, le diamètre est tiré
|
||||
de manière reproductible à partir de la graine et de l’identifiant :
|
||||
|
||||
| Diamètre | Poids du tirage |
|
||||
| --- | --- |
|
||||
| 64 | 40 % |
|
||||
| 128 | 30 % |
|
||||
| 256 | 18 % |
|
||||
| 512 | 9 % |
|
||||
| 1 024 | 3 % |
|
||||
|
||||
Ces poids décrivent la distribution du tirage, pas un quota de cinq îles à
|
||||
reproduire sur chaque serveur. Une taille explicite remplace le tirage. Les
|
||||
recherches d’emplacement ne changent ni cette taille ni la graine retenue.
|
||||
Les mondes historiques gardent leurs tailles et comportements antérieurs.
|
||||
|
||||
La direction choisit un secteur de 45° autour du parent. La recherche essaie
|
||||
d’abord des positions proches, puis des couronnes plus éloignées, avec un
|
||||
nombre borné de candidats. Les centres restent alignés sur les chunks.
|
||||
Les emprises circulaires incluent le terrain, la marge de décoration et une
|
||||
garde empêchant deux îles de posséder le même chunk de coin. Le voisinage lu
|
||||
par les étapes de génération n’est pas intégralement réservé comme terrain.
|
||||
Une distance minimale entre centres reste possible comme dernier argument.
|
||||
|
||||
## Contrat de reprise différée du vide
|
||||
|
||||
Ce mécanisme concerne **uniquement les nouveaux mondes de génération 14** et
|
||||
une expansion explicitement demandée. Les générations 12/13 gardent leur refus
|
||||
des chunks déjà générés, même vides. Il ne permet pas la régénération générale
|
||||
d’une île, d’un terrain occupé ou d’une construction.
|
||||
|
||||
1. Examiner toute l’emprise d’écriture et produire, pour chaque chunk existant,
|
||||
une preuve stricte qu’il est FULL, entièrement vide et vierge. Les positions
|
||||
encore absentes sont également certifiées, y compris leurs stockages
|
||||
d’entités et de POI. La simple absence de bloc visible ne suffit pas : entités
|
||||
persistées, chargées ou en attente, POI, ticks, événements de blocs et autres
|
||||
indices de contenu font partie des refus.
|
||||
2. Lier le certificat à l’identité exacte de l’expansion et à un jeton immuable
|
||||
dans le journal. Une preuve isolée sans réservation correspondante ne donne
|
||||
aucune autorisation ; un changement de paramètres ou une preuve manquante
|
||||
doit faire échouer la reprise.
|
||||
3. Enregistrer la réservation et demander de **fermer puis rouvrir le monde**.
|
||||
L’expansion reste différée pendant la session ayant établi la preuve.
|
||||
4. Au chargement suivant, vérifier le journal, le certificat et les données
|
||||
actuelles sur toute l’emprise avant toute préparation. Une divergence bloque
|
||||
la reprise avant l’écriture des positions absentes. Seuls les chunks
|
||||
explicitement certifiés peuvent être réutilisés par cette expansion.
|
||||
|
||||
La preuve lit le format natif exact de Minecraft 26.3-pre-2 : l’état d’air sans
|
||||
propriétés s’écrit comme la chaîne `minecraft:air` dans la palette ; sa forme
|
||||
composée autorisée utilise le champ `id`. L’ancien champ `Name` ne suffit pas
|
||||
à certifier un chunk. Les types NBT, sections, palettes, heightmaps et données
|
||||
annexes restent contrôlés strictement ; une métadonnée inconnue provoque un
|
||||
refus. Seuls les compteurs temporels et caches de lumière valides sont exclus
|
||||
de l’empreinte sémantique du vide.
|
||||
|
||||
Il ne faut supprimer ni certificat, ni journal, ni dossier de région pour
|
||||
débloquer une commande. L’absence de preuve est un refus, pas une invitation à
|
||||
reconstruire l’historique. Les sauvegardes personnelles ne sont pas ouvertes
|
||||
par les tests ou par la mise à jour packwiz.
|
||||
|
||||
## Protocole et preuves attendues
|
||||
|
||||
Un seul processus Gradle ou Minecraft de développement à la fois. Les suites
|
||||
de création repartent de mondes ignorés sous `mods/sanctuary/build/run/`.
|
||||
Un scénario de réouverture doit suivre sa création sans intercaler un test qui
|
||||
effacerait ce monde de développement.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:lostCityPlannerSmoke :sanctuary:lostCityRendererSmoke --console=plain
|
||||
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCityTests=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCityTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
La suite de reprise du vide repart ensuite d’un **autre monde de développement
|
||||
neuf**. La commande de réouverture est exécutée deux fois de suite :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryVoidReuseTests=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryVoidReuseTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryVoidReuseTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
### Résultats moteur — graine 0
|
||||
|
||||
Le 9 septembre 2026, la création sur les sources finales passe à **13 h 55 min
|
||||
15 s** dans le processus **19155** (`build/alpha14-city-final-create.log`,
|
||||
deux tests requis réussis, commande terminée en **2 min 24 s**). La réouverture
|
||||
du même monde de développement passe à **13 h 55 min 58 s** dans un second
|
||||
processus, **19372** (`build/alpha14-city-final-reload.log`, deux tests requis
|
||||
réussis, **33 s**).
|
||||
Ces durées sont celles des commandes Gradle complètes, pas des mesures de
|
||||
chargement en jeu ou de performance multijoueur.
|
||||
|
||||
Les rapports locaux ignorés sont
|
||||
`mods/sanctuary/build/run/gameTest/diagnostics/lost-city-alpha14-seed-0.json`
|
||||
et `lost-city-alpha14-reload-seed-0.json` dans le même dossier. Leurs copies
|
||||
sont conservées dans `build/alpha14-evidence/` avant le nettoyage du monde de
|
||||
développement. Le monde racine est de diamètre **724**, graine **0**, codec
|
||||
`sanctuary:island_v14`.
|
||||
|
||||
Trois extensions de diamètre 256 ont été examinées en lecture seule :
|
||||
|
||||
| Candidat | Graine de l’extension | Quartiers admissibles |
|
||||
| --- | --- | --- |
|
||||
| `city_test` | `7890860792428309400` | 0 |
|
||||
| `city_harbor` | `-1594320136210322067` | 0 |
|
||||
| `city_gardens` | `6666275329838981856` | 1 |
|
||||
|
||||
Seule `city_gardens` a ensuite été activée, à l’est, centre **(2032, 624)**.
|
||||
Sa préparation a terminé **418 chunks FULL**. L’inspection urbaine ci-dessous
|
||||
couvre les chunks des quartiers, pas la totalité de cette préparation.
|
||||
|
||||
| Site généré | Parcelles sauvegardées | Chunks FULL inspectés | Coffres non ouverts à butin différé |
|
||||
| --- | --- | --- | --- |
|
||||
| Île initiale | 26 : appartement, hôtel, bibliothèque, marché, parc boisé, mémorial et 20 rues | 26 | 4 |
|
||||
| `city_gardens` | 12 : appartement, parc boisé et 10 rues | 13 | 1 |
|
||||
|
||||
Les deux quartiers possèdent leurs références de structure natives ; la
|
||||
recherche native retrouve respectivement **(-49, 253, 13)** et **(2012, 229,
|
||||
621)**. Les intérieurs traversables et les tables de spawn limitées aux zombies
|
||||
sont contrôlés. Cela ne garantit pas des zombies visibles de jour. La
|
||||
couverture des **17 types dans quatre orientations** provient de 68 rendus
|
||||
témoins indépendants ; elle ne signifie pas que cette graine contient tous les
|
||||
types dans ses quartiers naturels.
|
||||
|
||||
L’inspection native des bâtiments contrôle également qu’aucun minerai ne
|
||||
remplace la maçonnerie dans leurs volumes à partir du plancher. Cette preuve
|
||||
concerne les bâtiments effectivement générés et inspectés ; les minerais du
|
||||
terrain sous les fondations restent possibles.
|
||||
|
||||
Une piscine native isolée, indépendante du tirage des quartiers, conserve
|
||||
**189 cellules d’eau**, ses marches et son confinement après l’exécution d’un
|
||||
tick d’eau. La réouverture conserve les mêmes 189 cellules et les marches ;
|
||||
elle n’exécute pas un second tick témoin.
|
||||
|
||||
Dans le second processus, les **38 pièces natives**, les **5 coffres non
|
||||
ouverts**, le bloc d’or témoin à **(-61, 250, 11)** et le journal sont conservés.
|
||||
L’empreinte du journal contrôlé est
|
||||
`38e4bc1b2e493628d1e5d1775250c0bc48821bf49d025df3c37159fcff5801ba`.
|
||||
Ces essais utilisent uniquement un monde de développement ignoré, sans ouvrir
|
||||
de sauvegarde personnelle.
|
||||
|
||||
### Reprise du vide — création et deux réouvertures
|
||||
|
||||
Les trois exécutions Minecraft, sur la graine **0**, génération **14**, passent
|
||||
le 9 septembre 2026 :
|
||||
|
||||
| Étape | Journal local | Résultat et durée de la commande |
|
||||
| --- | --- | --- |
|
||||
| Preuves et réservations | `build/alpha14-void-create.log` | Deux tests requis réussis à 13 h 43 ; 41 s |
|
||||
| Première réouverture | `build/alpha14-void-reload.log` | Deux tests requis réussis à 13 h 45 ; 1 min |
|
||||
| Deuxième réouverture | `build/alpha14-void-second-reload.log` | Deux tests requis réussis à 13 h 46 ; 29 s |
|
||||
|
||||
Les rapports sont `void-reuse-before-reload.json`,
|
||||
`void-reuse-after-reload.json` et `void-reuse-second-reload.json`, sous
|
||||
`mods/sanctuary/build/run/gameTest/world/`. Ce sont des preuves locales à
|
||||
conserver avant le nettoyage du monde de développement.
|
||||
|
||||
La première expansion, `void_loaded`, couvre **77 chunks FULL vides déjà
|
||||
chargés**. La seconde, `void_saved`, couvre **un chunk FULL vide uniquement
|
||||
stocké et 76 positions absentes**. Ce dernier scénario emploie une copie NBT
|
||||
native de chunk vide, déplacée uniquement pour constituer le témoin dans ce
|
||||
monde jetable. Chaque réservation reçoit son jeton durable propre et reste
|
||||
différée, sans publication dans le générateur pendant la session initiale.
|
||||
|
||||
Quatre témoins indépendants, chacun contrôlé vide avant modification, prouvent
|
||||
le refus d’un **bloc**, d’un **coffre avec son entité de bloc**, d’une **entité
|
||||
vivante enregistrée** et d’un **tick de bloc programmé**. Une métadonnée NBT
|
||||
étrangère ajoutée après certification fait également échouer la vérification
|
||||
globale : l’empreinte SHA-256 du fichier de région et le contenu NBT modifié
|
||||
restent identiques après ce refus. Le test restaure ensuite son témoin vide
|
||||
pour poursuivre le scénario positif.
|
||||
|
||||
À la première réouverture, les deux expansions atteignent **154 chunks FULL**,
|
||||
chacun conservant le marqueur de son jeton exact, avec **128 sections non
|
||||
vides**. Un bloc d’or témoin est alors posé à **(6080, 376, 1264)**. À la
|
||||
deuxième réouverture, il est préservé : les **154 chunks FULL marqués** et
|
||||
**129 sections non vides** sont retrouvés. Ce passage contrôle donc aussi
|
||||
qu’une reprise terminée ne réinitialise pas les blocs ajoutés ensuite.
|
||||
|
||||
Une réouverture supplémentaire passe à **13 h 48**, avec deux tests requis
|
||||
réussis en **37 s** (`build/alpha14-void-final-reload.log`). Elle contrôle
|
||||
explicitement la lumière native terminée (`isLightCorrect`) sur les **154
|
||||
chunks** et **32 échantillons de biomes hors `the_void`** à l’intérieur des
|
||||
extensions. Le bloc d’or et les 129 sections non vides sont toujours conservés.
|
||||
Ce dernier rapport est copié hors du monde de test dans
|
||||
`build/alpha14-evidence/void-reuse-second-reload.json` pour survivre à son
|
||||
nettoyage. Les 32 échantillons ne constituent pas un relevé exhaustif de tous
|
||||
les biomes des extensions.
|
||||
|
||||
### Vérification finale du build et des artefacts
|
||||
|
||||
`./gradlew check build assemblePack` passe en **1 min 34 s** : **40 tâches**, dont
|
||||
33 exécutées et 7 à jour. Le journal est `build/alpha14-check-build-pack.log`.
|
||||
Les huit tests moteur requis réussissent ; le témoin hydrologique de graine 0
|
||||
contrôle **125 chunks FULL**, **5 923 blocs d’eau conservés** et **1 800 ticks de
|
||||
fluides**. La même suite vérifie la sérialisation des tailles **512 / 724 /
|
||||
1 024**, le défaut Moyen et le codec historique de génération **13**.
|
||||
|
||||
Le smoke du journal valide aussi la politique de synchronisation de dossier
|
||||
Windows par simulation, ainsi que les échecs de stockage après remplacement.
|
||||
Ce résultat ne constitue pas une exécution native sous Windows.
|
||||
|
||||
`build/verify-alpha14-artifacts.py` valide les versions alignées, l’index packwiz,
|
||||
le JAR de production sans classes de test, son inclusion dans le MRpack et le
|
||||
ZIP d’amorçage Prism. Le SHA-256 du JAR `sanctuary-0.1.0-alpha.14.jar` est :
|
||||
|
||||
```text
|
||||
1c517a13e1151501c6f4974d1bc00faedf67aaecd9bf58f34ac4a655d09dc6ef
|
||||
```
|
||||
|
||||
Ces vérifications portent sur les artefacts locaux. La publication du canal,
|
||||
les installations packwiz isolées et la synchronisation de l’instance Prism
|
||||
restent à consigner.
|
||||
|
||||
| Contrôle | Preuve attendue | État de livraison |
|
||||
| --- | --- | --- |
|
||||
| Géométrie et fondations | Déterminisme, budget de colonnes, sites protégés, certificat de roche et ponts | Smoke Gradle réussi sur les sources finales |
|
||||
| Rendu des 17 types | Quatre orientations, limites des pièces, parcours vers les étages, lits complets, accès aux sous-sols et piscine contenue | Smoke Gradle réussi sur les sources finales |
|
||||
| Placement des expansions | Cinq tailles, tirage stable, huit secteurs, séparation et garde aux coins | Smoke Gradle réussi sur les sources finales |
|
||||
| Structures dans Minecraft | Quartier initial et extension Lost City réellement générés, références natives et coffres à butin différé | Réussi, graine 0 : 38 pièces et 5 coffres |
|
||||
| Rechargement dans un second processus | Journal et structures identiques, butin non recréé, bloc témoin de joueur conservé | Réussi sur les sources finales, graine 0, processus 19372 |
|
||||
| Protection de la maçonnerie | Aucun minerai dans les volumes des bâtiments à partir de leur plancher | Réussi sur les quartiers natifs inspectés, graine 0 |
|
||||
| Eau après ticks moteur | Piscine retenue et accès après écoulement, sans fuite extérieure | Réussi sur piscine native isolée : 189 cellules et marches conservées après tick puis réouverture |
|
||||
| Certificat de vide | Schéma NBT strict, refus des contenus et preuves de chunks absents | Smoke Gradle réussi, complété par les témoins natifs de création |
|
||||
| Reprise du vide | Refus des contenus et preuves invalides ; reprise bornée après réouverture avec preuve intacte | Réussi après deux réouvertures : 154 chunks FULL marqués, bloc d’or conservé |
|
||||
| Lumière et biomes après reprise | Lumière native terminée et remplacement du biome vide à l’intérieur des extensions | Réussi à 13 h 48 : 154 chunks éclairés correctement, 32 échantillons hors `the_void`, bloc d’or conservé |
|
||||
| Sérialisation et stockage | Trois tailles, codec historique et erreurs de stockage bloquantes | Suite native réussie pour les trois tailles et le codec 13 ; politique Windows simulée réussie |
|
||||
| Hydrologie conservée | Eau retenue après écoulement moteur | Réussi : 125 chunks FULL, 5 923 blocs d’eau, 1 800 ticks de fluides |
|
||||
| Build et pack | `check build assemblePack` réussi avec versions alignées | Réussi à 13 h 57 min 48 s, huit GameTests requis, JAR/MRpack/ZIP Prism vérifiés |
|
||||
| Distribution | Artefacts immuables vérifiés, canal avancé, deux installations isolées et deux synchronisations de la même instance | Réussi : source `1c5c2c2`, canal `dabf57e`, 713 fichiers personnels et réglages suivis conservés |
|
||||
|
||||
Les reçus locaux sont `build/alpha14-isolated-validation.json` et
|
||||
`build/alpha14-prism-validation.json`. Aucun essai Windows natif, mesure de FPS ou test
|
||||
de charge multijoueur n’est attesté par cette liste. Une validation de géométrie
|
||||
en mémoire ne remplace pas la génération, les ticks et la persistance du moteur.
|
||||
|
||||
La publication suit [Distribution packwiz et Prism](packwiz.md). Elle conserve
|
||||
l’instance existante, ses sauvegardes, ses réglages et les mods tiers.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Alpha.15 — intérieurs, sous-sols et ville compacte
|
||||
|
||||
**État : code, génération et distribution validés.**
|
||||
Ticket [WG-18](backlog.md). `check build assemblePack` et les huit tests moteur
|
||||
passent sur les sources finales, ainsi que la création et la réouverture
|
||||
natives de Lost City. Le reçu de publication figure dans [Distribution](packwiz.md).
|
||||
|
||||
## Contrat de génération
|
||||
|
||||
Minecraft **26.3-pre-2**, Java **25**, dépendances épinglées inchangées.
|
||||
Le mod et le pack passent à `0.1.0-alpha.15`. Un **nouveau monde Sanctuary**
|
||||
utilise `sanctuary:island_v15` et son journal propre
|
||||
`data/sanctuary-world-v15/expansions.json`.
|
||||
Le bouton natif **Personnaliser** conserve Petit 512, Moyen 724 par défaut et
|
||||
Grand 1 024. La densité du terrain, les forêts et l’hydrologie naturelle ne sont
|
||||
pas remplacées par cette passe urbaine.
|
||||
|
||||
Le JAR d’essai anticipé du 9 septembre, avant publication de l’alpha.15,
|
||||
contenait encore les fragments dispersés. Les essais de cette nouvelle
|
||||
composition se font dans un **monde neuf** ; aucune réparation ou régénération
|
||||
des mondes créés avec ce prototype n’est exécutée. Le binaire d’essai est
|
||||
conservé séparément avant synchronisation de l’instance.
|
||||
|
||||
Les mondes 12/13/14 conservent leurs codecs, paramètres, journaux, plans et
|
||||
rendus, même pour leurs chunks encore non générés. Le preset 14 reste enregistré
|
||||
mais caché dans la création. Aucun monde personnel n’est ouvert, converti ou
|
||||
régénéré. Les nouvelles pièces persistantes ont leurs propres identifiants :
|
||||
`sanctuary:lost_city_parcel_v15`, `sanctuary:lost_city_underground_v15` et
|
||||
`sanctuary:lost_city_support_v15`.
|
||||
|
||||
Le biome `sanctuary:lost_city` reste utilisé : les parcelles acceptées pilotent
|
||||
la carte des biomes urbains et les apparitions naturelles de zombies vanilla.
|
||||
Les cavités gardent leurs biomes ; un égout souterrain ne transforme pas toute
|
||||
la colonne en biome urbain. Il n’y a ni villageois générés, ni spawner, ni
|
||||
classes de zombies de la future bêta.
|
||||
|
||||
## Aménagements
|
||||
|
||||
Les appartements, hôtels, bibliothèques, restaurants, casernes et halls ont
|
||||
des usages distincts. Seuil et vestibule rejoignent un couloir, une cage
|
||||
d’escalier et des pièces séparées par de vraies portes. Sols, plafonds,
|
||||
chambres, salles d’eau, cuisines, réserves, réception, rayonnages, dortoirs,
|
||||
auditorium et galerie suivent le type du bâtiment. Les détails d’usure doivent
|
||||
préserver les passages. Le butin reste différé, avec un coffre par bâtiment.
|
||||
|
||||
Les réseaux souterrains cherchent une descente depuis la surface, des égouts,
|
||||
des passages, des garages et des pool rooms. La roche et les volumes d’eau
|
||||
naturelle décident de leur admission. Les parties exposées demandent des
|
||||
appuis courts certifiés : pylônes ou chaînes réellement ancrés. Les bassins
|
||||
construits ont un fond, des parois et un accès ; ils ne doivent pas se vider
|
||||
dans les corridors. Les modules voisins ne se superposent pas. Une grande salle sèche accessible
|
||||
prépare une future machine d’expansion ; la machine et ses cartes ne font pas
|
||||
partie de cette livraison.
|
||||
|
||||
Le retour de l’essai anticipé remplace les fragments dispersés par une grille
|
||||
compacte : lots, rues et carrefours sont jointifs, avec un réseau accessible
|
||||
depuis chaque entrée. Les routes sont dégagées sur huit blocs de hauteur ;
|
||||
les terrasses sont certifiées dans leur emprise et les ponts prolongent le
|
||||
réseau. Les sièges, charnières et appuis des lanternes sont revus. Les emplacements admissibles
|
||||
restent conditionnels ; aucun équipement n’est promis sur toute graine.
|
||||
|
||||
## Vérification reproductible
|
||||
|
||||
Utiliser seulement les mondes de développement ignorés. Exécuter les processus
|
||||
Minecraft séquentiellement et faire suivre chaque création de sa réouverture,
|
||||
avant un autre test qui nettoie ce dossier de développement.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:lostCityInteriors15Smoke :sanctuary:lostCityUnderground15Smoke --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCity15Tests=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew :sanctuary:runGameTest -PsanctuaryLostCity15Tests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
|
||||
```
|
||||
|
||||
La suite historique `-PsanctuaryLostCityTests=true` choisit explicitement la
|
||||
génération 14. Les suites de reprise certifiée du vide utilisent la génération
|
||||
15 par défaut ; la réouverture doit être exécutée deux fois après leur création.
|
||||
Les certificats sont liés à leur génération : un certificat ou journal 14 ne
|
||||
peut pas autoriser une expansion 15. Le [contrat WG-17](testing-alpha14.md)
|
||||
reste applicable dans chaque journal, avec inspection stricte avant écriture.
|
||||
|
||||
Les résultats finaux préciseront graine, version, coordonnées, pièces natives,
|
||||
eau après ticks, conservation après réouverture et durée des commandes.
|
||||
|
||||
### Compatibilité historique déjà vérifiée
|
||||
|
||||
La création de génération 14 avec le binaire alpha.15 passe le 9 septembre
|
||||
2026 (`build/alpha15-legacy14-create.log`, processus 22663, 2 min 57 s).
|
||||
Sur graine 0, racine 724 et extension `city_gardens` 256, les deux starts NBT,
|
||||
les inventaires de blocs, les coffres différés et le journal ont exactement les
|
||||
mêmes empreintes ou contenus que la livraison alpha.14. La réouverture du même
|
||||
binaire dans le processus 23083 passe (`build/alpha15-legacy14-reload.log`, 57 s),
|
||||
avec **38 pièces**, **5 coffres non ouverts**, le bloc témoin et le journal
|
||||
conservés. Cette réouverture exclut la recompilation : elle vérifie les mêmes
|
||||
binaires que la création, pendant le développement séparé du nouveau planner15.
|
||||
Les classes historiques de planification et de rendu ne sont pas modifiées.
|
||||
Les rapports sont conservés dans `build/alpha15-evidence/legacy14-*.json`.
|
||||
|
||||
Le premier test moteur15 a vérifié les accès des bâtiments et réseaux, puis a
|
||||
échoué sur une hypothèse insuffisante de son ordonnanceur de fluides. Le test
|
||||
attend maintenant que les chunks et leurs entités soient prêts, observe un
|
||||
écoulement natif dans des témoins temporaires fermés, puis contrôle les bassins
|
||||
naturels après des ticks mesurés. Ces témoins sont séparés des équipements
|
||||
naturellement générés et supprimés avant sauvegarde.
|
||||
Les essais sur modèles contrôlés seront distingués des équipements effectivement
|
||||
générés sur terrain naturel. La validation Windows native reste à effectuer.
|
||||
|
||||
### Contrôles ciblés du retour d’essai anticipé
|
||||
|
||||
Les smokes exécutés le 9 septembre passent avec les classes Minecraft exactes :
|
||||
les six familles d’intérieurs dans quatre orientations contrôlent la collision
|
||||
des portes ouvertes, le côté des dossiers des sièges et l’appui plein des
|
||||
lanternes (`build/alpha15-targeted-furnishings.log`). Le rendu de surface15
|
||||
corrige aussi les bancs et ajoute les marches sur le sol existant, leur sommet
|
||||
rejoignant le niveau voisin. Dégagement des rues, volumes de protection,
|
||||
grande salle, boucle alternative et fondations souterraines passent dans
|
||||
`build/alpha15-targeted-utilities.log`. Ces essais contrôlés ne remplacent pas
|
||||
le scénario natif final.
|
||||
|
||||
Le scénario de reprise certifiée du vide de génération15 passe sa création et
|
||||
ses deux réouvertures dans des JVM séparées : **154 chunks FULL**, tous éclairés
|
||||
correctement, et bloc témoin conservé. Les refus de bloc, coffre, entité ou tick
|
||||
présents et la conservation des régions face à une mutation étrangère passent.
|
||||
Rapports : `build/alpha15-evidence/void-reuse-*.json`. Ces preuves précèdent la
|
||||
révision compacte du seul placement urbain ; le test complet final reste exigé.
|
||||
|
||||
### Ville compacte : création finale vérifiée
|
||||
|
||||
La création finale passe sur la graine **0**, île **724**, le 9 septembre
|
||||
2026 : `build/alpha15-final-city-create.log`, processus **26229**, **3 min 19 s**
|
||||
pour les quatre smokes puis le scénario moteur. Cette durée inclut les contrôles,
|
||||
les prospects et la préparation d’un continent ; ce n’est pas un benchmark de
|
||||
chargement joueur ou de rendu.
|
||||
|
||||
- Racine : un quartier natif au chunk **(-2, 3)**, cinq lots (immeuble, hôtel,
|
||||
bibliothèque, jardin et place), **33 pièces de rues/carrefours**, **52 pièces
|
||||
natives** avec le sous-sol. Les cinq entrées rejoignent le même réseau ;
|
||||
**393 sections** de rues sont traversées sur les seules rues et seuils.
|
||||
L’immeuble est accessible en **(-30, 252, 21)** sur cette graine.
|
||||
- Sous la racine : un accès, quatre collecteurs, sept passages, une pool room
|
||||
et une grande salle sèche. Le socle et les quatre secteurs de la salle sont
|
||||
accessibles depuis la trappe de surface.
|
||||
- Continent témoin **256**, graine **8737523517608206795**, centre **(2112, 320)** :
|
||||
restaurant et immeuble, réseau de rues, pont de 17 blocs, garage et pool room
|
||||
souterrains. **24 pièces natives** et **5 appuis souterrains** inspectés.
|
||||
Le pont est parcouru de **(2104, 225, 309)** à **(2120, 224, 309)**.
|
||||
- Les biomes urbains et le spawn naturel de zombies sont vérifiés ; aucun
|
||||
spawner ni villageois ajouté. Cinq coffres restent à butin différé.
|
||||
- Les bassins souterrains sont contrôlés après **120 ticks de monde mesurés** ;
|
||||
19 témoins temporaires indépendants prouvent l’exécution native des fluides,
|
||||
puis sont retirés avant sauvegarde. La piscine de surface est vérifiée dans
|
||||
le premier monde compact, avant la dernière affectation des lots : ses
|
||||
**207 sources** sont conservées, et neuf cellules au-dessus des marches se
|
||||
remplissent normalement sans fuite (216 cellules d’eau au total).
|
||||
|
||||
Le témoin final contient un pont court et des appuis souterrains. Les grands
|
||||
viaducs sur piles construites restent validés sur terrains contrôlés ; ils ne
|
||||
sont pas présentés comme observés dans ce monde naturel. Aucune famille de
|
||||
bâtiments, piscine, garage, salle ou pont n’est garantie sur chaque graine.
|
||||
Rapport : `build/alpha15-evidence/final-city-create.json` ; le monde compact
|
||||
précédent est conservé dans `compact-first-create.json`.
|
||||
|
||||
La réouverture finale du même monde passe dans un autre processus (**26452**,
|
||||
`build/alpha15-final-city-reload.log`, **51 s**) : **76 pièces natives**, **5 coffres
|
||||
à butin différé**, **108 points de parcours intérieurs** et **5 appuis** conservés.
|
||||
Les sept entrées restent reliées aux rues, le bloc témoin **(-38, 252, 21)** est
|
||||
présent et le journal garde exactement ses octets. Rapport :
|
||||
`build/alpha15-evidence/final-city-reload.json`.
|
||||
|
||||
`check build assemblePack` final passe en **2 min 6 s** le 9 septembre 2026,
|
||||
avec les **huit tests moteur requis** et tous les smokes, dont les quatre
|
||||
contrôles15 (`build/alpha15-final-check-build-pack.log`). Le JAR et le pack
|
||||
sont construits depuis les mêmes sources que le scénario final. Le changement
|
||||
reste réservé à15 ; la revue finale ne trouve aucune modification des quatre
|
||||
classes de planification/rendu/pièce historiques14.
|
||||
|
||||
La release immuable `v0.1.0-alpha.15`, source `dd9c795`, et le canal `6cf54ee`
|
||||
sont publiés. JAR, MRpack et amorçage sont vérifiés ; deux installations isolées
|
||||
et deux synchronisations de la même instance Prism réussissent. Les **767**
|
||||
fichiers personnels et réglages suivis restent identiques. L’ancien JAR d’essai
|
||||
est conservé séparément ; un seul JAR Sanctuary est actif. Voir le
|
||||
[reçu de livraison](packwiz.md#alpha15-publiée--9-septembre-2026).
|
||||
@@ -0,0 +1,144 @@
|
||||
# Alpha.16 — Sanctuary habitée avant nous
|
||||
|
||||
**État : publiée et installée dans la même instance Prism.** Ticket WG-19,
|
||||
branche `codex/sanctuary-ruins-alpha16`. Minecraft 26.3-pre-2, Fabric Loader 0.19.5,
|
||||
Fabric API 0.160.0+26.3 et Java 25, comme l’alpha.15.
|
||||
|
||||
## Contrat de génération
|
||||
|
||||
La génération 16 demande un **nouveau monde Sanctuary**. Le choix public reste
|
||||
unique, avec le bouton natif Personnaliser : Petit 512, Moyen 724 par défaut,
|
||||
Grand 1024. Les codecs `island_v13`, `island_v14`, `island_v15`, leurs pièces
|
||||
natives, journaux, biomes et calculs de chunks futurs sont conservés. Aucun
|
||||
monde personnel n’est converti, ouvert par les tests ou régénéré.
|
||||
|
||||
Lorsque les structures sont activées, l’île de départ reçoit une ville
|
||||
compacte. Une recherche naturelle est suivie, si nécessaire, d’une réservation
|
||||
architecturale locale 49 × 49 : rues et bâtiments reliés au-dessus d’un socle
|
||||
creux, avec appuis de maçonnerie certifiés. Cette réservation peut dégager le
|
||||
terrain et remplacer l’eau dans sa seule emprise, avant le placement des pièces.
|
||||
Elle ne modifie pas la densité de toute l’île et ne s’applique pas aux continents.
|
||||
Les expansions conservent leur profil et leur placement soumis au terrain.
|
||||
|
||||
La passe souterraine comprend une piscine couverte avec bassin de 50 × 25
|
||||
blocs et quatre blocs de profondeur, des égouts ouverts et des parkings de
|
||||
3, 4, 6 ou 8 plateformes selon les appuis disponibles. Une grande salle sèche
|
||||
peut compléter le réseau. Chaque équipement dépend des volumes et appuis
|
||||
certifiés sur le terrain ; le tableau
|
||||
ci-dessous distingue les témoins natifs des cas de modèle. Les anciens petits
|
||||
bassins de la génération 15 restent inchangés.
|
||||
|
||||
Les plans des étages alternent réellement. Les sanitaires ont des parois
|
||||
complètes, leur porte et leur plafond ; les lits sont adossés aux murs avec
|
||||
une rive latérale accessible. Portes, sièges et lanternes conservent leurs
|
||||
contrôles d’orientation et d’appui.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- Les cinq smokes 16 passent : intérieurs, planification, fondation, protection
|
||||
et sous-sols. Ils vérifient notamment les quatre orientations, les plans
|
||||
d’étages, les sanitaires, les lits et les appuis.
|
||||
- Matrice de planification moteur : les trois tailles 512/724/1024 × graines 0/1
|
||||
passent. Une ville de 3 à 8 lots, avec rues et entrées reliées dans les six cas.
|
||||
Ces six reliefs trouvent une implantation naturelle ; le recours architectural
|
||||
est couvert séparément par les terrains adverses, le rendu borné et le NBT
|
||||
des smokes. Rapport : `build/alpha16-evidence/planning-matrix.json`.
|
||||
- Création native 16, graine 0, taille Moyenne, puis réouverture dans une seconde
|
||||
JVM : **71 pièces, 5 coffres différés, 107 cibles de parcours et 74 appuis**
|
||||
conservés entre l’île et une expansion de 256 blocs. Les hashes des starts,
|
||||
le journal et le bloc d’or témoin restent identiques. Processus 29685 puis 29853.
|
||||
Rapports : `build/alpha16-evidence/final-city-create.json` et
|
||||
`final-city-reload.json` ; logs `build/alpha16-third-city-create.log` et
|
||||
`alpha16-city-reload.log`.
|
||||
- Après **120 ticks du monde réellement observés**, la piscine garde ses
|
||||
**4 976 cellules d’eau** ; les deux égouts observés déversent de l’eau au-delà
|
||||
de leur bouche. Dix-neuf témoins temporaires indépendants prouvent l’activité
|
||||
effective du moteur des fluides, puis sont supprimés avant sauvegarde. Les
|
||||
cascades atteignent des corniches : elles ne sont pas toutes libres jusqu’au
|
||||
vide inférieur. Aucun test ne présente une simple attente comme un écoulement.
|
||||
- Compatibilité 15 : création puis réouverture sous le mod 16 réussies,
|
||||
76 pièces natives, 5 coffres, 108 cibles de parcours et 5 appuis conservés.
|
||||
Les SHA-256 des deux starts et du journal sont exactement ceux de l’alpha.15
|
||||
publiée. Les fichiers de génération 15 restent inchangés. Le preset caché
|
||||
`sanctuary_v15.json` est identique à l’ancien preset public, SHA-256
|
||||
`6c4a91eede4b9978526d6de6289dbc7394ed4114ebcae9f779fac3ce7e7d887e`.
|
||||
Rapports : `build/alpha16-evidence/legacy15-create.json` et `legacy15-reload.json`.
|
||||
- `./gradlew check build assemblePack` passe en 2 min 18 s : huit GameTests,
|
||||
tous les smokes et index du pack vérifiés. Log :
|
||||
`build/alpha16-final-check-build-pack.log`.
|
||||
- JAR, MRpack et ZIP d’amorçage vérifiés : même version et même JAR, index et
|
||||
hashes cohérents, aucun code de test embarqué. SHA-256 du JAR :
|
||||
`5504018c46300accf01478dbc8bfb8e216395a5435cf7d7f501005cbc65b8e43`.
|
||||
|
||||
### Distribution des sous-sols sur six reliefs
|
||||
|
||||
Un second relevé utilise le vrai bruit Minecraft et les protections hydrologiques,
|
||||
sans générer les chunks de la ville. Il contrôle les volumes, les fondations et
|
||||
les limites de calcul ; les fluides et la persistance restent prouvés par le
|
||||
scénario natif ci-dessus. Rapport : `build/alpha16-evidence/underground-matrix.json`.
|
||||
|
||||
| Diamètre | Graine | Piscine | Grande salle | Parking | Égout ouvert |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 512 | 0 | Absente | Présente | 8 niveaux | Présent |
|
||||
| 512 | 1 | Absente | Présente | 8 niveaux | Présent |
|
||||
| 724 | 0 | Présente | Présente | 8 niveaux | Présent |
|
||||
| 724 | 1 | Absente | Présente | 8 niveaux | Présent |
|
||||
| 1024 | 0 | Présente | Absente | 8 niveaux | Présent |
|
||||
| 1024 | 1 | Présente | Présente | 8 niveaux | Présent |
|
||||
|
||||
La piscine est donc présente dans trois cas sur six, la salle dans cinq cas.
|
||||
Les six réseaux ont un accès depuis la surface. Ce petit échantillon ne donne
|
||||
pas une fréquence statistique ni une garantie pour les autres graines. Le
|
||||
budget souterrain atteint son plafond de 420 000 sondes dans quatre cas ;
|
||||
la recherche s’arrête alors en conservant les équipements déjà certifiés.
|
||||
Une aile absente ne prouve donc pas l’absence de tout emplacement compatible :
|
||||
une autre solution peut se trouver au-delà du budget de recherche.
|
||||
La passe souterraine prend 2,9 à 7,0 secondes après la planification de surface
|
||||
sur cette machine ; cela ne mesure ni le rendu client ni le chargement complet.
|
||||
|
||||
## Repères pour l’essai en jeu
|
||||
|
||||
Créer un nouveau monde **Sanctuary, Moyen, graine 0**, structures activées.
|
||||
Les coordonnées suivantes proviennent des blocs réellement générés et relus.
|
||||
Elles ne constituent pas une migration d’un monde précédent.
|
||||
|
||||
| Lieu | Repère X / Y / Z |
|
||||
| --- | --- |
|
||||
| Entrée de l’immeuble | −30 / 252 / 21 |
|
||||
| Trappe depuis la rue | **−19 / 252 / 41** |
|
||||
| Promenade de la piscine | −45 / 230 / −11 |
|
||||
| Grande salle sèche | −42 / 231 / 20 |
|
||||
| Parking, niveau supérieur | 10 / 230 / 20 |
|
||||
| Parking, niveau inférieur | 10 / 118 / 20 |
|
||||
| Bouche de l’égout inférieur | **−50 / 117 / 20** |
|
||||
|
||||
Le bassin remplit X −44 à 5, Y 225 à 228, Z −23 à 1. Les plateformes du
|
||||
parking sont à Y 117, 133, 149, 165, 181, 197, 213 et 229. Le réseau est
|
||||
accessible depuis la trappe de rue. Sur ce témoin, la cascade atteint une
|
||||
corniche à Y 113. L’expansion de référence `city16_0_2`, graine
|
||||
9111000990819250332, possède aussi une bouche à 1958 / 202 / −651.
|
||||
|
||||
Les équipements restent conditionnés par leurs volumes et appuis ; seule la
|
||||
zone de ville initiale est garantie par le recours architectural. Aucun essai Windows natif ni revue visuelle en client n’a été effectué pour
|
||||
cet incrément ; les vérifications ci-dessus proviennent du moteur serveur Mac. Le pack doit encore être validé en jeu par Koka.
|
||||
|
||||
## Livraison
|
||||
|
||||
Le tag `v0.1.0-alpha.16` vise le commit source `98fc13c` ; le canal packwiz
|
||||
est au commit `927c862`. Les trois artefacts publics sont vérifiés par
|
||||
téléchargement, puis deux installations isolées et deux synchronisations de
|
||||
la même instance Prism passent. Un seul JAR alpha.16 est actif ; les 767
|
||||
fichiers personnels et réglages suivis conservent leurs hashes. Aucun monde
|
||||
personnel ouvert ou régénéré. Les reçus et la sauvegarde ciblée sont décrits
|
||||
dans [Distribution](packwiz.md).
|
||||
|
||||
## Suite validée avec Koka, hors alpha.16
|
||||
|
||||
Développer les trois familles : mines et ateliers, lieux étranges,
|
||||
infrastructures à réactiver. Plusieurs réseaux distincts se connectent parfois.
|
||||
Les installations encore utilisables et les chantiers interrompus racontent
|
||||
l’occupation passée par d’autres joueurs. Les back rooms de ces donjons sont
|
||||
physiquement dans l’île et restent distinctes de la dimension des objets perdus.
|
||||
Le butin combinera des reliques collectives uniques et des récompenses
|
||||
personnelles. Les règles d’attribution restent à définir ; aucun réacteur,
|
||||
nouveau spawner de donjon ou système de progression n’est ajouté ici.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Alpha.17 — réseaux oubliés, prototype réversible
|
||||
|
||||
**État : code et artefacts vérifiés ; publication en cours.** Ticket WG-20, branche
|
||||
`codex/structures-experiment-alpha17`. Minecraft 26.3-pre-2, mêmes dépendances
|
||||
que l’alpha.16. Cet essai porte sur les lieux et leur génération.
|
||||
|
||||
## Contrat
|
||||
|
||||
Créer un **nouveau monde Sanctuary**, puis **Personnaliser** : Petit 512,
|
||||
Moyen 724 par défaut, Grand 1 024 et **Structures expérimentales** (activées par
|
||||
défaut). Le choix est enregistré dans le générateur 17, avec la taille. Désactiver
|
||||
l’option pour un autre nouveau monde permet de comparer avec le socle urbain 16.
|
||||
Ce bouton ne supprime ni ne rajoute de structures dans une sauvegarde existante.
|
||||
|
||||
Les anciens mondes gardent leurs codecs, rendus, pièces et futurs chunks. Le
|
||||
preset 16 historique est conservé à l’identique et caché à la création. Le 17
|
||||
possède son propre journal ; aucune conversion ni rétrogradation de sauvegarde
|
||||
n’est proposée. Le retour au binaire 16 concerne seulement les mondes 16 et
|
||||
antérieurs, jamais un monde 17.
|
||||
|
||||
Les nouvelles structures sont recherchées sur l’île initiale uniquement, hors
|
||||
ville et eaux protégées. Trois petits réseaux distincts : mines et ateliers,
|
||||
salles étranges, installations techniques abandonnées. Salles et couloirs se
|
||||
raccordent à des accès de surface. Les fondations reposent sur le terrain ou sur
|
||||
des appuis courts certifiés. La présence dépend des emplacements trouvés dans
|
||||
un budget borné. Les jonctions entre familles restent une étape suivante.
|
||||
|
||||
Les établis, fourneaux et outils vanilla donnent des usages aux installations ;
|
||||
rails, soutènements et chantiers interrompus suggèrent les anciens occupants.
|
||||
Les lieux étranges sont physiques dans l’île, distincts de la future dimension
|
||||
Backrooms. Aucun réacteur, progression, relique collective, récompense personnelle
|
||||
ou nouveau spawner n’est ajouté par ce prototype. Les coffres éventuels utilisent
|
||||
les tables de butin vanilla.
|
||||
|
||||
## Vérification et essai
|
||||
|
||||
Le monde **Moyen, graine 0**, option expérimentale activée, reçoit trois réseaux
|
||||
avec cinq pièces chacun. Les contrôles natifs portent sur **15 pièces**, **7 639
|
||||
blocs**, **13 chunks FULL**, **12 appuis**, **49 cibles de parcours** et **3 coffres
|
||||
à butin différé**. Les empreintes des starts, le plan et un bloc d’or témoin sont
|
||||
conservés après réouverture dans une seconde JVM. Les trappes sont ouvertes pour
|
||||
vérifier le parcours réel d’un joueur, puis refermées avant la sauvegarde.
|
||||
|
||||
| Réseau | Entrée X / Y / Z |
|
||||
| --- | --- |
|
||||
| Mine et atelier | **78 / 252 / 179** |
|
||||
| Salles étranges et archives | **−194 / 254 / −22** |
|
||||
| Locaux techniques | **116 / 253 / −157** |
|
||||
|
||||
Avec les commandes autorisées :
|
||||
|
||||
```text
|
||||
/locate structure sanctuary:forgotten_networks
|
||||
```
|
||||
|
||||
La commande donne une entrée de surface. Le prototype recherche les trois
|
||||
familles, sans les garantir sur chaque graine. Les neuf types de salles et
|
||||
couloirs plus l’accès sont testés sur des dimensions et ouvertures variées ;
|
||||
les plafonds, appuis de mobilier, orientations et chemins sont contrôlés.
|
||||
|
||||
Les trois smokes dédiés passent. Les huit contrôles moteur du socle passent,
|
||||
avec conservation des cavités et eaux naturelles. Les trois tailles et les deux
|
||||
valeurs de l’option passent le stockage réel des paramètres sur disque.
|
||||
|
||||
La planification des réseaux prend environ une seconde après celle de la ville
|
||||
sur le témoin 0 de ce Mac : 915 colonnes lues et 11 974 sondages, pour des plafonds
|
||||
respectifs de 5 000 et 250 000. Cela ne mesure ni le rendu client ni le chargement
|
||||
complet sur une autre machine. Rapports locaux ignorés :
|
||||
`build/alpha17-evidence/ruins17-create.json` et `ruins17-reload.json`.
|
||||
Logs : `build/alpha17-native-create-final.log`, `alpha17-native-reload.log`,
|
||||
`alpha17-first-native-base.log`, `alpha17-planner-piece-smokes.log`.
|
||||
|
||||
L’essai natif avec **Structures expérimentales désactivées** passe : plan vide,
|
||||
zéro sondage expérimental et booléen conservé. Rapport :
|
||||
`build/alpha17-evidence/ruins17-disabled.json`, log `build/alpha17-native-disabled.log`.
|
||||
La création et la réouverture actives utilisent les processus 32029 et 32123 ; le
|
||||
cas désactivé utilise 32176. Aucune sauvegarde personnelle n’a été ouverte.
|
||||
|
||||
La création et la réouverture des mondes de génération **16** passent sous le
|
||||
binaire 17 : les 71 pièces de la ville initiale et du continent témoin gardent
|
||||
leurs empreintes natives, ainsi que le journal, les paramètres et le bloc d’or
|
||||
témoin de la version publiée. Rapports :
|
||||
`build/alpha17-evidence/legacy16-create.json` et `legacy16-reload.json`.
|
||||
Le preset 16 historique est également identique octet par octet. La comparaison
|
||||
avec les reçus publiés est consignée dans `legacy16-comparison.json` :
|
||||
création sous 17 dans le processus 32244, réouverture dans 32429.
|
||||
|
||||
`./gradlew check build assemblePack` passe : 52 tâches, huit GameTests moteur
|
||||
et les trois smokes du prototype. Le JAR de production exclut les classes de
|
||||
test ; les versions, les hashes du pack, le MRpack et le ZIP d’amorçage Prism
|
||||
sont vérifiés. Journal final : `build/alpha17-final-check-build-pack.log`.
|
||||
|
||||
La publication et la synchronisation de l’instance sont en cours. Aucun essai
|
||||
Windows natif ni revue visuelle dans le client n’est revendiqué ; le prochain
|
||||
essai de Koka validera l’ambiance.
|
||||
+1060
-6
File diff suppressed because it is too large
Load Diff
+167
-11
@@ -28,6 +28,11 @@ Le premier incrément porte sur le socle Fabric et la génération du monde : re
|
||||
|
||||
La progression, l'économie, les nouveaux monstres, les interfaces de pack et les autres dimensions restent des intentions futures. Ils ne constituent pas des dépendances du premier générateur. Le [backlog](backlog.md) précise les premiers tickets et leurs critères d'acceptation.
|
||||
|
||||
L’alpha se poursuit vers les versions 15 ou 16 pour éprouver la génération et
|
||||
ses structures. La bêta véritable commencera ensuite les systèmes de Sanctuary,
|
||||
en particulier la progression. Ce cap décrit un ordre de travail : une version
|
||||
n’est pas considérée terminée avant ses essais et sa livraison.
|
||||
|
||||
## Mod, modpack et modules autonomes
|
||||
|
||||
| Ensemble | Rôle souhaité |
|
||||
@@ -50,18 +55,107 @@ 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 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.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 des plages. Une rivière et de petits étangs ou lacs de
|
||||
surface peuvent accueillir de la canne à sucre. Les grands bassins souterrains
|
||||
remplis d'eau ne sont pas retenus pour cette île. Le terrain doit laisser voir un
|
||||
dessous sculpté et dégagé, dans un monde de **384 blocs de hauteur**, avec des
|
||||
plus sèches, notamment de larges plages et dépôts irréguliers de sable, gravier,
|
||||
pierre et terre enherbée. De petits étangs ou lacs de surface
|
||||
peuvent accueillir de la canne à sucre. Lorsque le relief le permet, une grande
|
||||
rivière suit la forme de l’île, depuis une source jusqu’à une cascade et un
|
||||
bassin, avec des rives de sable, de gravier et d’argile. Ce parcours doit former
|
||||
un lieu reconnaissable ; il reste facultatif. À défaut, l’île conserve ses
|
||||
petits bassins, sans imposer de courts ruisseaux. Le terrain est traité près de la surface,
|
||||
avec des dépôts épais et des rives variées. De rares sources sortent de niches
|
||||
rocheuses naturelles et leurs cascades peuvent descendre jusque dans le vide ;
|
||||
les bassins restent retenus et ne doivent pas provoquer une inondation globale.
|
||||
Une rivière peut avoir des échappements et des chutes déclarés. Les eaux
|
||||
intérieures doivent rester localisées et retenues, sans aquifère qui remplit
|
||||
globalement les cavités. Des bassins naturels étagés, évoquant des rizières,
|
||||
peuvent se relier par des cascades et offrir des passages verticaux par la nage.
|
||||
Cette image ne prévoit aucune culture de riz dans le mod Sanctuary.
|
||||
Le terrain doit laisser voir un dessous sculpté et dégagé, dans un monde de
|
||||
**384 blocs de hauteur**, avec des
|
||||
nuages relevés et sans disque noir artificiel apparaissant sous un seuil d'altitude.
|
||||
|
||||
Le premier champ de recherche des expansions associe les **huit directions**
|
||||
autour de Sanctuary à des signatures de température et d'humidité. En regardant
|
||||
la carte avec le nord en haut : **nord froid, sud chaud, ouest sec et est humide**.
|
||||
Les diagonales combinent ces tendances. La recherche porte sur des climats et
|
||||
leurs distributions, sans imposer la même forme de continent à chaque direction.
|
||||
L’ambiance de départ doit rester douce et tempérée, avec de véritables forêts
|
||||
et des clairières fleuries, quelques zones plus sèches et de larges masses
|
||||
rocheuses mêlant pierre, andésite, diorite et granite. Les nappes de sable et
|
||||
de gravier accompagnent les eaux. Charbon, fer et cuivre doivent aussi pouvoir
|
||||
affleurer sur la roche exposée pour commencer la survie, avec du charbon assez
|
||||
repérable pour fournir les premières expériences. Les failles doivent ouvrir
|
||||
des passages courbes et des vues vers les profondeurs, sans reconstruire une
|
||||
masse artificielle au centre de l’île.
|
||||
|
||||
La géologie change en descendant : tuf, cobblestone, pierres moussues et boue
|
||||
compactée dans les intérieurs humides, puis ardoise des abîmes, roche noire et
|
||||
basalte. Des poches de soufre doivent être réellement explorables et rester
|
||||
ponctuelles, avec de vrais geysers actifs lorsque les conditions s’y prêtent.
|
||||
Quelques sources profondes peuvent former des coulées de lave ; elles ne doivent pas
|
||||
transformer les profondeurs en grands bassins remplis. L’émeraude rejoint les
|
||||
minerais à rechercher, avec l’or, le diamant, la redstone et le lapis.
|
||||
|
||||
Les bois dominants sont le chêne, le chêne noir et le bouleau : chênes et
|
||||
bouleaux en surface, chênes noirs et gros champignons dans l’intérieur plus
|
||||
humide, avec de petits champignons dispersés et des tapis variés de mousse,
|
||||
podzol et mycélium. Épicéa, jungle, acacia, palétuvier et chêne pâle restent rares
|
||||
mais possibles ; du bambou peut se rencontrer dans les profondeurs. Un arbre remarquable isolé peut être un
|
||||
cerisier rose ou une autre de ces essences rares, lorsque le terrain convient.
|
||||
Les biomes très contrastés de Minecraft 26.3, dont Dappled Forest, restent
|
||||
réservés aux futurs continents.
|
||||
|
||||
Les cavités sous l’île doivent aussi accueillir des ambiances luxuriantes
|
||||
avec de la végétation lumineuse, des secteurs à stalactites et stalagmites et
|
||||
des géodes d’améthyste. Leur décor doit utiliser les sols et plafonds du relief,
|
||||
sans reconstruire l’île ni déposer de structures flottantes sans support.
|
||||
L’alpha.9 introduit ces ambiances et recherche des lacs plus profonds et
|
||||
volumineux ; les résultats et leurs limites sont documentés dans les contrôles moteur. Ces cavités appartiennent à
|
||||
l’Overworld de Sanctuary et ne remplacent pas le futur projet de dimension
|
||||
des cavernes.
|
||||
|
||||
L’île doit offrir les ressources nécessaires aux premières étapes de Minecraft,
|
||||
en quantités modestes pour pousser à explorer les expansions lors des grands
|
||||
projets. Les minerais doivent être trouvables aux altitudes du terrain. Les
|
||||
stocks sont observés après génération et les ressources renouvelables gardent
|
||||
leurs règles Minecraft. Le réglage précis de ces quantités reste à éprouver en
|
||||
partie coopérative. La recherche de l’alpha.8 porte notamment sur une rivière
|
||||
calme continue reliant deux bassins au même niveau, alimentée par une source
|
||||
en paroi et sa cascade. Son tracé doit former de larges courbes, avec une
|
||||
largeur variable et des berges progressives. La petite réserve de lave est
|
||||
recherchée d’abord dans les étages profonds ; un site plus haut reste possible
|
||||
si la roche ne fournit pas de niche compatible en dessous. Ces formes restent
|
||||
facultatives selon le relief ; leur existence doit être démontrée en jeu.
|
||||
L’état de chaque incrément et ses limites
|
||||
figurent dans [Génération](worldgen.md).
|
||||
|
||||
La progression des expansions se fait par **choix en branchement**. La direction
|
||||
géographique, le climat et l’archétype sont des choix distincts. La convention
|
||||
**nord froid, sud chaud, ouest sec et est humide** reste une voie possible, avec
|
||||
ses diagonales. Le joueur peut aussi ouvrir une branche alternative et composer
|
||||
des couronnes de nouvelles îles aux climats différents. Une île enregistre son
|
||||
parent ; le serveur conserve ainsi 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.
|
||||
|
||||
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,
|
||||
@@ -77,9 +171,71 @@ L'ambition est de reprendre puis d'étendre la centaine de biomes du catalogue T
|
||||
|
||||
### Structures et territoires
|
||||
|
||||
- **Lost Cities** : des zones traitées comme des biomes, où des bâtiments en ruine remplacent la végétation dominante. Le nom exprime ici un type de territoire souhaité, sans décider encore s'il sera produit par une intégration ou par du code Sanctuary.
|
||||
- **Structures contemporaines** : parcs, parkings et autres vestiges. Les parkings sont souterrains et peuvent s'étendre comme une infestation. Ils n'apparaissent pas dans la dimension des cavernes.
|
||||
- **Structures vanilla** : admises dans les nouvelles générations lorsque leur
|
||||
biome, leur emprise et leurs fondations conviennent au terrain flottant.
|
||||
L’autorisation ne garantit pas leur présence sur chaque île. Le laboratoire
|
||||
commence par les villages, temples, cabanes de marais et camps abandonnés.
|
||||
- **Lost City** : des quartiers traités comme un biome, où les bâtiments
|
||||
contemporains en ruine prennent la place principale. L’alpha.14 développe une
|
||||
réalisation originale Sanctuary, inspirée du principe de ville abandonnée,
|
||||
sans ajouter le mod The Lost Cities ni reprendre ses assets. L’alpha.16 garantit une petite ville compacte sur la nouvelle île de départ,
|
||||
lorsque les structures sont activées, puis admet des quartiers conditionnels
|
||||
dans les continents d’expansion, en laissant
|
||||
une majorité naturelle à l’île initiale. La pierre, la pierre lisse et les
|
||||
briques de pierre, notamment fissurées et moussues, donnent une palette sobre.
|
||||
- **Vie urbaine passée** : routes, ponts, places, marchés, appartements,
|
||||
bibliothèques, restaurants et cuisines abandonnées, hôtels et chambres,
|
||||
casernes, halls, excavations et piscines. Les parcs comprennent boisements,
|
||||
jardins à haies et fleurs, aires de jeux et mémoriaux. Chaque usage doit se
|
||||
reconnaître dans les volumes et les aménagements, avec entrées et circulations.
|
||||
La passe alpha.15 vise des pièces réellement séparées et aménagées : seuil,
|
||||
vestibule, hall, corridors, escaliers, chambres, sols, plafonds et ornements.
|
||||
Les rues, parcs et bâtiments doivent être incrustés dans le sol. Les bâtiments
|
||||
se regroupent en îlots denses avec rues et carrefours continus, sans mottes
|
||||
de terre entre les façades et leurs accès. Les ponts et viaducs prolongent
|
||||
le réseau urbain sur les franchissements naturels admissibles.
|
||||
- **Parkings et sous-sols** : creuser seulement là où l’épaisseur de roche
|
||||
permet un volume soutenu ; un garage en surface reste possible ailleurs.
|
||||
Les excavations ouvertes et les piscines doivent aussi garder des fondations,
|
||||
des accès et, pour l’eau, un bassin contenu. Les parkings ne font pas partie
|
||||
de la future dimension des cavernes. L’alpha.15 travaille des égouts et pool
|
||||
rooms en réseau, procéduralement creusés sous la ville,
|
||||
avec des passages et des accès de surface. Une section suspendue exige des
|
||||
chaînes ou des pylônes ancrés. Leur présence reste liée au terrain ; le
|
||||
suivi de validation distingue les intentions des preuves. L’alpha.16 ajoute
|
||||
un bassin couvert de 50 × 25 blocs, des plateformes de parking jusqu’à huit
|
||||
niveaux et des exutoires ouverts avec écoulement natif. Les étages alternent
|
||||
leurs plans, les sanitaires sont clos et les lits adossés aux murs. Seule la
|
||||
zone urbaine initiale est garantie ; piscine et autres grands équipements
|
||||
dépendent du terrain et des appuis. Voir [la validation alpha.16](testing-alpha16.md).
|
||||
- **Future salle d’expansion** : une grande salle sèche reliée aux tunnels
|
||||
pourrait accueillir une machine diégétique. Un socle et des cadres porteraient
|
||||
des cartes déjà découvertes, présentant l’ensemble de l’île en vue du dessus.
|
||||
L’alpha prépare la salle ; la machine, les cartes préremplies et les
|
||||
interactions d’expansion seront définies et implémentées dans un ticket suivant.
|
||||
- **Occupants de Lost City** : des zombies plutôt que des villageois, sans
|
||||
spawner. L’alpha utilise le spawn naturel des zombies vanilla ; les infectés,
|
||||
spitters, boomers, hunters, chargeurs et autres classes arrivent avec les
|
||||
systèmes de la bêta. La présence de zombies dépend des règles du jeu.
|
||||
- **Structures uniques et donjons** : d'abord sur Sanctuary Island, puis dans les continents. Ils peuvent contenir des clés de chunk et d'autres ressources rares.
|
||||
- **Sanctuary était habitée avant nous** (orientation validée après l’alpha.15) :
|
||||
développer les trois familles ensemble : mines et ateliers, lieux étranges,
|
||||
infrastructures à réactiver. Les ruines doivent raconter une occupation
|
||||
antérieure par d’autres joueurs, à travers leurs usages et leurs circulations.
|
||||
Plusieurs réseaux distincts peuvent se rejoindre ponctuellement : découvrir
|
||||
ces connexions fait partie de l’exploration. Privilégier les installations
|
||||
encore utilisables et les chantiers interrompus pour raconter cette histoire.
|
||||
Les back rooms de ces donjons sont des lieux physiques dans l’île, distincts
|
||||
de la dimension Backrooms recevant les objets perdus. Le futur butin combine
|
||||
des reliques collectives uniques et des récompenses personnelles ; les
|
||||
spawners pourront rester exploitables après exploration sans régénérer les
|
||||
donjons. Les règles précises restent à concevoir. Le réacteur et ces nouveaux
|
||||
systèmes ne font pas partie de l’alpha.16.
|
||||
Le prototype alpha.17 commence par trois petits réseaux indépendants, avec
|
||||
accès de surface, ateliers, galeries, salles étranges et locaux techniques.
|
||||
L’option « Structures expérimentales » se choisit pour un nouveau monde.
|
||||
Les connexions entre familles et les systèmes de butin spécifiques restent
|
||||
à construire ; voir [le contrat du prototype](testing-alpha17.md).
|
||||
- **Secret rooms et challenge rooms** : des accès présents dans le monde mènent à des indoors aménagés, avec des équipements, des épreuves ou des gisements de fer, d'or, d'argent ou de titane.
|
||||
|
||||
### Dimensions
|
||||
|
||||
+679
-31
@@ -1,63 +1,424 @@
|
||||
# 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
|
||||
|
||||
## Créer un monde
|
||||
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.
|
||||
|
||||
En solo : créer un **nouveau** monde et choisir le type de monde **Sanctuary**
|
||||
dans les options de génération. Les types de monde vanilla restent disponibles.
|
||||
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é.
|
||||
|
||||
Sur un nouveau serveur Fabric disposant du mod et de Fabric API :
|
||||
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.
|
||||
|
||||
## Créer un nouveau monde
|
||||
|
||||
En solo, choisir **Sanctuary**, ouvrir **Personnaliser**, puis confirmer la
|
||||
taille avec **Terminé**. **Annuler** conserve le réglage précédent. Cette
|
||||
interface configure le monde à créer ; elle ne redimensionne pas une partie.
|
||||
|
||||
Sur un nouveau serveur Fabric avec Fabric API, le preset public utilise la
|
||||
taille **Moyen**, 724 blocs :
|
||||
|
||||
```properties
|
||||
level-type=sanctuary:sanctuary
|
||||
level-name=sanctuary
|
||||
level-name=sanctuary-dev
|
||||
level-seed=0
|
||||
```
|
||||
|
||||
La seed est libre. Modifier `level-type` ne convertit pas un monde existant :
|
||||
utiliser un nouveau `level-name` pour tester une autre génération. Les fichiers
|
||||
de test et les sauvegardes personnelles doivent rester distincts.
|
||||
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.
|
||||
|
||||
## Ce qui est généré
|
||||
## 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é
|
||||
|
||||
Les points suivants décrivent le socle livré jusqu’à l’alpha.8. Les évolutions
|
||||
alpha.9 et alpha.10 sont précisées dans les sections v9 et v10 ci-dessous.
|
||||
|
||||
- Le générateur reste celui de Minecraft, `minecraft:noise`.
|
||||
- La forme reprend le champ de bruit flottant actif en 26.2 : `old_blended_noise`
|
||||
avec `xz_scale=0.25`, `y_scale=0.25`, `xz_factor=80`, `y_factor=160`, `smear=4`,
|
||||
ainsi que son gradient supérieur. Il est échantillonné **64 blocs plus bas**
|
||||
pour relever l'île entière de 64 blocs. Reliefs, surplombs, trous et fragments
|
||||
dépendent de la seed Minecraft. L'ancien gradient inférieur uniforme est
|
||||
dépendent de la seed Minecraft. L’alpha.8 y soustrait des failles courbes,
|
||||
sans ajouter de matière. L'ancien gradient inférieur uniforme est
|
||||
remplacé par la sculpture irrégulière décrite ci-dessous.
|
||||
- Le terrain naturel reste libre de laisser un trou à l'origine, de former des
|
||||
surplombs ou de se séparer en fragments. **Aucun noyau ellipsoïdal, cylindre ou
|
||||
plateforme de départ n'est ajouté.** Au-dessus de `Y=144`, le relief de la
|
||||
génération précédente est conservé, simplement relevé de 64 blocs.
|
||||
plateforme de départ n'est ajouté.** Le relief relevé de 64 blocs reste le
|
||||
champ source ; les failles de l’alpha.8 en retirent de la matière et
|
||||
l’hydrologie traite ensuite les lits et les rives près de la surface.
|
||||
- Le pourtour est sculpté par retrait de matière à partir du rayon **128 blocs**.
|
||||
Le contour de référence de 256 blocs est déformé par les bruits, entre 204 et
|
||||
288 blocs ; il indique la fin de l'érosion, pas un mur de roche.
|
||||
La côte réelle apparaît plus tôt, selon la densité du terrain et la seed.
|
||||
Il reste du vide permanent au-delà de **288 blocs**, sans répétition d'îles.
|
||||
Aucun terrain ne réapparaît au-delà de **288 blocs**, sans répétition d'îles.
|
||||
Les arbres peuvent dépasser la côte rocheuse de quelques blocs.
|
||||
- Les nouveaux mondes utilisent **384 blocs de hauteur**, de `Y=0` à `Y=383`,
|
||||
pour la dimension et le générateur. La roche disparaît avant leurs limites.
|
||||
Il n'y a ni plancher de bedrock,
|
||||
ni mer globale, ni reprise du terrain à distance. Le Nether et l'End restent
|
||||
ceux de Minecraft.
|
||||
- `sanctuary:starter_forest` reprend végétation, minerais et animaux de la forêt
|
||||
vanilla 26.3. Ses blocs sont exclusivement vanilla. C'est un biome distinct
|
||||
pour ne pas hériter des tags de structures vanilla et de leurs apparitions
|
||||
possibles dans le vide.
|
||||
- Les carvers, géodes, donjons, lacs de lave et sources sont retirés de cette
|
||||
forêt initiale. Les minerais remplacent la roche existante et les arbres ont
|
||||
besoin de sol. Aucun mod de biomes externe n'est nécessaire.
|
||||
- Les nouveaux mondes utilisent une palette tempérée de forêts de chênes et de
|
||||
bouleaux, de clairières fleuries et de zones rocheuses. Les strates intérieures
|
||||
portent des chênes noirs et des champignons sur leurs corniches naturelles.
|
||||
Dappled Forest est réservé aux futurs continents. Leur végétation et leurs animaux proviennent
|
||||
de Minecraft 26.3-pre-2. Les nouveaux identifiants ne reprennent pas les tags
|
||||
de structures vanilla, afin de ne pas placer leurs structures dans le vide.
|
||||
- Jusqu’à l’alpha.8, les carvers, géodes, donjons, grands lacs de lave et sources
|
||||
vanilla sont retirés. L’alpha.9 ajoute une intégration bornée de géodes et
|
||||
de geysers, sans réactiver l’ensemble de la décoration souterraine vanilla.
|
||||
L’eau de Sanctuary suit son propre plan de surface et de niches
|
||||
rocheuses. Les arbres et les petits filons ont besoin du terrain existant.
|
||||
Aucun mod de biomes externe n’est nécessaire.
|
||||
|
||||
Ce premier prototype ne fournit pas encore d'eau : hydrologie, répartition
|
||||
complète des ressources de survie et variété de biomes constituent le prochain
|
||||
ticket de terrain. Les minerais vanilla conservent leurs plages d'altitude ;
|
||||
leur abondance n'est donc pas encore équilibrée pour une île flottante.
|
||||
L’alpha.8 associe ces biomes aux plages, aux eaux et aux filons décrits
|
||||
ci-dessous. Les ressources de survie ne se limitent pas au bois et à la pierre,
|
||||
mais cet incrément ne garantit pas encore une partie Minecraft complète sur
|
||||
chaque seed. L’accès à chaque progression vanilla et les ressources renouvelables
|
||||
nécessitent des vérifications concrètes. Les stocks réels sont relevés après la
|
||||
génération, sans corriger une île pour atteindre un quota.
|
||||
|
||||
## Capacité de l’île — génération v10 historique
|
||||
|
||||
Lors de l’alpha.10, trois tailles étaient accessibles à la création d’un monde.
|
||||
Le choix **Sanctuary** visait 20 joueurs par défaut, accompagné de variantes
|
||||
pour 5 et 100 joueurs. Le tableau suivant conserve cette association historique ;
|
||||
l’alpha.11 utilise le nouveau défaut Grand indiqué plus haut :
|
||||
|
||||
| Preset de création | Paramètres enregistrés dans le monde |
|
||||
| --- | --- |
|
||||
| `sanctuary:sanctuary_5` | `sanctuary:population_5` |
|
||||
| `sanctuary:sanctuary` | `sanctuary:population_20` |
|
||||
| `sanctuary:sanctuary_100` | `sanctuary:population_100` |
|
||||
|
||||
Les nouveaux réglages restent distincts des identifiants enregistrés dans
|
||||
les anciennes sauvegardes. Modifier le preset
|
||||
sélectionné pour une nouvelle partie ne convertit pas un monde existant.
|
||||
|
||||
| Capacité de référence | Facteur horizontal | Aire nominale relative | Diamètre nominal | Hauteur |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 5 joueurs | 1 | 1 | 512 blocs | 384 blocs |
|
||||
| 20 joueurs, défaut | 2 | 4 | 1 024 blocs | 384 blocs |
|
||||
| 100 joueurs | √20, environ 4,472 | 20 | Environ 2 290 blocs | 384 blocs |
|
||||
|
||||
Le facteur horizontal vaut `√(capacité / 5)` : doubler le diamètre multiplie
|
||||
l’aire nominale par quatre. Cette relation décrit l’enveloppe de référence,
|
||||
pas la surface réellement constructible après les trous, les falaises et
|
||||
la sculpture de la bordure. Aucun cylindre central ni remplissage de secours
|
||||
ne doit être ajouté pour obtenir l’aire théorique. La hauteur de dimension
|
||||
reste `Y=0..383`.
|
||||
|
||||
La capacité choisie reste un paramètre de génération. Elle ne modifie pas
|
||||
`max-players`, n’agrandit pas l’île à chaque connexion et ne fixe aucun quota
|
||||
de ressources. Les anciens mondes jusqu’à l’alpha.9 gardent leur génération
|
||||
et leur taille ; leurs chunks ne sont ni remplacés ni régénérés. Le projet
|
||||
de continents déverrouillables reste séparé de ces tailles de départ.
|
||||
|
||||
Le bruit naturel reste échantillonné aux coordonnées du monde ; seule
|
||||
l’enveloppe extérieure change de taille. Les plans hydrauliques sont construits
|
||||
à la demande dans des régions de 512 blocs, avec une graine régionale distincte.
|
||||
Les marges d’admission sont de 16 blocs pour l’eau et 32 pour la lave. Bassins,
|
||||
berges et groupes de terrasses sont admis entiers ; les sources dépendant d’une
|
||||
coupe écartée sont retirées aussi. Aucun trajet n’est tronqué à la frontière.
|
||||
Les couches superposées et leurs sources sont ensuite traduites ensemble.
|
||||
|
||||
Les forêts, minerais et décorations utilisent toute l’emprise du profil.
|
||||
La recherche facultative d’un arbre remarquable reste unique et centrale.
|
||||
Les sources et exutoires n’agissent que pendant la génération, avec les
|
||||
marqueurs vanilla consommés ; recharger un ancien chunk ne rouvre pas une bouche.
|
||||
|
||||
Les trois tailles passent onze tests requis sur la graine `0`, avec 1 800 ticks
|
||||
de fluides et un aller-retour réel des paramètres sur disque. Le fichier
|
||||
`data/minecraft/world_gen_settings.dat` conserve la clé du profil choisi.
|
||||
212 anciens fichiers de production sont strictement identiques, tandis que
|
||||
huit fichiers d’intégration ou d’interface exposent les nouvelles générations.
|
||||
Le premier calcul d’une région reste coûteux ; les résultats ne sont pas un
|
||||
test de cent connexions ni une garantie de génération rapide de toute l’île.
|
||||
Voir les emprises et durées dans [Validation](testing.md).
|
||||
|
||||
## Cavités et terrasses d’eau — génération v9
|
||||
|
||||
Le nouveau réglage est `sanctuary:sanctuary_cavern`, avec le biome source
|
||||
`sanctuary:cavern_island` et la règle de matière `sanctuary:cavern_island`.
|
||||
La densité réutilise `sanctuary:final_density_rift` sans changer cet identifiant
|
||||
historique. Ses nouvelles données et traitements
|
||||
doivent rester distincts de `sanctuary:sanctuary_rift` et de toutes les
|
||||
générations précédentes. Le preset sélectionne la nouvelle génération à la
|
||||
création ; une mise à jour du JAR ne convertit pas une sauvegarde ni ne
|
||||
régénère ses chunks. Les cavités restent dans l’Overworld sur 384 blocs de
|
||||
hauteur ; la dimension des cavernes décrite dans la vision n’est pas ajoutée.
|
||||
|
||||
### Ambiances et ressources
|
||||
|
||||
Le ticket conserve les forêts de surface et ajoute des cavités luxuriantes
|
||||
éclairées par leur végétation, des secteurs à spéléothèmes et des géodes
|
||||
d’améthyste sur des emplacements compatibles. Les sols, plafonds, coquilles
|
||||
et ouvertures doivent être observés après la décoration réelle : une valeur
|
||||
de biome ne prouve ni le décor ni son éclairage. L’objectif est d’utiliser
|
||||
les volumes naturels de l’île, sans remplir ses trous pour accueillir une
|
||||
structure.
|
||||
|
||||
Les dix biomes ont leurs propres identifiants : `cavern_oak_forest`,
|
||||
`cavern_birch_forest`, `cavern_clearing`, `cavern_dry_woodland`,
|
||||
`cavern_rocky_heath`, `cavern_dark_grove`, `cavern_bamboo_grove`,
|
||||
`cavern_sulfur_depths`, `cavern_lush_caves` et `cavern_dripstone_caves`, dans
|
||||
le namespace `sanctuary`. Les deux nouvelles ambiances luxuriantes et à
|
||||
spéléothèmes sont limitées à Y<184 par la palette ; leur décoration recherche
|
||||
des sols entre Y=40 et Y=182, avec trois blocs naturels de support et un
|
||||
plafond situé de 6 à 48 blocs au-dessus.
|
||||
|
||||
Dans les cavités luxuriantes, la mousse remplace le sol compatible et de
|
||||
petites plantes l’accompagnent. Les vraies lianes `cave_vines` et
|
||||
`cave_vines_plant` pendent sur 3 à 8 blocs ; leurs baies émettent de la lumière
|
||||
et restent récoltables. Ce décor n’ajoute pas d’eau. Les spéléothèmes utilisent
|
||||
les blocs `pointed_dripstone` orientés, sur un appui de `dripstone_block`
|
||||
remplaçant de la roche existante, avec une longueur de 1 à 4 blocs selon
|
||||
l’espace disponible.
|
||||
|
||||
La géode utilise une configuration vanilla compacte avec améthyste,
|
||||
améthyste bourgeonnante, calcite et basalte lisse. Son enveloppe doit tenir
|
||||
dans la roche préexistante et un passage de deux blocs de haut la relie à
|
||||
la cavité, en préservant l’améthyste bourgeonnante et les cristaux attachés.
|
||||
Un chunk sur huit est candidat ; au plus une pose réussie est retenue par
|
||||
chunk candidat. Cela ne garantit pas de géode sur une graine.
|
||||
|
||||
Le soufre devient admissible dans une bande climatique plus étroite sous
|
||||
Y=176. La réduction de cette sélection peut être testée indépendamment du
|
||||
terrain ; seule une comparaison des vrais blocs sur un périmètre commun
|
||||
mesure sa diminution dans l’île. Un geyser éventuel creuse un seul bloc de
|
||||
sol pour sa source d’eau, avec `potent_sulfur` dessous et `magma_block` encore
|
||||
en dessous. Le fond, les quatre parois et la hauteur libre sont contrôlés.
|
||||
Le cycle du geyser reste celui de Minecraft ; ce décor est distinct des
|
||||
bassins hydrologiques et son eau doit être comptée séparément.
|
||||
|
||||
Leur présence, leur activité, l’éclairage et la cohabitation avec les eaux
|
||||
et la végétation sont contrôlés dans les mondes de test. Les preuves de ces
|
||||
éléments sont cumulées sur les trois graines. Un site incompatible peut
|
||||
être rejeté ; aucun geyser ni stock n’est garanti pour chaque graine.
|
||||
|
||||
### Lacs et bassins étagés
|
||||
|
||||
Le plan hydrologique conserve la recherche de rivière de
|
||||
l’alpha.8 et vise de plus grands lacs de surface. Les cibles actuelles sont
|
||||
de l’ordre de **700 à 1 100 colonnes** par grand lac, jusqu’à **7 à 8 blocs**
|
||||
de profondeur intérieure et une incision limitée à **12 blocs**. Ce sont des
|
||||
bornes de recherche ; les dimensions réelles sont relevées séparément, sans quota d’eau à
|
||||
remplir. Les fonds, les parois et les dépôts exigent du terrain naturel.
|
||||
|
||||
Les terrasses sont recherchées dans des cavités entre **Y=56 et Y=208**,
|
||||
avec deux paliers par groupe et jusqu’à deux groupes facultatifs.
|
||||
Un palier vise environ **40 à 180 colonnes**, une profondeur de **1 à 3 blocs**
|
||||
et une incision d’au plus **5 blocs**. Le fond doit conserver trois couches
|
||||
de sédiments et deux couches de support naturel ; le plafond et l’espace
|
||||
disponible sont vérifiés. Les mesures moteur précisent les sites effectivement retenus.
|
||||
|
||||
La recherche suit le choix de pente de l’eau vanilla : parmi les passages
|
||||
libres, elle privilégie ceux qui rejoignent la chute la plus proche. Un
|
||||
bassin inférieur doit intercepter ce trajet réel, sans détour creusé pour
|
||||
le rejoindre. Les sorties entre paliers sont déclarées et les cascades sont formées par les
|
||||
ticks de fluides Minecraft. Le premier débordement doit atteindre le second
|
||||
bassin. Celui-ci peut avoir un exutoire terminal qui s’écoule sur les roches
|
||||
ou vers le vide, sans troisième bassin ajouté. Le générateur ne doit ni peindre une colonne
|
||||
d’eau jusqu’au vide ni colmater un trou pour retenir un bassin. Les volumes
|
||||
retenus et les liaisons réelles sont vérifiés séparément après décoration
|
||||
et simulation, y compris aux frontières de chunks. Les cascades peuvent
|
||||
servir à monter ou descendre selon la nage vanilla ; aucune mécanique
|
||||
d’ascenseur supplémentaire n’est prévue. « Rizières » décrit seulement
|
||||
l’aspect du paysage en terrasses, sans ajouter une culture de riz.
|
||||
|
||||
**Validation moteur au 9 septembre 2026 :** les trois graines de référence
|
||||
passent les contrôles après 1 800 ticks, avec preuves cumulées des terrasses
|
||||
connectées, geysers actifs, baies lumineuses, spéléothèmes et améthyste
|
||||
bourgeonnante. Le build complet réussit. Les résultats et leurs limites sont
|
||||
consignés dans [Validation](testing.md). Aucune présence universelle n’est
|
||||
garantie. La [distribution](packwiz.md) est publiée et la même instance Prism
|
||||
est synchronisée, sauvegardes et réglages conservés.
|
||||
|
||||
## Failles, géologie et ressources — génération v8
|
||||
|
||||
Les nouveaux mondes utilisent `sanctuary:sanctuary_rift`, la densité
|
||||
`sanctuary:final_density_rift` et le biome source `sanctuary:rift_island`.
|
||||
Les anciens paramètres `sanctuary`, `sanctuary_384`, `sanctuary_hydrology`,
|
||||
`sanctuary_natural`, `sanctuary_layered` et `sanctuary_woodland` restent séparés.
|
||||
Changer le preset ne migre pas un monde existant ; aucune régénération de
|
||||
chunks ni modification de sauvegarde n’est prévue par cet incrément.
|
||||
|
||||
### Failles et palette
|
||||
|
||||
`RiftShape` définit une à trois traces principales, courbes et ramifiées,
|
||||
de longueur nominale 84 à 160 blocs. Leur largeur varie avec deux bruits en
|
||||
trois dimensions ; le cœur ouvert est de l’ordre de 4 à 12 blocs, avec des
|
||||
parois érodées autour. Ces nombres décrivent les traces avant leur intersection
|
||||
avec la roche, pas une garantie de canyon continu de cette taille. La passe
|
||||
retire seulement de la matière et laisse intactes les zones de rayon inférieur
|
||||
à 20 blocs et extérieur à 224 blocs. Elle ne crée aucun socle central.
|
||||
|
||||
Les huit biomes `rift_oak_forest`, `rift_birch_forest`, `rift_clearing`,
|
||||
`rift_dry_woodland`, `rift_rocky_heath`, `rift_dark_grove`, `rift_bamboo_grove`
|
||||
et `rift_sulfur_depths` ont de nouveaux identifiants dans le namespace
|
||||
`sanctuary`. Les choix climatiques de l’alpha.7 sont conservés en haut. Le
|
||||
soufre devient admissible dans des régions plus larges des étages inférieurs,
|
||||
notamment vers Y=176, selon les bruits de variation et d’humidité. Le test de
|
||||
palette prouve cette possibilité ; seule la lecture des vrais blocs peut
|
||||
prouver une poche accessible sur une graine.
|
||||
|
||||
La végétation vanilla de surface garde ses arbres et ses teintes. La feature
|
||||
`rift_groves` passe ensuite, avant `rift_shore_sugar_cane`, pour choisir les
|
||||
volumes inférieurs encore libres après les arbres. Elle peut placer de gros
|
||||
champignons vanilla, avec des petits champignons dispersés et des sols mêlant
|
||||
mousse, podzol et mycélium. Les supports naturels et la place disponible
|
||||
restent nécessaires ; un arbre remarquable peut échouer si son site est occupé.
|
||||
L’ordre des plantations intérieures donne la priorité aux arbres dans trois
|
||||
cas sur quatre et aux gros champignons dans l’autre cas, selon un choix lié
|
||||
au chunk. Cela laisse des volumes disponibles aux chênes noirs. Dans les
|
||||
biomes `sanctuary:rift_dark_grove` et `sanctuary:rift_bamboo_grove`, l’ardoise
|
||||
des abîmes peut aussi fournir le support naturel des sols et bosquets
|
||||
inférieurs. Son admissibilité est vérifiée au-dessus de chaque pied : seules
|
||||
ces deux variantes humides autorisent ce support.
|
||||
|
||||
### Matières selon la profondeur
|
||||
|
||||
La règle `sanctuary:rift_island` ne change que la matière de blocs déjà solides.
|
||||
Ses nappes utilisent un bruit 3D, avec un bruit distinct pour faire varier les
|
||||
transitions de profondeur.
|
||||
|
||||
| Domaine | Matières |
|
||||
| --- | --- |
|
||||
| Masses supérieures | Pierre, granite, andésite, diorite ; sols enherbés hors affleurements |
|
||||
| Intérieur des bosquets humides | Tuf, cobblestone, cobblestone moussue, boue compactée |
|
||||
| Masses profondes | Ardoise des abîmes, roche noire, basalte |
|
||||
| Poches soufrées | Soufre et cinabre ordinaires sur les masses existantes |
|
||||
|
||||
Les strates humides se mêlent aux matières hautes entre Y=192 et Y=224 dans
|
||||
leurs biomes ; la transition vers les roches profondes s’étend de Y=136 à Y=168.
|
||||
La profondeur sert de critère géologique : ces blocs ne signifient pas qu’une
|
||||
source de lave se trouve forcément à proximité. Aucun bassin, spike, source
|
||||
vanilla ou bloc de soufre puissant du biome Sulfur Caves n’est ajouté.
|
||||
|
||||
Les anciens petits dépôts vanilla de granite, andésite, diorite et tuf sont
|
||||
remplacés par ces nappes dans les seuls biomes `rift_*`. Les anciennes données
|
||||
de biomes et leurs features ne sont pas modifiées.
|
||||
|
||||
### Minerais
|
||||
|
||||
Les features `rift_ore_*` ciblent des tags Sanctuary locaux : les pierres hautes
|
||||
et humides produisent les minerais ordinaires ; ardoise des abîmes, roche noire
|
||||
et basalte produisent leurs variantes d’ardoise. Les tags vanilla ne sont pas
|
||||
élargis globalement. Le soufre et le cinabre restent des matières distinctes.
|
||||
|
||||
| Minerai | Taille du filon configurée | Essais principaux par chunk | Altitudes principales |
|
||||
| --- | ---: | --- | --- |
|
||||
| Charbon | 9 | 8 | Y=96..304 |
|
||||
| Fer | 7 | 4 | Y=88..280 |
|
||||
| Cuivre | 7 | 3 | Y=96..280 |
|
||||
| Or | 5 | 2 | Y=88..224 |
|
||||
| Redstone | 5 | 2 | Y=88..216 |
|
||||
| Lapis | 4 | 1 | Y=104..240 |
|
||||
| Diamant | 4 | Un essai une fois sur deux | Y=80..208 |
|
||||
| Émeraude | 3 | 1 | Y=104..224 |
|
||||
|
||||
Les hauteurs suivent une distribution en trapèze. Charbon, fer et cuivre
|
||||
reçoivent aussi respectivement 4, 2 et 2 essais sur roche supérieure exposée,
|
||||
et autant de recherches de corniche depuis l’air entre Y=64 et Y=288. Un essai
|
||||
peut ne produire aucun bloc. Les tailles configurées ne sont ni des quantités
|
||||
garanties par filon ni des quotas par île. L’exposition à l’air est autorisée
|
||||
pour ces trois minerais et l’émeraude ; une partie des candidats exposés est
|
||||
encore écartée pour les autres minerais. Les stocks devront être relevés après
|
||||
génération, avec leur emprise explicite.
|
||||
|
||||
### Eaux et lave
|
||||
|
||||
`RiftHydrology` cherche des bassins plus grands et une longue liaison calme
|
||||
sur la vraie densité fissurée. Le critère de cet incrément est un chemin d’eau
|
||||
continu d’au moins 140 blocs entre deux bassins au même niveau, alimenté par
|
||||
une source rocheuse et sa cascade entrante. Il ne s’agit pas d’une rivière
|
||||
descendant continuellement en altitude. La recherche parcourt huit directions
|
||||
et pondère les trajets selon le relief et un bruit ; le parcours retenu est
|
||||
lissé sur une échelle de 16 à 32 blocs, puis arrondi avant sa conversion en
|
||||
blocs. Le lit varie entre environ 5 et 7 blocs de largeur, hors élargissement
|
||||
des bassins. Le chemin et ses marges sont à nouveau vérifiés sur la roche
|
||||
après ce lissage : une courbe ne peut pas servir à franchir un trou.
|
||||
|
||||
La roche doit soutenir le fond et les berges. L’incision du lit reste limitée
|
||||
à 16 blocs. Les berges de rivière peuvent retirer jusqu’à 12 blocs pour
|
||||
rejoindre l’eau par paliers progressifs ; les autres rives conservent leur
|
||||
limite de 2 blocs. Les dépôts de 3 à 5 couches ont toujours besoin de supports
|
||||
naturels. Aucun remblai ne franchit une faille ou un trou. Si aucun parcours
|
||||
compatible n’est retenu, le générateur garde les bassins et sources qui
|
||||
conviennent au relief.
|
||||
|
||||
Le calcul initial du plan reste coûteux : les essais finaux sur ce poste
|
||||
mesurent 80,5 secondes pour la graine `0`, 50,7 pour `42` et 59,7 pour
|
||||
`8675309`, hors génération des chunks. Le résultat est mis en cache par état
|
||||
aléatoire du monde ; il n’est pas recalculé pour chaque chunk. L’optimisation
|
||||
de ce premier calcul reste un travail ultérieur.
|
||||
|
||||
La recherche de lave profonde vise une à deux petites sources entre Y=40 et
|
||||
Y=160, en complément de la niche couverte accessible. Cette poche de 18 blocs
|
||||
sources est recherchée d’abord sur une corniche intérieure entre Y=64 et
|
||||
Y=160. Si aucun site profond ne réunit les conditions de support, de voûte
|
||||
et d’accès, la recherche reprend sur les corniches plus hautes, jusqu’à Y=300.
|
||||
Il n’y a pas de poche créée de force lorsque les deux recherches échouent.
|
||||
Les sources supplémentaires laissent Minecraft former leurs coulées par ses
|
||||
ticks, sans colonne préremplie. L’eau, les supports et les marges de végétation doivent être
|
||||
contrôlés après décoration et simulation. La finition des bouches doit rester
|
||||
limitée à la première génération du chunk, sans effacer un aménagement joueur
|
||||
lors d’un rechargement.
|
||||
|
||||
**Validation du 9 septembre 2026 :** le build complet réussit et les contrôles
|
||||
moteur passent sur les trois graines de référence. Les failles, les bassins terminaux,
|
||||
les liaisons de rivière, les champignons et les fluides après 1 800 ticks sont
|
||||
documentés avec les relevés de ressources dans [Validation](testing.md).
|
||||
Ces échantillons ne garantissent pas la présence de chaque ressource sur
|
||||
toutes les graines. Les rapports intermédiaires ne servent pas de preuve
|
||||
pour la version finale ; aucun monde personnel n’est utilisé par ces tests.
|
||||
|
||||
## Sculpture du pourtour — conservée depuis la génération v2
|
||||
|
||||
@@ -118,8 +479,9 @@ ce gradient inférieur. Une nouvelle érosion travaille sur une transition de
|
||||
deux bruits de sculpture existants. Elle laisse les lobes naturels se poursuivre
|
||||
vers le bas, puis les amincit à différentes hauteurs ; elle n'ajoute ni socle ni
|
||||
surface garantie. La transition n'affecte plus le terrain à partir de `Y=144`.
|
||||
Une marge vide protège le bas de la dimension ; le contrôle des chunks terminés
|
||||
vérifie que la tranche `Y=0..15` ne contient aucun bloc.
|
||||
Une marge vide protège le bas de la dimension : aucun plancher rocheux ne doit
|
||||
apparaître dans `Y=0..15`. À partir de l’alpha.5, seules les eaux descendantes de
|
||||
sources explicitement déclarées peuvent traverser cette marge.
|
||||
|
||||
Les champs optionnels du codec `vertical_offset`, `height` et `underside` valent
|
||||
respectivement `0`, `256` et un bruit nul par défaut. Seule la nouvelle fonction
|
||||
@@ -134,6 +496,227 @@ ou régénéré. Il n'existe pas de migration automatique des anciens mondes ver
|
||||
dimension de 384 blocs. Changer manuellement leurs références de générateur ou
|
||||
de dimension sort de ce contrat de compatibilité.
|
||||
|
||||
## Hydrologie de surface — génération v4
|
||||
|
||||
La version **0.1.0-alpha.4** conserve la densité, la hauteur et le ciel de
|
||||
l’alpha.3. Les nouveaux mondes sélectionnent les paramètres
|
||||
`sanctuary:sanctuary_hydrology` et le biome `sanctuary:starter_forest_hydrology`.
|
||||
Le type de dimension reste `sanctuary:sanctuary_384`. Les anciens paramètres et
|
||||
le biome `sanctuary:starter_forest` restent disponibles et inchangés : ils
|
||||
n’activent pas cette hydrologie dans les sauvegardes antérieures.
|
||||
|
||||
Un plan calculé depuis le relief et la graine recherche des emplacements de
|
||||
surface pour des étangs, des petits lacs et un premier cours d’eau calme.
|
||||
Le lit peut être légèrement creusé dans la roche existante. Avant d’accepter
|
||||
un emplacement, le plan vérifie le fond et les parois en trois dimensions ainsi
|
||||
que l’ouverture vers le ciel. Un trou profond dans la carte de hauteur ne suffit
|
||||
pas à constituer un lac. Aucune digue ni masse de roche n’est ajoutée pour
|
||||
boucher une fuite, et aucun bassin souterrain n’est rempli.
|
||||
|
||||
Les fonds alternent sable, gravier et argile ; les berges basses peuvent recevoir
|
||||
du sable. Dans ce nouveau biome, les disques de sédiments vanilla et les veines
|
||||
de gravier sont remplacés par ces dépôts de rive et de fond : une feature
|
||||
ultérieure ne doit pas transformer leur support en gravier instable. Les
|
||||
sources de magma sous-marines sont également absentes. Après les arbres et la
|
||||
végétation, de la canne à sucre peut pousser sur
|
||||
les rives qui respectent ses règles de placement vanilla. Le nombre de sites,
|
||||
leur eau et leurs ressources dépendent du terrain : le générateur ne complète
|
||||
pas une graine pour atteindre un quota économique.
|
||||
|
||||
Le plan est indépendant des chunks chargés. Chaque chunk applique uniquement
|
||||
ses propres colonnes après le terrain et avant la décoration, ce qui permet de
|
||||
raccorder un même plan d’eau aux frontières des chunks. Les anciens chunks ne
|
||||
sont jamais retouchés, et aucun travail hydrologique ne se déclenche au simple
|
||||
chargement d’une sauvegarde.
|
||||
|
||||
Cette première version vise de petites eaux retenues et des cours d’eau calmes
|
||||
sur les plateaux. Le cours d’eau est un chenal à niveau constant reliant des
|
||||
plans d’eau ; il ne simule pas encore une pente vers l’aval ou un débit. Les
|
||||
tracés peuvent passer par les diagonales, mais leur eau reste connectée de
|
||||
bloc en bloc. L’excavation est limitée à six blocs, l’eau à quatre blocs de
|
||||
profondeur, avec un fond plein sur trois blocs d’épaisseur, dont deux blocs naturels sous
|
||||
le sédiment. Ce contrat alpha.4 reste celui de ses sauvegardes. Les sources en cascade sont
|
||||
ajoutées séparément dans la génération v5 ; les grandes rivières en pente, océans,
|
||||
aquifères, climats multiples et continents restent des incréments distincts.
|
||||
|
||||
## Plages, sources et mosaïque tempérée — génération v5
|
||||
|
||||
L’implémentation **0.1.0-alpha.5** sélectionne la nouvelle clé de paramètres
|
||||
`sanctuary:sanctuary_natural` (`SanctuarySpawn.NATURAL_SETTINGS`). Elle conserve
|
||||
la densité relevée, les 384 blocs de hauteur et le ciel de l’alpha.3. La classe
|
||||
`NaturalHydrology` et son application sont séparées de `SurfaceHydrology` et
|
||||
`HydrologyRuntime` : le code, les paramètres `sanctuary:sanctuary_hydrology` et
|
||||
le biome `sanctuary:starter_forest_hydrology` de l’alpha.4 restent inchangés.
|
||||
Les clés alpha.2 et alpha.3 restent également disponibles avec leur comportement
|
||||
antérieur. **Ces changements concernent uniquement les nouveaux mondes** : aucune
|
||||
conversion de sauvegarde, régénération de chunk ou expansion n’est effectuée.
|
||||
|
||||
### Dépôts et eau de surface
|
||||
|
||||
Le plan recherche des étangs, de petits lacs et un cours d’eau calme dans le
|
||||
relief naturel. L’excavation maximale passe à **quatre blocs**, pour une eau de
|
||||
**trois blocs de profondeur** au plus. Les bassins gardent un fond et des parois
|
||||
naturels pleins ; aucune digue n’est ajoutée et aucune chambre souterraine n’est
|
||||
remplie. Le cours d’eau reste à niveau constant : il ne modélise pas un débit
|
||||
ni une pente continue vers l’aval.
|
||||
|
||||
Les plages sont des nappes irrégulières qui suivent les faibles variations du
|
||||
relief autour des plans d’eau. Elles peuvent s’étendre sur plusieurs blocs,
|
||||
se rapprocher d’une autre poche d’eau et retrouver progressivement l’herbe ou
|
||||
la pierre. Les matériaux varient entre sable, gravier, pierre et terre enherbée ;
|
||||
l’argile peut apparaître dans les fonds. Le traitement d’une terrasse sèche
|
||||
retire au plus **deux blocs** du relief existant.
|
||||
|
||||
Chaque dépôt remplace **trois à cinq couches** de terrain déjà plein et garde
|
||||
**deux couches naturelles de support** en dessous. Ce support permet notamment
|
||||
au sable et au gravier de rester en place après les mises à jour de gravité.
|
||||
Les veines de gravier, disques de sédiments et magma sous-marin vanilla ne sont
|
||||
pas réintroduits dans ces nouveaux biomes. Les autres décorations rocheuses et
|
||||
les minerais peuvent encore remplacer la pierre ; ils ne remplacent pas les
|
||||
couches de sable, gravier ou argile. Les rives compatibles peuvent porter de la
|
||||
canne à sucre après la végétation. Les troncs couchés vanilla peuvent occuper
|
||||
quelques blocs de la surface d’un étang ; les contrôles distinguent cette
|
||||
décoration de l’eau restante et vérifient leur fond ou l’eau sous les troncs,
|
||||
les sources voisines et la rétention du bassin.
|
||||
|
||||
### Sources rocheuses et cascades
|
||||
|
||||
Le plan peut retenir jusqu’à **trois sources** dans des niches rocheuses
|
||||
naturelles, séparées des dépôts modifiés. Une source remplace un seul bloc de
|
||||
roche, sous un toit, sur un fond et entre trois côtés déjà pleins. Son unique
|
||||
sortie horizontale est naturellement ouverte sur une chute d’au moins huit
|
||||
blocs. Le générateur ne construit ni pylône, ni corniche, ni tunnel pour obtenir
|
||||
ce résultat.
|
||||
|
||||
Seul le bloc source est généré. Les ticks de fluide de Minecraft forment la
|
||||
cascade et ses éventuels passages sur des paliers. Ces écoulements peuvent
|
||||
descendre **jusque dans le vide**, conformément au comportement demandé. Cette
|
||||
exception concerne les sources déclarées. Le trajet calculé dans la roche nue
|
||||
est une prévision : les arbres générés ensuite peuvent dévier une chute. Les
|
||||
diagnostics vérifient donc les connexions réelles de chaque bloc d’eau vers une
|
||||
source, dans l’emprise des chunks inspectés et de leur halo. Ils échouent si de
|
||||
l’eau touche une frontière non inspectée ou si un bassin perd sa coque. Les
|
||||
déviations par rapport à la prévision restent mesurées dans le rapport. Le
|
||||
comportement des fluides reste celui de Minecraft, y compris lorsque les joueurs
|
||||
modifient une rive.
|
||||
|
||||
### Petite réserve de lave
|
||||
|
||||
`StarterLavaDeposit` recherche au plus une niche de **18 blocs sources** dans
|
||||
la roche existante, accessible par un passage sec de deux blocs de haut depuis
|
||||
une corniche naturelle. Le fond garde trois blocs pleins, les parois deux et
|
||||
la couverture huit ; ces supports sont vérifiés avant les écritures. La niche
|
||||
reste éloignée des eaux et de leurs parcours. Elle fournit de la lave prélevable
|
||||
pour les premières productions et un portail du Nether. Si aucun site ne
|
||||
convient, le plan reste vide : aucun bassin artificiel n’est ajouté dans le vide.
|
||||
|
||||
Les tests vérifient l’accès praticable, la rétention de la lave et l’absence
|
||||
d’incendie après simulation.
|
||||
|
||||
### Biomes et minerais
|
||||
|
||||
Le biome source `sanctuary:temperate_island` utilise deux champs de bruit
|
||||
initialisés par la seed pour répartir cinq variantes horizontales. Une rive et
|
||||
la roche sous elle restent dans la même variante :
|
||||
|
||||
| Identifiant | Variante |
|
||||
| --- | --- |
|
||||
| `sanctuary:temperate_forest` | Forêt dominante |
|
||||
| `sanctuary:temperate_plains` | Clairières et plaines tempérées |
|
||||
| `sanctuary:temperate_birch_forest` | Forêt de bouleaux |
|
||||
| `sanctuary:temperate_flower_forest` | Forêt fleurie |
|
||||
| `sanctuary:temperate_dappled_forest` | Dappled Forest, peupliers et végétation associée |
|
||||
|
||||
Dappled Forest est un biome vanilla introduit dans
|
||||
[Minecraft 26.3 Snapshot 1](https://www.minecraft.net/en-us/article/minecraft-26-3-snapshot-1).
|
||||
Cette variante reprend ses données de **26.3-pre-2**, avec une humidité légèrement
|
||||
relevée pour Sanctuary. Elle n’ajoute ni dépendance externe ni structure de camp.
|
||||
La palette tempérée ne constitue pas encore le catalogue TerraMix ni le champ
|
||||
climatique des futurs continents.
|
||||
|
||||
Les nouveaux biomes remplacent les filons vanilla de métaux par de petites
|
||||
features `sanctuary:island_ore_*`, à des altitudes adaptées à l’île relevée :
|
||||
|
||||
| Ressource | Taille du filon | Tentatives par chunk | Plage Y |
|
||||
| --- | --- | --- | --- |
|
||||
| Charbon | 6 | 2 | 96–288 |
|
||||
| Fer | 4 | 2 | 88–248 |
|
||||
| Cuivre | 4 | 1 | 96–248 |
|
||||
| Or | 4 | 1 | 104–224 |
|
||||
| Redstone | 4 | 1 | 96–224 |
|
||||
| Lapis | 3 | Une tentative, chance 1/2 | 112–232 |
|
||||
| Diamant | 3 | Une tentative, chance 1/4 | 104–208 |
|
||||
|
||||
Ces paramètres décrivent des tentatives, pas des quantités promises : le filon
|
||||
doit rencontrer la roche existante, et sa forme dépend du hasard de génération.
|
||||
La sélection des biomes et des eaux ne cherche aucun stock cible. Un relevé
|
||||
partiel reste un relevé partiel ; la présence des sept ressources sur une seed
|
||||
de référence ne garantit pas leur présence sur toutes les seeds.
|
||||
|
||||
La validation de l’alpha.5 est suivie dans [Validation](testing.md). Ses diagnostics
|
||||
sont conçus pour inspecter les blocs décorés, les profils des dépôts, les
|
||||
jointures de chunks, les biomes et les ressources, puis simuler réellement les
|
||||
fluides dans les chunks des sites. Les cartes et profils PNG montrent des
|
||||
lectures de blocs, pas un rendu Minecraft. La descente des
|
||||
cascades est contrôlée après activation effective des chunks qui les simulent.
|
||||
|
||||
## Prairies et corniches — génération v6
|
||||
|
||||
L’alpha.6 utilise `sanctuary:sanctuary_layered` et le biome source
|
||||
`sanctuary:layered_island`. Les formes et le ciel restent ceux de la génération
|
||||
relevée. Les anciennes classes, biomes et règles de matériaux restent disponibles
|
||||
pour les sauvegardes alpha.2 à alpha.5 ; seul le preset de création choisit v6.
|
||||
|
||||
### Une palette qui varie aussi en hauteur
|
||||
|
||||
Les cinq variantes partagent leurs teintes d’herbe et de feuillage. Les champs
|
||||
de climat liés à la seed déforment de larges bandes d’altitude, autour de Y154
|
||||
et Y214 : prairies et prairies fleuries sur le dessus, bosquets et corniches
|
||||
moussues plus bas. Des zones rocheuses traversent ces bandes et remplacent la
|
||||
couverture de terre par de la pierre sur le relief existant. Les prairies gardent
|
||||
quelques arbres épars et leur végétation vanilla. Dappled Forest n’appartient
|
||||
plus à cette palette ; son utilisation sur de futurs continents reste à développer.
|
||||
|
||||
| Identifiant | Ambiance |
|
||||
| --- | --- |
|
||||
| `sanctuary:layered_meadow` | Prairie tempérée |
|
||||
| `sanctuary:layered_flower_meadow` | Prairie fleurie |
|
||||
| `sanctuary:layered_lower_grove` | Bosquet inférieur |
|
||||
| `sanctuary:layered_mossy_terraces` | Corniches moussues |
|
||||
| `sanctuary:layered_rocky_terraces` | Affleurements rocheux |
|
||||
|
||||
`LowerTerraceVegetationFeature` parcourt aussi les sols naturels sous les
|
||||
surplombs, au moins huit blocs sous une surface supérieure. Elle demande trois
|
||||
blocs pleins de support et deux blocs libres au-dessus. Elle peut remplacer la
|
||||
surface existante par de la mousse et planter fougères, fleurs ou petits arbustes
|
||||
lorsque leurs règles de survie le permettent. Les cellules hydrologiques, les
|
||||
sources, les parcours d’eau prévus et les supports de lave sont protégés.
|
||||
Il n’y a ni corniche construite dans l’air ni ajout d’éclairage artificiel.
|
||||
|
||||
### Bassins et sources de paroi
|
||||
|
||||
`LayeredHydrology` conserve les petits bassins et les dépôts épais, avec une
|
||||
plus grande présence de pierre. Le court `STREAM` à niveau constant de l’alpha.5
|
||||
n’est pas repris. Cette version retient uniquement les étangs et les petits
|
||||
lacs ; elle ne cherche pas de grande rivière. Le parcours traversant avec
|
||||
source, cascade et bassin reste un ticket distinct, à démontrer sur le relief
|
||||
Minecraft réel. Aucun chenal de secours n’est creusé.
|
||||
|
||||
La profondeur reste limitée à trois blocs et l’incision à quatre. Le plan
|
||||
vérifie le fond et les rives avant d’appliquer ses écritures, dans les nouveaux
|
||||
chunks seulement. Le sable, le gravier, l’argile et la roche suivent les rives.
|
||||
Des niches de source sont également recherchées dans les strates inférieures,
|
||||
et pas seulement près de la surface la plus haute. Leurs chutes sont produites
|
||||
par les ticks de Minecraft à partir d’un seul bloc source. Le post-traitement
|
||||
des nouveaux chunks libère uniquement leur ouverture, déjà vide dans la densité
|
||||
naturelle, pour qu’un lichen ou une plante ne bouche pas la source. Les positions
|
||||
sont marquées dans la file vanilla pendant la génération, puis consommées une
|
||||
seule fois. Un rechargement ne dégage pas une bouche modifiée par un joueur.
|
||||
Aucune chute n’est préremplie et aucun nouveau passage n’est creusé dans la roche.
|
||||
|
||||
Les parcours, les écoulements et la végétation réellement observés, ainsi que
|
||||
les limites du modèle, sont consignés dans [Validation](testing.md).
|
||||
|
||||
## Apparition commune
|
||||
|
||||
Une injection limitée à la création initiale cherche du sol naturel depuis
|
||||
@@ -142,7 +725,9 @@ une surface stable de 3 × 3 blocs, avec deux blocs libres en hauteur. En
|
||||
l'absence d'une telle surface, elle retient une colonne de sol praticable ; si
|
||||
aucun emplacement naturel sûr n'existe, la création signale la seed concernée
|
||||
plutôt que d'ajouter une plateforme. Elle ne s'applique qu'à l'Overworld
|
||||
utilisant les paramètres `sanctuary:sanctuary`.
|
||||
utilisant les paramètres `sanctuary:sanctuary`, `sanctuary:sanctuary_384` ou
|
||||
`sanctuary:sanctuary_hydrology`, `sanctuary:sanctuary_natural` ou
|
||||
`sanctuary:sanctuary_layered`.
|
||||
Elle enregistre le spawn partagé dans la sauvegarde et ne le réinitialise pas
|
||||
au redémarrage : les modifications administratives de `/setworldspawn` restent
|
||||
conservées. Les lits et la dispersion habituelle du spawn suivent encore les
|
||||
@@ -221,3 +806,66 @@ chunks sauvegardés.
|
||||
La version de génération et les emprises doivent être gelées avant les premières
|
||||
sauvegardes destinées à durer. Ce prototype ne promet pas encore de migration
|
||||
automatique des mondes entre deux versions de terrain.
|
||||
|
||||
|
||||
## Forêts, corniches humides et affleurements — génération v7
|
||||
|
||||
L’alpha.7 crée les nouveaux mondes avec `sanctuary:sanctuary_woodland` et
|
||||
`sanctuary:woodland_island`. La densité `sanctuary:final_density_384`, les
|
||||
formes rocheuses, la hauteur et le ciel restent identiques. Les plans des
|
||||
bassins, sources et petites niches de lave reprennent les algorithmes alpha.6.
|
||||
Les anciens paramètres, biomes et features sont conservés ; cette livraison
|
||||
ne modifie pas les mondes existants.
|
||||
|
||||
La palette comporte huit biomes :
|
||||
|
||||
| Identifiant | Rôle |
|
||||
| --- | --- |
|
||||
| `sanctuary:woodland_oak_forest` | Forêt de chênes dominante, bouleaux secondaires |
|
||||
| `sanctuary:woodland_birch_forest` | Forêt de bouleaux complémentaire |
|
||||
| `sanctuary:woodland_clearing` | Clairière fleurie |
|
||||
| `sanctuary:woodland_dry_woodland` | Bois sec plus clairsemé |
|
||||
| `sanctuary:woodland_rocky_heath` | Masses rocheuses exposées |
|
||||
| `sanctuary:woodland_dark_grove` | Corniches humides à chênes noirs et champignons |
|
||||
| `sanctuary:woodland_bamboo_grove` | Rare intérieur à jungle et bambou |
|
||||
| `sanctuary:woodland_sulfur_depths` | Rare poche profonde de soufre sur roche existante |
|
||||
|
||||
Une altitude élevée ne transforme plus automatiquement une forêt en prairie
|
||||
ou en roche. Les forêts utilisent les véritables arbres vanilla avec leurs
|
||||
conditions de croissance ; les affleurements sont des patches horizontaux
|
||||
et des variations de matériau sur le terrain existant.
|
||||
|
||||
`woodland_groves` place de vrais chênes noirs sur des sols naturels de 2×2,
|
||||
avec trois blocs de support et une place suffisante pour le tronc et la
|
||||
canopée sous le plafond. Des champignons accompagnent les sols de podzol et
|
||||
de mycélium, avec quelques touches de mousse. La jungle et le bambou restent
|
||||
limités aux rares poches qui leur conviennent.
|
||||
|
||||
Un seul emplacement remarquable est recherché par graine, avec une essence
|
||||
choisie parmi cerisier (favorisé), épicéa, jungle, acacia, palétuvier et chêne
|
||||
pâle. Il ne produit pas de terrain : si le site ou les conditions de l’arbre
|
||||
vanilla ne conviennent pas, il ne pousse pas. Ce n’est pas une garantie de
|
||||
chaque essence sur chaque île. Aucun cœur de Creaking n’est ajouté par l’arbre
|
||||
pâle utilisé ici.
|
||||
|
||||
Les affleurements mêlent des nappes cohérentes de pierre, andésite, diorite et
|
||||
granite. Les berges conservent leur excavation limitée et leurs dépôts de
|
||||
3 à 5 blocs, avec des patches de sable, gravier, argile et roche. Les plantations de `woodland_groves` évitent les sources, leurs exutoires
|
||||
et les niches de lave. Les décorations vanilla conservent leurs placements ;
|
||||
le traitement unique après génération peut rouvrir la seule bouche de source
|
||||
déclarée si une plante l’obstrue. Le soufre
|
||||
reste une matière rare des profondeurs ; les grands bassins et sources
|
||||
vanilla du biome Sulfur Caves ne sont pas activés.
|
||||
|
||||
Les anciens petits filons souterrains de charbon, fer et cuivre étaient peu
|
||||
susceptibles d’atteindre les surfaces hautes ; leur réglage écartait aussi une
|
||||
partie des blocs voisins de l’air. Les nouvelles features `woodland_ore_*`
|
||||
utilisent des tailles vanilla limitées (`size=6` pour le charbon, `size=5`
|
||||
pour fer/cuivre), sans garantir ce nombre de blocs produits par filon,
|
||||
autorisent cette exposition et ajoutent des essais aléatoires sur les roches
|
||||
de surface et les corniches rencontrées depuis l’air. Elles remplacent
|
||||
seulement les roches naturelles reconnues par Minecraft, dont andésite,
|
||||
diorite et granite. Les autres minerais et les anciens générateurs gardent
|
||||
leurs réglages ; aucune quantité cible n’est réinjectée après mesure.
|
||||
|
||||
La version et les graines de validation sont consignées dans [Validation](testing.md).
|
||||
|
||||
+2
-2
@@ -8,6 +8,6 @@ loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.3
|
||||
|
||||
mod_version=0.1.0-alpha.3
|
||||
pack_version=0.1.0-alpha.3
|
||||
mod_version=0.1.0-alpha.17
|
||||
pack_version=0.1.0-alpha.17
|
||||
maven_group=fr.koka.sanctuary
|
||||
|
||||
+338
-1
@@ -26,14 +26,57 @@ if (clientRenderTests) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every integration run must exercise fresh generation, never stale saved chunks.
|
||||
// Fresh generation by default; the explicit laboratory reload verifies the same saved test world.
|
||||
tasks.register('cleanGameTestWorld', Delete) {
|
||||
onlyIf { !providers.gradleProperty('sanctuaryExpansionReload').map { it.toBoolean() }.getOrElse(false) }
|
||||
delete(layout.buildDirectory.dir('run/gameTest/world'))
|
||||
}
|
||||
tasks.named('runGameTest') {
|
||||
dependsOn('cleanGameTestWorld')
|
||||
systemProperty('sanctuary.test.seed', providers.gradleProperty('sanctuaryTestSeed').getOrElse('0'))
|
||||
systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('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.generation', providers.gradleProperty('sanctuaryTestGeneration').getOrElse(
|
||||
providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false) ? '14' : providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false) ? '15' : providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false) ? '16' : '17'))
|
||||
systemProperty('sanctuary.test.expansion', providers.gradleProperty('sanctuaryExpansionTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.expansionReload', providers.gradleProperty('sanctuaryExpansionReload').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity', providers.gradleProperty('sanctuaryLostCityTests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity15', providers.gradleProperty('sanctuaryLostCity15Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.lostCity16', providers.gradleProperty('sanctuaryLostCity16Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.ruins17', providers.gradleProperty('sanctuaryRuins17Tests').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.experimentalStructures', providers.gradleProperty('sanctuaryExperimentalStructures').getOrElse('true'))
|
||||
systemProperty('sanctuary.test.cityMatrix', providers.gradleProperty('sanctuaryCityMatrix').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.cityMatrixOnly', providers.gradleProperty('sanctuaryCityMatrixOnly').getOrElse('false'))
|
||||
systemProperty('sanctuary.test.voidReuse', providers.gradleProperty('sanctuaryVoidReuseTests').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)
|
||||
def lostCity = providers.gradleProperty('sanctuaryLostCityTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity', lostCity)
|
||||
def lostCity15 = providers.gradleProperty('sanctuaryLostCity15Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity15', lostCity15)
|
||||
def lostCity16 = providers.gradleProperty('sanctuaryLostCity16Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('lostCity16', lostCity16)
|
||||
def ruins17 = providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('ruins17', ruins17)
|
||||
def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('voidReuse', voidReuse)
|
||||
filesMatching('fabric.mod.json') {
|
||||
expand(gametest_entrypoint: ruins17 ? 'fr.koka.sanctuary.gametest.Ruins17WorldGameTests' : voidReuse ? 'fr.koka.sanctuary.gametest.ExpansionVoidReuseGameTests' : lostCity16 ? 'fr.koka.sanctuary.gametest.LostCity16WorldGameTests' : lostCity15 ? 'fr.koka.sanctuary.gametest.LostCity15WorldGameTests' : lostCity ? 'fr.koka.sanctuary.gametest.LostCityWorldGameTests' : expansion ? 'fr.koka.sanctuary.gametest.ExpansionWorldGameTests' : benchmark
|
||||
? 'fr.koka.sanctuary.gametest.WorldgenPerformanceGameTests'
|
||||
: unified ? 'fr.koka.sanctuary.gametest.UnifiedWorldGameTests'
|
||||
: 'fr.koka.sanctuary.gametest.SanctuaryWorldGameTests')
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -76,5 +119,299 @@ tasks.register('worldgenSmoke', JavaExec) {
|
||||
|
||||
tasks.named('check') { dependsOn('worldgenSmoke') }
|
||||
|
||||
tasks.register('hydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check deterministic surface drainage, natural support and bounded excavation.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.SurfaceHydrologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('hydrologySmoke') }
|
||||
|
||||
// src/test contains a JavaExec assertion harness; engine tests use Fabric's gametest source set.
|
||||
tasks.named('test') { failOnNoDiscoveredTests = false }
|
||||
|
||||
tasks.register('naturalHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check broad shores, supported sediments and natural rock springs.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.NaturalHydrologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('naturalHydrologySmoke') }
|
||||
|
||||
tasks.register('starterLavaSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check a small contained lava niche in naturally supported rock.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.StarterLavaDepositSmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('starterLavaSmoke') }
|
||||
|
||||
// Alpha.6 has separate contracts so saved alpha.5 worlds keep their generation.
|
||||
tasks.register('layeredHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check retained basins, broad rock shores and lower wall springs.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.LayeredHydrologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('layeredHydrologySmoke') }
|
||||
|
||||
tasks.register('layeredEcologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check altitude biome variation and naturally supported lower terraces.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.LayeredEcologySmoke'
|
||||
}
|
||||
|
||||
tasks.named('check') { dependsOn('layeredEcologySmoke') }
|
||||
|
||||
// Alpha.7 keeps previous biome palettes and generators unchanged.
|
||||
tasks.register('woodlandPaletteSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check surface forest coverage and bounded rare interior biomes.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.WoodlandPaletteSmoke'
|
||||
}
|
||||
tasks.register('woodlandGroveSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check natural tree supports and canopy space without creating terrain.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.WoodlandGroveSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('woodlandPaletteSmoke', 'woodlandGroveSmoke') }
|
||||
|
||||
tasks.register('riftShapeSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftShapeSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftShapeSmoke') }
|
||||
|
||||
tasks.register('riftPaletteSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftPaletteSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftPaletteSmoke') }
|
||||
|
||||
tasks.register('riftHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftHydrologySmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftHydrologySmoke') }
|
||||
|
||||
tasks.register('riftLavaSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.RiftLavaSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('riftLavaSmoke') }
|
||||
|
||||
tasks.register('cavernPaletteSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.CavernPaletteSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('cavernPaletteSmoke') }
|
||||
|
||||
tasks.register('cavernHydrologySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.CavernHydrologySmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('cavernHydrologySmoke') }
|
||||
|
||||
tasks.register('cavernLavaSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.CavernLavaSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('cavernLavaSmoke') }
|
||||
|
||||
tasks.register('populationCapacitySmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check supported capacities, area scaling, finite boundaries and regional ownership.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.PopulationCapacitySmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('populationCapacitySmoke') }
|
||||
|
||||
tasks.register('populationRegionPlanSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check atomic regional admission, hydraulic dependencies and translated chunk indices.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.PopulationRegionPlanSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('populationRegionPlanSmoke') }
|
||||
|
||||
tasks.register('populationPlanCacheSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check that completed regions remain readable while new regions are planned.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.PopulationPlanCacheSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('populationPlanCacheSmoke') }
|
||||
|
||||
tasks.register('expansionJournalSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionJournalSmoke'
|
||||
}
|
||||
tasks.register('expansionReliefSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionReliefSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionJournalSmoke', 'expansionReliefSmoke') }
|
||||
|
||||
tasks.register('expansionPlacementSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionPlacementSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionPlacementSmoke') }
|
||||
|
||||
// Alpha.14 planner uses bounded natural support and never queries loaded chunks.
|
||||
tasks.register('lostCityPlannerSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityPlannerSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityPlannerSmoke') }
|
||||
|
||||
tasks.register('lostCityRendererSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityRendererSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityRendererSmoke') }
|
||||
|
||||
tasks.register('expansionSpatial14Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionSpatialPlacement14Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionSpatial14Smoke') }
|
||||
|
||||
tasks.register('expansionVoidCertificateSmoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.expansion.ExpansionVoidCertificateSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('expansionVoidCertificateSmoke') }
|
||||
|
||||
tasks.register('lostCityInteriors15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityInteriors15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityInteriors15Smoke') }
|
||||
|
||||
tasks.register('lostCityUnderground15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityUnderground15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityUnderground15Smoke') }
|
||||
|
||||
tasks.register('lostCityProtection15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check bounded three-dimensional city protection and immutable snapshots.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityProtection15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityProtection15Smoke') }
|
||||
|
||||
tasks.register('lostCityPlanner15Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check terrain-certified city fragments, inset floors and anchored viaducts.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityPlanner15Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityPlanner15Smoke') }
|
||||
|
||||
tasks.register('lostCityInteriors16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityInteriors16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityInteriors16Smoke') }
|
||||
|
||||
tasks.register('lostCityUnderground16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityUnderground16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityUnderground16Smoke') }
|
||||
|
||||
tasks.register('lostCityProtection16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check bounded three-dimensional city protection and immutable snapshots.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityProtection16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityProtection16Smoke') }
|
||||
|
||||
tasks.register('lostCityPlanner16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Check terrain-certified city fragments, inset floors and anchored viaducts.'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityPlanner16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityPlanner16Smoke') }
|
||||
|
||||
|
||||
tasks.register('lostCityFoundation16Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.worldgen.city.LostCityFoundation16Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('lostCityFoundation16Smoke') }
|
||||
|
||||
// The experimental pieces and algorithms are additive; historical city suites remain unchanged.
|
||||
['Planner', 'Renderer', 'Piece'].each { component ->
|
||||
def smoke = tasks.register("ruins17${component}Smoke", JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka.sanctuary.worldgen.ruins.Ruins${component}17Smoke"
|
||||
}
|
||||
tasks.named('check') { dependsOn(smoke) }
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// Frozen test oracle from a26b72f PopulationIslandDensity.java.
|
||||
// Original SHA-256: fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1
|
||||
// Only package/imports/class name differ; do not optimize this reference.
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.*;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.util.Interval;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.*;
|
||||
|
||||
/** Capacity changes the envelope; all natural noise is still sampled at world coordinates. */
|
||||
public record Alpha10PopulationDensityReference(DensityFunction terrain, DensityFunction distortion,
|
||||
DensityFunction sculpt, DensityFunction detail, DensityFunction underside,
|
||||
DensityFunction riftWarp, DensityFunction riftDetail, IslandCapacity capacity) implements DensityFunction {
|
||||
public static final MapCodec<Alpha10PopulationDensityReference> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
DensityFunction.CODEC.fieldOf("terrain").forGetter(Alpha10PopulationDensityReference::terrain),
|
||||
DensityFunction.CODEC.fieldOf("distortion").forGetter(Alpha10PopulationDensityReference::distortion),
|
||||
DensityFunction.CODEC.fieldOf("sculpt").forGetter(Alpha10PopulationDensityReference::sculpt),
|
||||
DensityFunction.CODEC.fieldOf("detail").forGetter(Alpha10PopulationDensityReference::detail),
|
||||
DensityFunction.CODEC.fieldOf("underside").forGetter(Alpha10PopulationDensityReference::underside),
|
||||
DensityFunction.CODEC.fieldOf("rift_warp").forGetter(Alpha10PopulationDensityReference::riftWarp),
|
||||
DensityFunction.CODEC.fieldOf("rift_detail").forGetter(Alpha10PopulationDensityReference::riftDetail),
|
||||
IslandCapacity.CODEC.optionalFieldOf("players", IslandCapacity.TWENTY).forGetter(Alpha10PopulationDensityReference::capacity)
|
||||
).apply(instance, Alpha10PopulationDensityReference::new));
|
||||
|
||||
@Override public DensitySampler compileSampler(CompileContext context) {
|
||||
var raw = terrain.compileSampler(context); var edge = distortion.compileSampler(context);
|
||||
var coarse = sculpt.compileSampler(context); var fine = detail.compileSampler(context);
|
||||
var bottom = underside.compileSampler(context); var warp = riftWarp.compileSampler(context);
|
||||
var fractureDetail = riftDetail.compileSampler(context);
|
||||
long seed = context.createRandom(SanctuaryMod.id("population_rifts_alpha10")).nextLong();
|
||||
var layouts = new ConcurrentHashMap<IslandCapacity.Region, RiftShape.Layout>();
|
||||
return new DensitySampler() {
|
||||
@Override public float sampleValue(SamplerContext sample, int x, int y, int z) {
|
||||
if (Math.hypot((double) x, z) >= capacity.terrainLimit() || y <= 0 || y >= 384) return -1;
|
||||
int localY = y - 64;
|
||||
float natural = PopulationIslandShape.raisedDensity(x, y, z, raw.sampleValue(sample, x, localY, z),
|
||||
edge.sampleValue(sample, x, 0, z), coarse.sampleValue(sample, x, localY, z),
|
||||
fine.sampleValue(sample, x, localY, z), bottom.sampleValue(sample, x, localY, z), 64, 384, capacity);
|
||||
if (natural <= 0) return natural;
|
||||
var region = capacity.regionAt(x, z);
|
||||
var layout = layouts.computeIfAbsent(region, r -> RiftShape.layout(regionSeed(seed, r)));
|
||||
int rx = x - region.originX(), rz = z - region.originZ();
|
||||
if (!RiftShape.mayAffect(layout, rx, rz)) return natural;
|
||||
return RiftShape.density(layout, rx, y, rz, natural,
|
||||
warp.sampleValue(sample, x, y, z), fractureDetail.sampleValue(sample, x, y, z));
|
||||
}
|
||||
@Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) {
|
||||
DensitySampler.sampleVolumeNaive(sample, buffer, volume, this);
|
||||
}
|
||||
};
|
||||
}
|
||||
public static long regionSeed(long seed, IslandCapacity.Region region) {
|
||||
long value = seed ^ ((long) region.x() << 32) ^ (region.z() & 0xffffffffL) ^ 0xA10CA7E1L;
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) {
|
||||
return new Alpha10PopulationDensityReference(rule.rewrite(terrain), rule.rewrite(distortion), rule.rewrite(sculpt),
|
||||
rule.rewrite(detail), rule.rewrite(underside), rule.rewrite(riftWarp), rule.rewrite(riftDetail), capacity);
|
||||
}
|
||||
@Override public Interval range() { return Interval.of(-1, 1); }
|
||||
@Override public int domainAxes() { return ALL_AXES; }
|
||||
@Override public MapCodec<Alpha10PopulationDensityReference> codec() { return CODEC; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,7 @@ final class DensityDiagnostics {
|
||||
var noises = registries.lookupOrThrow(Registries.NOISE);
|
||||
// The default run exports all three seeds once. Additional complete-world runs only
|
||||
// repeat the sparse assertions, avoiding redundant image generation.
|
||||
boolean export = level.getSeed() == 0L;
|
||||
boolean export = level.getSeed() == 0L && Boolean.parseBoolean(System.getProperty("sanctuary.test.densityMaps", "true"));
|
||||
boolean foundUnforcedCentralVoid = false;
|
||||
for (long seed : new long[]{0L, 42L, 8675309L}) {
|
||||
RandomState state = RandomState.create(noises, seed, settings);
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import net.minecraft.commands.CommandSource;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import net.minecraft.server.level.ChunkLevel;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.server.permissions.PermissionLevel;
|
||||
import net.minecraft.server.permissions.PermissionSet;
|
||||
|
||||
/** Tests real registered command permissions using sources; no real or simulated creative player is created. */
|
||||
public final class ExpansionCommandChecks {
|
||||
private ExpansionCommandChecks() { }
|
||||
|
||||
public static void verify(GameTestHelper helper) throws IOException {
|
||||
var level = helper.getLevel();
|
||||
var server = level.getServer();
|
||||
var session = ExpansionRuntime.session(level);
|
||||
var before = session.islands();
|
||||
var dispatcher = server.getCommands().getDispatcher();
|
||||
var console = server.createCommandSourceStack().withLevel(level).withSource(CommandSource.NULL).withSuppressedOutput();
|
||||
var levelZero = console.withPermission(LevelBasedPermissionSet.forLevel(PermissionLevel.ALL));
|
||||
var noPermissions = console.withPermission(PermissionSet.NO_PERMISSIONS);
|
||||
var levelOne = console.withPermission(LevelBasedPermissionSet.MODERATOR);
|
||||
var operatorTwo = console.withPermission(LevelBasedPermissionSet.GAMEMASTER);
|
||||
var admin = console.withPermission(LevelBasedPermissionSet.ADMIN);
|
||||
helper.assertTrue(console.getEntity() == null, "Command permission checks use console-derived sources, not a creative-player simulation");
|
||||
helper.assertTrue(ChunkLevel.RADIUS_AROUND_FULL_CHUNK * 16 <= ExpansionIsland.GENERATION_HALO,
|
||||
"The declared expansion halo must include the exact engine FULL generation dependency radius");
|
||||
|
||||
var sanctuary = dispatcher.getRoot().getChild("sanctuary");
|
||||
helper.assertTrue(sanctuary != null && sanctuary.getChild("expansion") != null,
|
||||
"The production Sanctuary expansion command tree must be registered");
|
||||
helper.assertTrue(!sanctuary.canUse(levelZero) && !sanctuary.canUse(noPermissions) && !sanctuary.canUse(levelOne),
|
||||
"Expansion commands must reject level 0, absent permissions and OP1 regardless of any game-mode assumption");
|
||||
helper.assertTrue(sanctuary.canUse(operatorTwo) && sanctuary.canUse(admin) && sanctuary.canUse(console),
|
||||
"Expansion commands must admit OP2 and higher, including the dedicated server console");
|
||||
|
||||
String create = "sanctuary expansion create command_denied sanctuary north boreal 64 natural";
|
||||
expectDenied(helper, dispatcher, levelZero, create, "level 0");
|
||||
expectDenied(helper, dispatcher, noPermissions, create, "no permissions");
|
||||
expectDenied(helper, dispatcher, levelOne, create, "OP1");
|
||||
helper.assertTrue(session.islands().equals(before), "Denied command sources must not add or change expansion regions");
|
||||
|
||||
String preview = "sanctuary expansion preview command_preview sanctuary north boreal 64 natural";
|
||||
try {
|
||||
var parsed = dispatcher.parse(preview, console);
|
||||
helper.assertTrue(!parsed.getReader().canRead() && parsed.getExceptions().isEmpty(),
|
||||
"The console must parse a complete preview with the known Sanctuary parent");
|
||||
helper.assertTrue(dispatcher.execute(parsed) == 1, "The console preview must execute successfully through the registered dispatcher");
|
||||
helper.assertTrue(dispatcher.execute(preview, operatorTwo) == 1, "OP2 must execute the same read-only preview successfully");
|
||||
helper.assertTrue(session.islands().equals(before), "Preview must not reserve or activate an island");
|
||||
|
||||
var feedback = new ArrayList<Component>();
|
||||
var previewSource = server.createCommandSourceStack().withLevel(level).withPermission(LevelBasedPermissionSet.GAMEMASTER)
|
||||
.withSource(new CommandSource() {
|
||||
@Override public void sendSystemMessage(Component message) { feedback.add(message); }
|
||||
@Override public boolean acceptsSuccess() { return true; }
|
||||
@Override public boolean acceptsFailure() { return true; }
|
||||
@Override public boolean shouldInformAdmins() { return false; }
|
||||
});
|
||||
var parent = session.island("sanctuary");
|
||||
for (String direction : new String[] {"north", "northeast"}) {
|
||||
feedback.clear();
|
||||
helper.assertTrue(dispatcher.execute("sanctuary expansion preview distance_preview sanctuary " + direction
|
||||
+ " boreal 64 natural 2048", previewSource) == 1, "The optional distance must execute for " + direction);
|
||||
helper.assertTrue(feedback.size() == 1 && feedback.getFirst().getContents() instanceof TranslatableContents,
|
||||
"A successful distance preview must return its proposed coordinates");
|
||||
var result = (TranslatableContents) feedback.getFirst().getContents();
|
||||
helper.assertTrue(result.getKey().equals("sanctuary.expansion.preview"), "Distance preview must return the production preview response");
|
||||
var args = result.getArgs();
|
||||
int x = ((Number) args[1]).intValue(), z = ((Number) args[2]).intValue();
|
||||
int dx = x - parent.centerX(), dz = z - parent.centerZ();
|
||||
helper.assertTrue(Math.hypot(dx, dz) >= 2048,
|
||||
"The command distance must be a minimum centre-to-centre distance, including diagonals");
|
||||
helper.assertTrue(dz < 0 && (direction.equals("north") ? dx == 0 : dx == -dz),
|
||||
"The distance preview must preserve the exact requested direction");
|
||||
helper.assertTrue(session.islands().equals(before), "Distance preview must not reserve or activate an island");
|
||||
}
|
||||
for (String command : new String[] {
|
||||
"sanctuary expansion quick north",
|
||||
"sanctuary expansion quick north boreal 64 natural",
|
||||
"sanctuary expansion quick north boreal 64 natural 2048",
|
||||
"sanctuary expansion create distance_create sanctuary north boreal 64 natural",
|
||||
"sanctuary expansion create distance_create sanctuary north boreal 64 natural 2048"}) {
|
||||
var syntax = dispatcher.parse(command, operatorTwo);
|
||||
helper.assertTrue(!syntax.getReader().canRead() && syntax.getExceptions().isEmpty() && syntax.getContext().getCommand() != null,
|
||||
"Old and distance-aware create/quick syntax must remain executable: " + command);
|
||||
}
|
||||
helper.assertTrue(dispatcher.execute(preview + " 0", operatorTwo) == 1,
|
||||
"An explicit zero distance must preserve automatic positioning");
|
||||
|
||||
for (String distance : new String[] {"-1", "100001"}) {
|
||||
for (String command : new String[] {
|
||||
preview,
|
||||
"sanctuary expansion create distance_invalid sanctuary north boreal 64 natural",
|
||||
"sanctuary expansion quick north boreal 64 natural"}) {
|
||||
boolean distanceRejected = false;
|
||||
try { dispatcher.execute(command + " " + distance, operatorTwo); }
|
||||
catch (CommandSyntaxException expected) { distanceRejected = true; }
|
||||
helper.assertTrue(distanceRejected && session.islands().equals(before),
|
||||
"All expansion commands must reject an out-of-range distance without mutation");
|
||||
}
|
||||
}
|
||||
|
||||
// 65 is accepted by Brigadier's numeric bounds, then rejected by the production size contract.
|
||||
int invalid = dispatcher.execute("sanctuary expansion create command_invalid sanctuary east boreal 65 natural", operatorTwo);
|
||||
helper.assertTrue(invalid == 0, "An unsupported nominal diameter must fail in the production command handler");
|
||||
helper.assertTrue(session.islands().equals(before), "Rejecting an unsupported diameter must preserve the registry");
|
||||
|
||||
boolean outsideRangeRejected = false;
|
||||
try { dispatcher.execute("sanctuary expansion create command_out_of_range sanctuary east boreal 1025 natural", console); }
|
||||
catch (CommandSyntaxException expected) { outsideRangeRejected = true; }
|
||||
helper.assertTrue(outsideRangeRejected && session.islands().equals(before),
|
||||
"The parser must reject a diameter above 1024 before changing the registry");
|
||||
} catch (CommandSyntaxException exception) {
|
||||
throw new IOException("Registered expansion command checks failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void expectDenied(GameTestHelper helper, CommandDispatcher<CommandSourceStack> dispatcher,
|
||||
CommandSourceStack source, String command, String label) {
|
||||
var parsed = dispatcher.parse(command, source);
|
||||
helper.assertTrue(parsed.getContext().getNodes().isEmpty() && parsed.getReader().canRead(),
|
||||
"The " + label + " source must not parse the restricted expansion command tree");
|
||||
boolean rejected = false;
|
||||
try { dispatcher.execute(parsed); }
|
||||
catch (CommandSyntaxException expected) { rejected = true; }
|
||||
helper.assertTrue(rejected, "The " + label + " source must not execute an expansion command");
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonParser;
|
||||
import fr.koka.sanctuary.expansion.ExpansionAdmission;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import fr.koka.sanctuary.expansion.ExpansionVoidReuse;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Opt-in, disposable-world proof. Run fresh, then twice with sanctuaryExpansionReload=true. */
|
||||
public final class ExpansionVoidReuseGameTests {
|
||||
private static final String LOADED = "void_loaded", SAVED = "void_saved";
|
||||
@GameTest(maxTicks = 6000)
|
||||
public void certifiedVoidRestartsThroughNativeGeneration(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Void reuse proof needs the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(ExpansionIsland.spatialGeneration(generator.rootGeneration()), "Void reuse is restricted to spatial city generations");
|
||||
new Scenario(helper, generator).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final ExpansionRuntime.Session session;
|
||||
final boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
ExpansionIsland loaded, saved;
|
||||
final List<ExpansionIsland> rejectionFixtures = new ArrayList<>();
|
||||
int phase;
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
|
||||
this.session = ExpansionRuntime.session(level);
|
||||
report.put("world_seed", level.getSeed()); report.put("generation", generator.rootGeneration());
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (reload) { resume(); return; }
|
||||
if (phase == 0) {
|
||||
loaded = session.candidate(LOADED, "sanctuary", "east", "temperate", 64, "natural", 6144);
|
||||
for (var pos : positions(loaded)) {
|
||||
level.setChunkForced(pos.x(), pos.z(), true);
|
||||
level.getChunk(pos.x(), pos.z());
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
var fixture = new ExpansionIsland("reject_" + i, "sanctuary", "north", "temperate", 64,
|
||||
0, -12288 - i * 4096, level.getSeed() + i, "natural", "reserved", generator.rootGeneration());
|
||||
rejectionFixtures.add(fixture);
|
||||
for (var pos : positions(fixture)) {
|
||||
level.setChunkForced(pos.x(), pos.z(), true);
|
||||
level.getChunk(pos.x(), pos.z());
|
||||
}
|
||||
}
|
||||
phase = 1;
|
||||
helper.runAtTickTime(helper.getTick() + 40, this::advance);
|
||||
return;
|
||||
}
|
||||
if (phase == 1) {
|
||||
report.put("stage", "loaded_positive_baseline");
|
||||
var proof = ExpansionVoidReuse.inspect(level, loaded, ExpansionAdmission.searchDeadline());
|
||||
helper.assertTrue(proof.certificates().stream().noneMatch(c -> c.absent()), "Loaded fixture needs native FULL void throughout its write scope");
|
||||
report.put("loaded_positive_baseline_passed", true);
|
||||
report.put("stage", "independent_rejections");
|
||||
rejectionChecks(helper, rejectionFixtures);
|
||||
report.put("stage", "loaded_positive_reservation");
|
||||
var source = level.getChunk(loaded.centerX() >> 4, loaded.centerZ() >> 4);
|
||||
CompoundTag snapshot = SerializableChunkData.copyOf(level, source).write();
|
||||
var admitted = session.create(LOADED, "sanctuary", "east", "temperate", 64, "natural", 6144);
|
||||
helper.assertTrue(admitted.centerX() == loaded.centerX() && admitted.centerZ() == loaded.centerZ(), "Inspection must retain the requested candidate");
|
||||
helper.assertTrue(!admitted.reuseToken().isEmpty() && ExpansionVoidReuse.requiresReload(level, LOADED), "Loaded FULL void must be deferred with a durable journal token");
|
||||
helper.assertTrue(session.island(LOADED).status().equals("reserved"), "A live FULL reservation cannot become ready before reopening");
|
||||
helper.assertTrue(generator.source().islands().stream().noneMatch(i -> i.id().equals(LOADED)), "Deferred island must remain invisible to generation");
|
||||
loaded = admitted;
|
||||
|
||||
report.put("stage", "saved_positive_baseline");
|
||||
saved = session.candidate(SAVED, "sanctuary", "west", "temperate", 64, "natural", 6144);
|
||||
var pos = new ChunkPos(saved.centerX() >> 4, saved.centerZ() >> 4);
|
||||
helper.assertTrue(level.getChunkSource().chunkMap.read(pos).get(30, TimeUnit.SECONDS).isEmpty(), "Saved-only fixture starts from absent storage");
|
||||
// One genuine native FULL-void snapshot, relocated only inside this disposable test fixture.
|
||||
snapshot.putInt("xPos", pos.x()); snapshot.putInt("zPos", pos.z());
|
||||
level.getChunkSource().chunkMap.write(pos, snapshot).get(30, TimeUnit.SECONDS);
|
||||
level.getChunkSource().chunkMap.synchronize(true).get(30, TimeUnit.SECONDS);
|
||||
var savedProof = ExpansionVoidReuse.inspect(level, saved, ExpansionAdmission.searchDeadline());
|
||||
helper.assertTrue(savedProof.certificates().stream().filter(c -> !c.absent()).count() == 1
|
||||
&& savedProof.certificates().stream().anyMatch(c -> c.absent()), "Saved-only fixture needs both FULL and certified absent positions");
|
||||
report.put("stage", "external_mutation_preflight");
|
||||
verifyMutationBlocksWholePreflight(helper, generator, savedProof, snapshot, pos);
|
||||
report.put("stage", "saved_positive_reservation");
|
||||
saved = session.create(SAVED, "sanctuary", "west", "temperate", 64, "natural", 6144);
|
||||
helper.assertTrue(!saved.reuseToken().isEmpty() && ExpansionVoidReuse.requiresReload(level, SAVED), "Saved void also needs a reload transaction");
|
||||
for (var loadedPos : positions(loaded)) level.setChunkForced(loadedPos.x(), loadedPos.z(), false);
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save native void and reservations before the first real restart");
|
||||
report.put("loaded_full_chunks", positions(loaded).size());
|
||||
report.put("saved_only_full_chunks", 1);
|
||||
report.put("saved_only_absent_chunks", savedProof.certificates().stream().filter(c -> c.absent()).count());
|
||||
report.put("loaded_token", loaded.reuseToken()); report.put("saved_token", saved.reuseToken());
|
||||
report.put("rejected_block_chest_entity_tick", true); report.put("foreign_mutation_preserved_region_bytes", true);
|
||||
report.put("passed", true); write(level, "void-reuse-before-reload.json", report);
|
||||
helper.succeed();
|
||||
}
|
||||
} catch (Exception error) {
|
||||
report.put("passed", false); report.put("failure", error.toString());
|
||||
try { write(level, "void-reuse-failure.json", report); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Native void reuse proof failed", error);
|
||||
}
|
||||
}
|
||||
void resume() throws Exception {
|
||||
loaded = session.island(LOADED); saved = session.island(SAVED);
|
||||
helper.assertTrue(!ExpansionVoidReuse.requiresReload(level, LOADED) && !ExpansionVoidReuse.requiresReload(level, SAVED),
|
||||
"A genuine restart must open durable transactions");
|
||||
if (!loaded.status().equals("ready") || !saved.status().equals("ready")) {
|
||||
helper.assertTrue(ExpansionVoidReuse.failure(level, LOADED) == null && ExpansionVoidReuse.failure(level, SAVED) == null,
|
||||
"Native load must not block either certified transaction");
|
||||
helper.runAtTickTime(helper.getTick() + 20, this::advance);
|
||||
return;
|
||||
}
|
||||
Path witnessFile = path(level, "void-reuse-after-reload.json");
|
||||
boolean secondReload = Files.exists(witnessFile);
|
||||
if (secondReload) {
|
||||
var original = JsonParser.parseString(Files.readString(witnessFile)).getAsJsonObject();
|
||||
var p = original.getAsJsonArray("gold_witness");
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt())).is(Blocks.GOLD_BLOCK),
|
||||
"A native marked FULL chunk must preserve gameplay blocks on subsequent reloads");
|
||||
}
|
||||
int full = 0, nonAir = 0, rebuiltBiomeSamples = 0;
|
||||
for (var island : List.of(loaded, saved)) for (var pos : positions(island)) {
|
||||
var chunk = level.getChunk(pos.x(), pos.z());
|
||||
helper.assertTrue(chunk.getPersistedStatus() == ChunkStatus.FULL && ExpansionVoidReuse.marker(chunk).equals(island.reuseToken()),
|
||||
"Every claimed position must complete the native pipeline and retain its exact marker: " + pos);
|
||||
helper.assertTrue(chunk.isLightCorrect(), "Every reconstructed FULL chunk must finish native lighting: " + pos);
|
||||
int midX = pos.x() * 16 + 8, midZ = pos.z() * 16 + 8;
|
||||
if (island.contains(midX, midZ)) {
|
||||
helper.assertTrue(!chunk.getNoiseBiome(pos.x() * 4 + 2, 220 / 4, pos.z() * 4 + 2)
|
||||
.is(net.minecraft.world.level.biome.Biomes.THE_VOID),
|
||||
"Native BIOMES must replace the previous void biome inside the expansion: " + pos);
|
||||
rebuiltBiomeSamples++;
|
||||
}
|
||||
full++;
|
||||
for (var section : chunk.getSections()) if (!section.hasOnlyAir()) nonAir++;
|
||||
}
|
||||
helper.assertTrue(nonAir > 0, "A reconstructed expansion must contain actual generated terrain");
|
||||
var witness = new BlockPos(loaded.centerX(), 376, loaded.centerZ());
|
||||
if (!secondReload) level.setBlock(witness, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush generated native chunks and trusted markers");
|
||||
report.put("full_marked_chunks", full); report.put("nonempty_sections", nonAir);
|
||||
report.put("light_correct_chunks", full); report.put("rebuilt_biome_samples", rebuiltBiomeSamples);
|
||||
report.put("gold_witness", List.of(witness.getX(), witness.getY(), witness.getZ()));
|
||||
report.put("second_reload_preserved_gameplay", secondReload); report.put("passed", true);
|
||||
write(level, secondReload ? "void-reuse-second-reload.json" : "void-reuse-after-reload.json", report);
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectionChecks(GameTestHelper helper, List<ExpansionIsland> fixtures) throws Exception {
|
||||
var level = helper.getLevel();
|
||||
for (int i = 0; i < fixtures.size(); i++) {
|
||||
var island = fixtures.get(i); var p = new BlockPos(island.centerX(), 200, island.centerZ());
|
||||
// Each witness has its own untouched native palette. Never reuse a section edited by a prior witness.
|
||||
var baseline = ExpansionVoidReuse.inspect(level, island, ExpansionAdmission.searchDeadline());
|
||||
helper.assertTrue(baseline.certificates().stream().noneMatch(c -> c.absent()), "Every rejection first proves its own FULL-void baseline");
|
||||
switch (i) {
|
||||
case 0 -> {
|
||||
level.setBlock(p, Blocks.STONE.defaultBlockState(), 3);
|
||||
expectOccupied(helper, island, "block");
|
||||
}
|
||||
case 1 -> {
|
||||
level.setBlock(p, Blocks.CHEST.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getBlockEntity(p) != null, "Chest witness must contain its native block entity");
|
||||
expectOccupied(helper, island, "chest");
|
||||
}
|
||||
case 2 -> {
|
||||
var entity = EntityTypes.ZOMBIE.create(level, EntitySpawnReason.COMMAND);
|
||||
helper.assertTrue(entity != null, "Entity rejection needs a real entity");
|
||||
entity.setPos(p.getX() + .5, p.getY(), p.getZ() + .5);
|
||||
helper.assertTrue(level.addFreshEntity(entity), "Entity fixture must be registered with the native manager");
|
||||
expectOccupied(helper, island, "entity"); entity.discard();
|
||||
}
|
||||
case 3 -> {
|
||||
level.scheduleTick(p, Blocks.STONE, 1000);
|
||||
expectOccupied(helper, island, "tick");
|
||||
level.getBlockTicks().clearArea(new BoundingBox(p));
|
||||
}
|
||||
default -> throw new IllegalStateException("Unexpected rejection witness");
|
||||
}
|
||||
for (var pos : positions(island)) level.setChunkForced(pos.x(), pos.z(), false);
|
||||
}
|
||||
}
|
||||
private static void expectOccupied(GameTestHelper helper, ExpansionIsland island, String kind) throws Exception {
|
||||
boolean refused = false;
|
||||
try { ExpansionVoidReuse.inspect(helper.getLevel(), island, ExpansionAdmission.searchDeadline()); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) { refused = true; }
|
||||
helper.assertTrue(refused, "Admission must reject " + kind + " even in an otherwise empty void chunk");
|
||||
}
|
||||
|
||||
private static void verifyMutationBlocksWholePreflight(GameTestHelper helper, SanctuaryChunkGenerator generator,
|
||||
ExpansionVoidReuse.Inspection proof, CompoundTag original, ChunkPos pos) throws Exception {
|
||||
var level = helper.getLevel(); var session = ExpansionRuntime.session(level);
|
||||
String token = ExpansionVoidReuse.persist(proof);
|
||||
var mutated = original.copy(); mutated.putString("foreign_mod_metadata", "must survive unchanged");
|
||||
var storage = level.getChunkSource().chunkMap;
|
||||
storage.write(pos, mutated).get(30, TimeUnit.SECONDS); storage.synchronize(true).get(30, TimeUnit.SECONDS);
|
||||
Path region = net.minecraft.world.level.dimension.DimensionType.getStorageFolder(level.dimension(),
|
||||
level.getServer().getWorldPath(LevelResource.ROOT)).resolve("region/r."
|
||||
+ Math.floorDiv(pos.x(), 32) + "." + Math.floorDiv(pos.z(), 32) + ".mca");
|
||||
byte[] before = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(region));
|
||||
var islands = List.of(session.island("sanctuary"), proof.island().withReuseToken(token));
|
||||
boolean refused = false;
|
||||
try { ExpansionVoidReuse.open(level, generator, islands); } catch (IOException expected) { refused = true; }
|
||||
helper.assertTrue(refused, "External mutation must block startup before any absent chunk is staged");
|
||||
helper.assertTrue(java.util.Arrays.equals(before, MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(region))),
|
||||
"A rejected preflight must preserve the original region file byte for byte");
|
||||
helper.assertTrue(storage.read(pos).get(30, TimeUnit.SECONDS).orElseThrow().equals(mutated), "Foreign NBT must be preserved");
|
||||
storage.write(pos, original).get(30, TimeUnit.SECONDS); storage.synchronize(true).get(30, TimeUnit.SECONDS);
|
||||
// This deliberately simulates startup inside a running test; restore the runtime's deferred state afterward.
|
||||
ExpansionVoidReuse.open(level, generator, List.of(session.island("sanctuary")));
|
||||
for (var island : session.islands()) if (!island.reuseToken().isEmpty()) ExpansionVoidReuse.deferred(level, island);
|
||||
}
|
||||
private static List<ChunkPos> positions(ExpansionIsland island) {
|
||||
var result = new ArrayList<ChunkPos>(); int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++)
|
||||
for (int x = (island.centerX() - radius) >> 4; x <= (island.centerX() + radius) >> 4; x++)
|
||||
if (island.ownsChunk(x, z)) result.add(new ChunkPos(x, z));
|
||||
return result;
|
||||
}
|
||||
private static Path path(ServerLevel level, String name) { return level.getServer().getWorldPath(LevelResource.ROOT).resolve(name); }
|
||||
private static void write(ServerLevel level, String name, Map<String, Object> report) throws IOException {
|
||||
Files.writeString(path(level, name), new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
}
|
||||
}
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.expansion.ExpansionAdmission;
|
||||
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
|
||||
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionJournal;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Opt-in end-to-end exercise on a new laboratory world; never rewrites chunks or a personal world. */
|
||||
public final class ExpansionWorldGameTests {
|
||||
private static final boolean OPTIONAL_STRUCTURE_WITNESS = Boolean.getBoolean("sanctuary.test.optionalStructureWitness");
|
||||
|
||||
@GameTest(maxTicks = 6000)
|
||||
public void islandsBranchPersistAndProtectVisitedChunks(GameTestHelper helper) {
|
||||
helper.assertTrue(Boolean.getBoolean("sanctuary.test.expansion"), "Expansion GameTest must be explicitly enabled");
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator,
|
||||
"Expansion GameTest must load the new laboratory preset");
|
||||
if (Boolean.getBoolean("sanctuary.test.expansionReload")) {
|
||||
verifyProcessReload(helper);
|
||||
return;
|
||||
}
|
||||
new Scenario(helper).advance();
|
||||
}
|
||||
|
||||
private static void verifyProcessReload(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
|
||||
var session = ExpansionRuntime.session(level);
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("process_id", ProcessHandle.current().pid());
|
||||
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
|
||||
report.put("scope", "A second Java process opens the same saved development chunks and expansion journal, and verifies a "
|
||||
+ "gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; "
|
||||
+ "the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client "
|
||||
+ "world-selection test or a multiplayer/teleportation test.");
|
||||
try {
|
||||
JsonObject previous = JsonParser.parseString(Files.readString(diagnostic(level, false))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean(), "Reload requires a successful first integration run");
|
||||
helper.assertTrue(previous.has("restart_snapshot"), "The first process must save a restart witness before shutting down");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(),
|
||||
"The reload proof must run in another Java process");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed(), "Reload must keep the original world seed");
|
||||
JsonObject saved = previous.getAsJsonObject("restart_snapshot");
|
||||
List<ExpansionIsland> blockers = ExpansionIsland.CODEC.listOf().parse(JsonOps.INSTANCE, saved.get("blocked_sites")).getOrThrow();
|
||||
int savedRefusals = 0;
|
||||
for (var site : blockers) {
|
||||
helper.assertTrue(level.getChunkSource().chunkMap.getUpdatingChunkIfPresent(new ChunkPos(site.centerX() >> 4, site.centerZ() >> 4).pack()) == null,
|
||||
"Reload collision proof must start with an unloaded saved witness");
|
||||
try { ExpansionAdmission.verify(level, site); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) {
|
||||
helper.assertTrue(expected.getMessage().contains("saved chunk"), "The reload proof must detect disk occupancy, not just a loaded holder");
|
||||
savedRefusals++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(savedRefusals == 8, "All eight first sites must remain unavailable on disk after restart");
|
||||
report.put("saved_only_collision_refusals", savedRefusals);
|
||||
helper.assertTrue(saved.get("world_directory").getAsString().equals(worldDirectory(level)),
|
||||
"Reload must use exactly the same development save directory");
|
||||
helper.assertTrue(session.islands().size() == 3 && session.islands().stream().allMatch(i -> i.status().equals("ready")),
|
||||
"The loaded journal must contain exactly the ready root, alpha and beta");
|
||||
helper.assertTrue(session.island("alpha").parent().equals("sanctuary") && session.island("beta").parent().equals("alpha"),
|
||||
"Reload must preserve the expansion branch");
|
||||
var encodedIslands = ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow();
|
||||
helper.assertTrue(encodedIslands.equals(saved.get("islands")), "Every persisted region field must survive the process restart");
|
||||
helper.assertTrue(sha256(Files.readString(journalFile(level))).equals(saved.get("journal_sha256").getAsString()),
|
||||
"Loading a ready journal must preserve its exact saved contents");
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
var liveCodec = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
|
||||
helper.assertTrue(liveCodec.equals(saved.get("generator_codec")), "Generator settings and biome holder identities must remain stable");
|
||||
var decoded = ChunkGenerator.CODEC.parse(ops, saved.get("generator_codec")).getOrThrow();
|
||||
helper.assertTrue(decoded instanceof ExpansionChunkGenerator, "The saved generator codec must decode to the laboratory generator");
|
||||
var restored = (ExpansionChunkGenerator) decoded;
|
||||
restored.initialize(level, session.islands());
|
||||
|
||||
BlockPos witness = position(saved.getAsJsonArray("gold_witness"));
|
||||
full(helper, level, witness.getX() >> 4, witness.getZ() >> 4);
|
||||
helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK),
|
||||
"The first process's saved gold block must exist before the reload test writes anything");
|
||||
List<Map<String, Object>> samples = new ArrayList<>();
|
||||
for (var element : saved.getAsJsonArray("samples")) {
|
||||
JsonObject original = element.getAsJsonObject();
|
||||
var island = session.island(original.get("id").getAsString());
|
||||
BlockPos point = position(original.getAsJsonArray("point"));
|
||||
Map<String, Object> current = sample(helper, generator, island, point);
|
||||
helper.assertTrue(new GsonBuilder().create().toJsonTree(current).equals(original),
|
||||
"Stored blocks, stored biomes and generated base column must survive restart: " + island.id());
|
||||
helper.assertTrue(new GsonBuilder().create().toJsonTree(sample(helper, restored, island, point)).equals(original),
|
||||
"Decoding the saved generator codec must reproduce its base column and climate: " + island.id());
|
||||
samples.add(current);
|
||||
}
|
||||
boolean hasNativeStructure = previous.has("native_structure") && !previous.get("native_structure").isJsonNull();
|
||||
if (hasNativeStructure) {
|
||||
helper.assertTrue(!previous.has("native_structure_observed") || previous.get("native_structure_observed").getAsBoolean(),
|
||||
"Stored native structure evidence must not contradict the observation result");
|
||||
JsonObject nativeStructure = previous.getAsJsonObject("native_structure");
|
||||
BlockPos containerPos = position(nativeStructure.getAsJsonArray("container"));
|
||||
full(helper, level, containerPos.getX() >> 4, containerPos.getZ() >> 4);
|
||||
helper.assertTrue(level.getBlockState(containerPos).toString().equals(nativeStructure.get("placed_block").getAsString()),
|
||||
"The native structure's placed block state must survive restart");
|
||||
helper.assertTrue(level.getBlockEntity(containerPos) instanceof RandomizableContainerBlockEntity,
|
||||
"The native structure's placed container must be loaded from the saved chunk");
|
||||
var container = (RandomizableContainerBlockEntity) level.getBlockEntity(containerPos);
|
||||
helper.assertTrue(container.getLootTable() != null && container.getLootTable().identifier().toString()
|
||||
.equals(nativeStructure.get("loot_table").getAsString()), "The native structure loot table must survive restart unconsumed");
|
||||
report.put("native_container", coordinates(containerPos));
|
||||
report.put("native_structure_observed", true);
|
||||
report.put("native_container_verified", true);
|
||||
report.put("native_structure_status", "placed_block_and_unconsumed_loot_verified_after_restart");
|
||||
} else {
|
||||
helper.assertTrue(OPTIONAL_STRUCTURE_WITNESS
|
||||
&& previous.has("optional_native_structure_witness") && previous.get("optional_native_structure_witness").getAsBoolean()
|
||||
&& previous.has("native_structure_observed") && !previous.get("native_structure_observed").getAsBoolean()
|
||||
&& previous.has("native_structure_search_complete") && previous.get("native_structure_search_complete").getAsBoolean(),
|
||||
"Missing native structure evidence is allowed only after an explicitly optional, completed first-process search");
|
||||
report.put("native_structure_observed", false);
|
||||
report.put("native_container_verified", false);
|
||||
report.put("native_structure_status", "not_observed_after_complete_first_process_scan; no_native_structure_reload_proof");
|
||||
report.put("native_structure_limitation", "No natural structure with placed vanilla loot was observed in the first process. "
|
||||
+ "This reload verifies chunks, generator settings, the journal and expansion behavior, but supplies no positive structure proof.");
|
||||
}
|
||||
report.put("previous_process_id", previous.get("process_id").getAsLong());
|
||||
report.put("world_directory", worldDirectory(level));
|
||||
report.put("gold_witness", coordinates(witness));
|
||||
report.put("samples", samples);
|
||||
report.put("regions", session.islands().size());
|
||||
report.put("saved_generator_codec_decoded", true);
|
||||
var firstCandidate = session.candidate("after_reload", "sanctuary", "south", "boreal", 64, "natural");
|
||||
var opened = session.create("after_reload", "sanctuary", "south", "boreal", 64, "natural");
|
||||
helper.assertTrue(opened.centerZ() > firstCandidate.centerZ() && opened.centerX() == 0,
|
||||
"Creation must skip the saved occupied first site and preserve the requested southern ray");
|
||||
report.put("after_reload_expansion", List.of(opened.centerX(), opened.centerZ()));
|
||||
awaitReloadExpansion(helper, session, blockers, witness, report);
|
||||
} catch (Exception exception) {
|
||||
report.put("passed", false);
|
||||
report.put("failure", exception.toString());
|
||||
try { writeDiagnostic(level, true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
|
||||
SanctuaryMod.LOGGER.error("Expansion process reload scenario failed", exception);
|
||||
helper.fail(exception.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitReloadExpansion(GameTestHelper helper, ExpansionRuntime.Session session,
|
||||
List<ExpansionIsland> blockers, BlockPos gold, Map<String, Object> report) {
|
||||
try {
|
||||
helper.assertTrue(helper.getTick() < 5000, "Reload expansion preparation deadline: " + session.progress());
|
||||
if (!session.island("after_reload").status().equals("ready")) {
|
||||
helper.runAtTickTime(helper.getTick() + 10, () -> awaitReloadExpansion(helper, session, blockers, gold, report));
|
||||
return;
|
||||
}
|
||||
verifyUntouchedVoid(helper, blockers);
|
||||
helper.assertTrue(helper.getLevel().getBlockState(gold).is(Blocks.GOLD_BLOCK), "Opening farther away must preserve the saved player witness");
|
||||
var landing = session.landing("after_reload");
|
||||
helper.assertTrue(ExpansionRuntime.safeLanding(helper.getLevel(), landing), "The new region after restart must reach usable terrain");
|
||||
report.put("after_reload_landing", coordinates(landing));
|
||||
report.put("final_regions", session.islands().size());
|
||||
report.put("passed", true);
|
||||
writeDiagnostic(helper.getLevel(), true, report);
|
||||
helper.succeed();
|
||||
} catch (Exception exception) {
|
||||
report.put("passed", false);
|
||||
report.put("failure", exception.toString());
|
||||
try { writeDiagnostic(helper.getLevel(), true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
|
||||
helper.fail(exception.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyUntouchedVoid(GameTestHelper helper, List<ExpansionIsland> blockers) {
|
||||
var generator = (ExpansionChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
for (var site : blockers) {
|
||||
var chunk = full(helper, helper.getLevel(), site.centerX() >> 4, site.centerZ() >> 4);
|
||||
helper.assertTrue(generator.islandAt(site.centerX(), site.centerZ()) == null
|
||||
&& Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()),
|
||||
"An occupied saved void chunk must remain unchanged after farther placement: " + site.direction());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
private final GameTestHelper helper;
|
||||
private final ServerLevel level;
|
||||
private final ExpansionChunkGenerator generator;
|
||||
private final ExpansionRuntime.Session session;
|
||||
private final Map<String, Object> report = new LinkedHashMap<>();
|
||||
private final List<Map<String, Object>> observedIslands = new ArrayList<>();
|
||||
private final Map<String, BlockPos> samplePoints = new LinkedHashMap<>();
|
||||
private final List<ChunkPos> structureSearch = new ArrayList<>();
|
||||
private final long started = System.nanoTime();
|
||||
private ExpansionIsland alpha;
|
||||
private ExpansionIsland beta;
|
||||
private BlockPos marker;
|
||||
private BlockState previousMarker;
|
||||
private int phase;
|
||||
private int structureIndex;
|
||||
private int validStarts;
|
||||
private final List<ExpansionIsland> blockedSites = new ArrayList<>();
|
||||
|
||||
Scenario(GameTestHelper helper) {
|
||||
this.helper = helper;
|
||||
this.level = helper.getLevel();
|
||||
this.generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
|
||||
this.session = ExpansionRuntime.session(level);
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("process_id", ProcessHandle.current().pid());
|
||||
report.put("generator_version", generator.rootGeneration());
|
||||
report.put("initial_diameter", generator.initialDiameter());
|
||||
report.put("islands", observedIslands);
|
||||
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
|
||||
report.put("native_structure_observed", false);
|
||||
report.put("native_structure_search_complete", false);
|
||||
report.put("native_structure_status", "not_searched");
|
||||
report.put("scope", "Fresh versioned Sanctuary development world; real FULL chunks, a natural native-structure search, asynchronous activation, "
|
||||
+ "branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. "
|
||||
+ "The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated. "
|
||||
+ (OPTIONAL_STRUCTURE_WITNESS ? "The native structure witness is explicitly optional; an absent witness is reported as missing evidence, never as a successful structures test."
|
||||
: "A naturally accepted structure with placed vanilla loot is required."));
|
||||
}
|
||||
|
||||
void advance() {
|
||||
try {
|
||||
helper.assertTrue(helper.getTick() < 5800, "Expansion scenario deadline: " + session.progress());
|
||||
switch (phase) {
|
||||
case 0 -> start();
|
||||
case 1 -> {
|
||||
if (!session.island("alpha").status().equals("ready")) break;
|
||||
helper.assertTrue(session.island("alpha").equals(alpha.ready()), "Activation must only advance the immutable island state");
|
||||
observeIsland("alpha", Set.of("jungle", "sparse_jungle", "bamboo_jungle", "mangrove_swamp"));
|
||||
verifyMarker();
|
||||
beta = session.create("beta", "alpha", "east", "arid", 64, "natural", 2048);
|
||||
helper.assertTrue(beta.parent().equals("alpha") && beta.centerX() > alpha.centerX()
|
||||
&& beta.centerZ() == alpha.centerZ(), "A child must branch east from alpha, not from world origin");
|
||||
helper.assertTrue(beta.centerX() - alpha.centerX() >= 2048, "The requested minimum distance must be honoured from the chosen parent");
|
||||
verifyJournal();
|
||||
phase = 2;
|
||||
}
|
||||
case 2 -> {
|
||||
if (!session.island("beta").status().equals("ready")) break;
|
||||
helper.assertTrue(session.island("beta").equals(beta.ready()), "Branch activation must preserve its planned identity");
|
||||
observeIsland("beta", Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands"));
|
||||
verifyMarker();
|
||||
verifyJournal();
|
||||
verifyUntouchedVoid(helper, blockedSites);
|
||||
prepareStructureSearch();
|
||||
phase = 3;
|
||||
}
|
||||
case 3 -> {
|
||||
if (observeNativeStructure()) {
|
||||
verifyMarker();
|
||||
verifyJournal();
|
||||
level.setBlock(marker, previousMarker, 3);
|
||||
prepareRestartWitness();
|
||||
report.put("islands", observedIslands);
|
||||
report.put("native_structure_starts_seen", validStarts);
|
||||
report.put("structure_start_chunks_inspected", structureIndex);
|
||||
report.put("ticks", helper.getTick());
|
||||
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000.0);
|
||||
report.put("passed", true);
|
||||
writeReport();
|
||||
helper.succeed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalStateException("Unknown expansion test phase");
|
||||
}
|
||||
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Expansion scenario phase {}: {}", phase, session.progress());
|
||||
helper.runAtTickTime(helper.getTick() + 10, this::advance);
|
||||
} catch (Exception exception) {
|
||||
report.put("passed", false);
|
||||
report.put("failure", exception.toString());
|
||||
report.put("phase", phase);
|
||||
report.put("runtime_progress", session.progress());
|
||||
try { writeReport(); } catch (IOException failure) { exception.addSuppressed(failure); }
|
||||
SanctuaryMod.LOGGER.error("Expansion integration scenario failed", exception);
|
||||
helper.fail(exception.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void start() throws IOException {
|
||||
helper.assertTrue(session.islands().size() == 1, "The test must start from a new expansion journal");
|
||||
helper.assertTrue(level.structureManager().shouldGenerateStructures(), "The laboratory GameTest must enable actual vanilla structures in WorldOptions");
|
||||
ExpansionCommandChecks.verify(helper);
|
||||
report.put("registered_command_permission_checks", true);
|
||||
helper.assertTrue(generator.source().possibleBiomes().size() >= ExpansionBiomeSource.REQUIRED_BIOMES.size(),
|
||||
"Future biome holders must be declared before the first expansion");
|
||||
BlockPos landing = session.landing("sanctuary");
|
||||
helper.assertTrue(ExpansionRuntime.safeLanding(level, landing), "The initial laboratory island must provide a real safe landing");
|
||||
full(landing.getX() >> 4, landing.getZ() >> 4);
|
||||
marker = landing.above(6);
|
||||
previousMarker = level.getBlockState(marker);
|
||||
helper.assertTrue(level.setBlock(marker, Blocks.DIAMOND_BLOCK.defaultBlockState(), 3), "Place the parent player-edit witness");
|
||||
report.put("parent_witness", coordinates(marker));
|
||||
report.put("root_landing", coordinates(landing));
|
||||
samplePoints.put("sanctuary", landing);
|
||||
|
||||
for (String direction : ExpansionIsland.DIRECTIONS) {
|
||||
var site = session.candidate("blocked_" + direction, "sanctuary", direction, "boreal", 64, "natural");
|
||||
var chunk = full(site.centerX() >> 4, site.centerZ() >> 4);
|
||||
helper.assertTrue(Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()), "The first site must initially contain only real generated void");
|
||||
blockedSites.add(site);
|
||||
}
|
||||
int occupied = 0;
|
||||
for (var site : blockedSites) {
|
||||
try { ExpansionAdmission.verify(level, site); }
|
||||
catch (ExpansionAdmission.OccupiedException expected) { occupied++; }
|
||||
}
|
||||
helper.assertTrue(occupied == 8, "Reproduce the user failure with all eight first directions occupied");
|
||||
report.put("initial_occupied_directions", occupied);
|
||||
var nearestNorth = session.candidate("alpha", "sanctuary", "north", "tropical", 64, "natural");
|
||||
|
||||
alpha = session.create("alpha", "sanctuary", "north", "tropical", 64, "natural");
|
||||
helper.assertTrue(alpha.status().equals("reserved") && alpha.centerZ() < 0 && alpha.centerX() == 0,
|
||||
"North placement and the explicitly chosen tropical climate must remain independent");
|
||||
helper.assertTrue(alpha.centerZ() < nearestNorth.centerZ(), "Automatic placement must skip occupied chunks and continue farther north");
|
||||
helper.assertTrue(alpha.equals(session.create("alpha", "sanctuary", "north", "tropical", 64, "natural"))
|
||||
&& session.islands().size() == 2, "Repeating a matching request must not create another region or change its identity");
|
||||
boolean conflict = false;
|
||||
try { session.create("alpha", "sanctuary", "north", "arid", 64, "natural"); }
|
||||
catch (IllegalArgumentException expected) { conflict = expected.getMessage().equals("id_conflict"); }
|
||||
helper.assertTrue(conflict, "A conflicting duplicate island id must be refused");
|
||||
verifyJournal();
|
||||
phase = 1;
|
||||
}
|
||||
|
||||
private void observeIsland(String id, Set<String> expectedSurfaceBiomes) {
|
||||
ExpansionIsland island = session.island(id);
|
||||
BlockPos landing = session.landing(id);
|
||||
helper.assertTrue(island.contains(landing.getX(), landing.getZ()) && ExpansionRuntime.safeLanding(level, landing),
|
||||
"READY must offer actual safe terrain inside " + id);
|
||||
LevelChunk chunk = full(landing.getX() >> 4, landing.getZ() >> 4);
|
||||
helper.assertTrue(Arrays.stream(chunk.getSections()).anyMatch(section -> !section.hasOnlyAir()), "READY cannot be only a registry entry: " + id);
|
||||
// The 240 sample is the stored upper climate in a FULL chunk, independent of which safe ledge was found.
|
||||
String climateBiome = chunk.getNoiseBiome(landing.getX() >> 2, 60, landing.getZ() >> 2).unwrapKey().orElseThrow().identifier().toString();
|
||||
helper.assertTrue(expectedSurfaceBiomes.contains(climateBiome.substring("minecraft:".length())),
|
||||
"The FULL chunk must contain its requested upper climate: " + id + " / " + climateBiome);
|
||||
String landingBiome = level.getBiome(landing).unwrapKey().orElseThrow().identifier().toString();
|
||||
samplePoints.put(id, landing);
|
||||
observedIslands.add(Map.of("id", id, "parent", island.parent(), "climate", island.climate(),
|
||||
"centre", List.of(island.centerX(), island.centerZ()), "diameter", island.diameter(),
|
||||
"landing", coordinates(landing), "landing_biome", landingBiome, "upper_biome", climateBiome));
|
||||
}
|
||||
|
||||
private void verifyMarker() {
|
||||
helper.assertTrue(level.getBlockState(marker).is(Blocks.DIAMOND_BLOCK), "An expansion must preserve the parent player-edit witness");
|
||||
}
|
||||
|
||||
private void verifyJournal() throws IOException {
|
||||
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory());
|
||||
var reloaded = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
|
||||
helper.assertTrue(reloaded.islands().equals(session.islands()), "Re-reading the saved journal must preserve all region identities and states");
|
||||
report.put("journal_reloaded_regions", reloaded.islands().size());
|
||||
}
|
||||
|
||||
private void prepareRestartWitness() throws IOException {
|
||||
BlockPos gold = samplePoints.get("beta").above(6);
|
||||
while (!level.getBlockState(gold).isAir() && gold.getY() < level.getMaxY() - 1) gold = gold.above();
|
||||
helper.assertTrue(gold.getY() < level.getMaxY() - 1 && level.getBlockState(gold).isAir(), "The restart witness needs empty space above beta");
|
||||
helper.assertTrue(level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3), "Write the restart witness in the first process");
|
||||
var snapshot = new LinkedHashMap<String, Object>();
|
||||
snapshot.put("world_directory", worldDirectory(level));
|
||||
snapshot.put("gold_witness", coordinates(gold));
|
||||
snapshot.put("journal_sha256", sha256(Files.readString(journalFile(level))));
|
||||
snapshot.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
|
||||
snapshot.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
|
||||
snapshot.put("blocked_sites", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, blockedSites).getOrThrow());
|
||||
List<Map<String, Object>> samples = new ArrayList<>();
|
||||
for (var island : session.islands()) samples.add(sample(helper, generator, island, samplePoints.get(island.id())));
|
||||
snapshot.put("samples", samples);
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush the first process's world and marker before writing restart evidence");
|
||||
snapshot.put("world_flushed_before_shutdown", true);
|
||||
report.put("restart_snapshot", snapshot);
|
||||
}
|
||||
|
||||
private void prepareStructureSearch() {
|
||||
ExpansionIsland root = session.island("sanctuary");
|
||||
int limit = (root.terrainRadius() + 15) / 16;
|
||||
// STARTS only, centre outwards. Only an accepted structure's region is later requested at FULL.
|
||||
for (int ring = 0; ring <= limit; ring++) for (int x = -ring; x <= ring; x++) for (int z = -ring; z <= ring; z++) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) == ring && root.contains(x * 16 + 8, z * 16 + 8)) structureSearch.add(new ChunkPos(x, z));
|
||||
}
|
||||
report.put("structure_search_candidate_chunks", structureSearch.size());
|
||||
report.put("native_structure_status", "search_in_progress");
|
||||
}
|
||||
|
||||
private boolean observeNativeStructure() {
|
||||
int end = Math.min(structureSearch.size(), structureIndex + 16);
|
||||
while (structureIndex < end) {
|
||||
ChunkPos position = structureSearch.get(structureIndex++);
|
||||
var chunk = level.getChunkSource().getChunk(position.x(), position.z(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
helper.assertTrue(chunk != null, "A structure-start request must complete");
|
||||
for (StructureStart start : chunk.getAllStarts().values()) {
|
||||
if (!start.isValid()) continue;
|
||||
validStarts++;
|
||||
String name = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getKey(start.getStructure()).toString();
|
||||
if (!name.startsWith("minecraft:")) continue;
|
||||
Map<String, Object> evidence = materializeAndFindLoot(start, name);
|
||||
if (!evidence.isEmpty()) {
|
||||
report.put("native_structure", evidence);
|
||||
report.put("native_structure_observed", true);
|
||||
report.put("native_structure_status", "natural_start_with_placed_vanilla_loot_observed");
|
||||
report.put("native_structure_search_complete", structureIndex == structureSearch.size());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
report.put("native_structure_starts_seen", validStarts);
|
||||
report.put("structure_start_chunks_inspected", structureIndex);
|
||||
if (structureIndex == structureSearch.size()) {
|
||||
report.put("native_structure_search_complete", true);
|
||||
report.put("native_structure_status", "not_observed_after_complete_scan");
|
||||
report.put("native_structure_limitation", "No naturally accepted native structure with placed vanilla loot was observed after "
|
||||
+ structureIndex + " root-island start chunks and " + validStarts + " valid starts. "
|
||||
+ "This is missing engine evidence for structures, not a positive generation or loot result.");
|
||||
if (OPTIONAL_STRUCTURE_WITNESS) return true;
|
||||
}
|
||||
helper.assertTrue(structureIndex < structureSearch.size(), "No naturally accepted native structure with placed vanilla loot was observed; "
|
||||
+ validStarts + " valid starts. This is a missing engine proof, not a successful structures test.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private Map<String, Object> materializeAndFindLoot(StructureStart start, String name) {
|
||||
var bounds = start.getBoundingBox();
|
||||
List<LevelChunk> chunks = new ArrayList<>();
|
||||
for (int x = bounds.minX() >> 4; x <= bounds.maxX() >> 4; x++) {
|
||||
for (int z = bounds.minZ() >> 4; z <= bounds.maxZ() >> 4; z++) chunks.add(full(x, z));
|
||||
}
|
||||
for (LevelChunk chunk : chunks) {
|
||||
for (var entry : chunk.getBlockEntities().entrySet()) {
|
||||
if (!(entry.getValue() instanceof RandomizableContainerBlockEntity container) || container.getLootTable() == null) continue;
|
||||
BlockPos pos = entry.getKey();
|
||||
if (start.getPieces().stream().noneMatch(piece -> piece.getBoundingBox().isInside(pos))) continue;
|
||||
String loot = container.getLootTable().identifier().toString();
|
||||
if (!loot.startsWith("minecraft:")) continue;
|
||||
return Map.of("structure", name, "loot_table", loot, "container", coordinates(pos),
|
||||
"placed_block", level.getBlockState(pos).toString(), "full_chunks", chunks.size(),
|
||||
"mode", "natural candidate accepted by production ExpansionStructures, native decoration");
|
||||
}
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
private LevelChunk full(int x, int z) {
|
||||
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Expected real FULL chunk at " + new ChunkPos(x, z));
|
||||
return (LevelChunk) chunk;
|
||||
}
|
||||
|
||||
private void writeReport() throws IOException {
|
||||
writeDiagnostic(level, false, report);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> sample(GameTestHelper helper, ExpansionChunkGenerator generator, ExpansionIsland island, BlockPos point) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
LevelChunk chunk = full(helper, level, point.getX() >> 4, point.getZ() >> 4);
|
||||
var random = generator.randomState(island);
|
||||
var base = generator.getBaseColumn(point.getX(), point.getZ(), level, random);
|
||||
StringBuilder storedBlocks = new StringBuilder(), baseBlocks = new StringBuilder(), biomes = new StringBuilder(), resolvedBiomes = new StringBuilder();
|
||||
var resolver = generator.source().createUncachedResolver(random);
|
||||
for (int y = level.getMinY(); y <= level.getMaxY(); y++) {
|
||||
storedBlocks.append(chunk.getBlockState(new BlockPos(point.getX(), y, point.getZ()))).append('\n');
|
||||
baseBlocks.append(base.getBlock(y)).append('\n');
|
||||
if ((y & 3) == 0) {
|
||||
biomes.append(chunk.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
|
||||
resolvedBiomes.append(resolver.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
|
||||
}
|
||||
}
|
||||
return Map.of("id", island.id(), "point", coordinates(point), "stored_column_sha256", sha256(storedBlocks.toString()),
|
||||
"base_column_sha256", sha256(baseBlocks.toString()), "stored_biomes_sha256", sha256(biomes.toString()),
|
||||
"resolved_biomes_sha256", sha256(resolvedBiomes.toString()), "base_height",
|
||||
generator.getBaseHeight(point.getX(), point.getZ(), Heightmap.Types.WORLD_SURFACE_WG, level, random));
|
||||
}
|
||||
|
||||
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
|
||||
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Expected saved FULL chunk at " + new ChunkPos(x, z));
|
||||
return (LevelChunk) chunk;
|
||||
}
|
||||
|
||||
private static String sha256(String text) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
|
||||
private static Path journalFile(ServerLevel level) {
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory()).resolve(ExpansionJournal.FILE_NAME);
|
||||
}
|
||||
private static String worldDirectory(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString(); }
|
||||
private static Path diagnostic(ServerLevel level, boolean reload) {
|
||||
return Path.of("diagnostics", (Boolean.getBoolean("sanctuary.test.unified") ? "expansion-alpha13-" : "expansion-alpha12-1-") + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void writeDiagnostic(ServerLevel level, boolean reload, Map<String, Object> report) throws IOException {
|
||||
Path output = diagnostic(level, reload);
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
}
|
||||
private static BlockPos position(JsonArray coordinates) { return new BlockPos(coordinates.get(0).getAsInt(), coordinates.get(1).getAsInt(), coordinates.get(2).getAsInt()); }
|
||||
private static List<Integer> coordinates(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
}
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.SurfaceHydrology;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Checks the actual decorated terrain and fluid simulation in a disposable world, far from test fixtures. */
|
||||
public final class HydrologyDiagnostics {
|
||||
private static final List<Direction> HORIZONTAL = List.of(Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST);
|
||||
|
||||
private HydrologyDiagnostics() {}
|
||||
|
||||
public static void start(GameTestHelper helper) throws IOException {
|
||||
Context context = new Context(helper);
|
||||
context.prepare();
|
||||
context.verifyNaturalShellAndFinishedWater();
|
||||
context.verifyAllObservedWaterIsPlanned();
|
||||
context.export("generated");
|
||||
helper.assertTrue(context.caneBlocks > 0,
|
||||
"Regression seed must provide at least one naturally valid shore sugar cane plant; inspect the exported site map");
|
||||
// Other synchronous generation diagnostics finish before this tick. The temporary fluid
|
||||
// witness therefore cannot be counted by the initial resource survey.
|
||||
helper.runAtTickTime(20, context::startFluidWitness);
|
||||
helper.runAtTickTime(50, context::verifyFluidWitnessAndRemoveIt);
|
||||
helper.runAtTickTime(200, () -> {
|
||||
try {
|
||||
context.verifyNaturalShellAndFinishedWater();
|
||||
context.verifyAllObservedWaterIsPlanned();
|
||||
context.export("after-200-ticks");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: {} water blocks retained after real fluid ticks, {} chunk seams checked",
|
||||
context.level.getSeed(), context.expectedWater.size(), context.seams);
|
||||
helper.succeed();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Could not write hydrology diagnostics", exception);
|
||||
} finally {
|
||||
context.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void verifyLegacyIsolation(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var active = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertTrue(HydrologyRuntime.enabled(active), "The current preset must enable surface hydrology");
|
||||
helper.assertTrue(active.generatorSettings().is(SanctuarySpawn.HYDROLOGY_SETTINGS),
|
||||
"The active generator must use the new hydrology settings, not alter a legacy settings key");
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
for (var key : List.of(SanctuarySpawn.SETTINGS, SanctuarySpawn.RAISED_SETTINGS)) {
|
||||
var legacy = new NoiseBasedChunkGenerator(active.getBiomeSource(), registry.getOrThrow(key));
|
||||
helper.assertFalse(HydrologyRuntime.enabled(legacy), "Saved legacy generator must remain dry: " + key.identifier());
|
||||
helper.assertFalse(new ShoreSugarCaneFeature().place(level, legacy, RandomSource.create(0), BlockPos.ZERO),
|
||||
"Even an explicitly invoked shoreline feature must ignore legacy generators");
|
||||
}
|
||||
var biomes = level.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
var oldBiome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest"))).value();
|
||||
var wetBiome = biomes.getOrThrow(ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest_hydrology"))).value();
|
||||
var shore = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE).getOrThrow(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, SanctuaryMod.id("shore_sugar_cane"))).value();
|
||||
helper.assertFalse(oldBiome.getGenerationSettings().hasFeature(shore),
|
||||
"The old biome referenced by saved worlds must not acquire the new shoreline decoration");
|
||||
helper.assertTrue(wetBiome.getGenerationSettings().hasFeature(shore),
|
||||
"The new hydrology biome must include its shoreline decoration");
|
||||
helper.assertTrue(active.getBiomeSource().possibleBiomes().stream().allMatch(holder -> holder.is(
|
||||
ResourceKey.create(Registries.BIOME, SanctuaryMod.id("starter_forest_hydrology")))),
|
||||
"The current preset must select the separate hydrology biome");
|
||||
}
|
||||
|
||||
private static final class Context {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SurfaceHydrology.Plan plan;
|
||||
final RandomState random;
|
||||
final DensityFunction natural;
|
||||
final Map<Long, SurfaceHydrology.Cell> cells = new HashMap<>();
|
||||
final Set<BlockPos> expectedWater = new HashSet<>();
|
||||
final Map<Long, LevelChunk> chunks = new TreeMap<>();
|
||||
final Set<Long> newlyForced = new HashSet<>();
|
||||
BlockPos witness;
|
||||
boolean witnessPlaced;
|
||||
boolean allWaterChunksTicking;
|
||||
int seams;
|
||||
int caneBlocks;
|
||||
|
||||
Context(GameTestHelper helper) {
|
||||
this.helper = helper;
|
||||
level = helper.getLevel();
|
||||
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertTrue(HydrologyRuntime.enabled(generator), "Hydrology test requires the production wet preset");
|
||||
plan = HydrologyRuntime.plan(level);
|
||||
random = level.getChunkSource().randomState();
|
||||
natural = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
}
|
||||
|
||||
void prepare() {
|
||||
helper.assertTrue(!plan.features().isEmpty(), "Regression seed must exercise real surface water");
|
||||
for (var feature : plan.features()) {
|
||||
SanctuaryMod.LOGGER.info("Hydrology planned site: seed {}, {}, Y{}, start {}, {} water columns, {} path points",
|
||||
level.getSeed(), feature.kind(), feature.waterY(), feature.path().getFirst(), feature.waterCells(), feature.path().size());
|
||||
}
|
||||
if (level.getSeed() == 0L) {
|
||||
helper.assertTrue(plan.features().stream().anyMatch(feature -> feature.kind() == SurfaceHydrology.Kind.STREAM),
|
||||
"The reference seed 0 must demonstrate a real stream, not only isolated ponds; see the logged planned sites");
|
||||
}
|
||||
Set<Long> requested = new HashSet<>();
|
||||
for (var cell : plan.cells()) {
|
||||
helper.assertTrue(cells.put(ChunkPos.pack(cell.x(), cell.z()), cell) == null,
|
||||
"A plan must own each horizontal column exactly once");
|
||||
if (cell.hasWater()) {
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
expectedWater.add(new BlockPos(cell.x(), y, cell.z()));
|
||||
}
|
||||
}
|
||||
int cx = cell.x() >> 4;
|
||||
int cz = cell.z() >> 4;
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) requested.add(ChunkPos.pack(cx + dx, cz + dz));
|
||||
}
|
||||
}
|
||||
helper.assertTrue(!expectedWater.isEmpty(), "At least one generated water volume must be tested");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: preparing {} site and neighbour chunks for actual water inspection",
|
||||
level.getSeed(), requested.size());
|
||||
for (long packed : requested.stream().sorted().toList()) {
|
||||
ChunkPos pos = ChunkPos.unpack(packed);
|
||||
if (level.setChunkForced(pos.x(), pos.z(), true)) newlyForced.add(packed);
|
||||
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Hydrology inspection requires a decorated FULL chunk");
|
||||
chunks.put(packed, (LevelChunk) chunk);
|
||||
}
|
||||
// Use one contained witness in the SAME simulation chunk as actual water. It proves
|
||||
// that the island's fluids tick, rather than merely waiting beside the remote test grid.
|
||||
BlockPos first = expectedWater.stream().min(Comparator.comparingInt((BlockPos pos) -> pos.getX())
|
||||
.thenComparingInt(BlockPos::getZ).thenComparingInt(BlockPos::getY)).orElseThrow();
|
||||
witness = new BlockPos((first.getX() >> 4) * 16 + 8, level.getMaxY() - 20,
|
||||
(first.getZ() >> 4) * 16 + 8);
|
||||
for (BlockPos position : expectedWater) {
|
||||
for (Direction direction : List.of(Direction.EAST, Direction.SOUTH)) {
|
||||
BlockPos next = position.relative(direction);
|
||||
if (expectedWater.contains(next)
|
||||
&& (position.getX() >> 4 != next.getX() >> 4 || position.getZ() >> 4 != next.getZ() >> 4)) seams++;
|
||||
}
|
||||
}
|
||||
helper.assertTrue(seams > 0, "Regression seed must exercise a continuous water volume across a chunk seam");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: {} features, {} water blocks, {} FULL chunks including halo, {} seam adjacencies",
|
||||
level.getSeed(), plan.features().size(), expectedWater.size(), chunks.size(), seams);
|
||||
}
|
||||
|
||||
void verifyNaturalShellAndFinishedWater() {
|
||||
for (var cell : plan.cells()) {
|
||||
if (!cell.hasWater()) continue;
|
||||
helper.assertTrue(cell.waterY() > cell.bedY() && cell.waterY() - cell.bedY() <= SurfaceHydrology.MAX_DEPTH,
|
||||
"A surface basin must keep the declared shallow water depth");
|
||||
helper.assertTrue(cell.carveTop() - cell.bedY() <= SurfaceHydrology.MAX_CARVE,
|
||||
"Hydrology must not excavate a deep artificial chamber beneath the surface");
|
||||
for (int depth = 0; depth < 3; depth++) {
|
||||
int y = cell.bedY() - depth;
|
||||
helper.assertTrue(sample(cell.x(), y, cell.z()) > 0,
|
||||
"Water must retain three blocks of natural floor, without adding a liner at " + cell.x() + "," + y + "," + cell.z());
|
||||
BlockPos floor = new BlockPos(cell.x(), y, cell.z());
|
||||
helper.assertTrue(level.getBlockState(floor).isCollisionShapeFullBlock(level, floor),
|
||||
"Generated water floor must remain solid after decoration and fluid ticks: " + floor);
|
||||
}
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
BlockPos water = new BlockPos(cell.x(), y, cell.z());
|
||||
helper.assertTrue(level.getFluidState(water).is(FluidTags.WATER) && level.getFluidState(water).isSource(),
|
||||
"The planned water volume must exist as retained source water: " + water);
|
||||
for (Direction direction : HORIZONTAL) {
|
||||
BlockPos side = water.relative(direction);
|
||||
if (expectedWater.contains(side)) continue;
|
||||
helper.assertTrue(sample(side.getX(), side.getY(), side.getZ()) > 0,
|
||||
"Every external underwater side must be supported by natural terrain: " + side);
|
||||
helper.assertTrue(level.getBlockState(side).isCollisionShapeFullBlock(level, side),
|
||||
"A decorated shore must not leave a hole in the three-dimensional water shell: " + side);
|
||||
}
|
||||
}
|
||||
// The original density must have no roof above the explicitly carved surface.
|
||||
// Leaves and branches from neighbouring trees may shade the water afterwards.
|
||||
for (int y = cell.carveTop() + 1; y <= level.getMaxY(); y++) {
|
||||
helper.assertTrue(sample(cell.x(), y, cell.z()) <= 0,
|
||||
"A surface basin must not be a flooded underground chamber: " + cell.x() + "," + y + "," + cell.z());
|
||||
}
|
||||
}
|
||||
for (var feature : plan.features()) {
|
||||
if (feature.kind() != SurfaceHydrology.Kind.STREAM) continue;
|
||||
helper.assertTrue(feature.path().size() > 1, "A stream needs a route connecting distinct surface locations");
|
||||
double travelled = 0;
|
||||
for (int i = 1; i < feature.path().size(); i++) {
|
||||
var from = feature.path().get(i - 1);
|
||||
var to = feature.path().get(i);
|
||||
int length = Math.max(Math.abs(to.x() - from.x()), Math.abs(to.z() - from.z()));
|
||||
helper.assertTrue(length > 0, "A stream route must advance between consecutive points");
|
||||
helper.assertTrue(from.x() == to.x() || from.z() == to.z()
|
||||
|| Math.abs(to.x() - from.x()) == Math.abs(to.z() - from.z()),
|
||||
"Stream control points must be joined by cardinal or diagonal steps");
|
||||
travelled += Math.hypot(to.x() - from.x(), to.z() - from.z());
|
||||
BlockPos previous = null;
|
||||
for (int step = 0; step <= length; step++) {
|
||||
BlockPos p = new BlockPos(from.x() + (to.x() - from.x()) * step / length,
|
||||
feature.waterY(), from.z() + (to.z() - from.z()) * step / length);
|
||||
helper.assertTrue(expectedWater.contains(p) && level.getFluidState(p).is(FluidTags.WATER),
|
||||
"The real stream must form an uninterrupted water route across chunk boundaries: " + p);
|
||||
var column = plan.cellAt(p.getX(), p.getZ());
|
||||
helper.assertTrue(column != null && column.waterY() == feature.waterY(),
|
||||
"This stream contract has one constant water level along its whole route");
|
||||
if (previous != null && p.getX() != previous.getX() && p.getZ() != previous.getZ()) {
|
||||
BlockPos bridgeX = new BlockPos(p.getX(), p.getY(), previous.getZ());
|
||||
BlockPos bridgeZ = new BlockPos(previous.getX(), p.getY(), p.getZ());
|
||||
helper.assertTrue(expectedWater.contains(bridgeX) && level.getFluidState(bridgeX).is(FluidTags.WATER)
|
||||
|| expectedWater.contains(bridgeZ) && level.getFluidState(bridgeZ).is(FluidTags.WATER),
|
||||
"Diagonal route steps need actual cardinal water connectivity, not only touching corners");
|
||||
}
|
||||
previous = p;
|
||||
}
|
||||
}
|
||||
var first = feature.path().getFirst();
|
||||
var last = feature.path().getLast();
|
||||
helper.assertTrue(travelled >= 24 && Math.hypot(last.x() - first.x(), last.z() - first.z()) >= 16,
|
||||
"A demonstrated stream must have at least 24 blocks of route between locations at least 16 blocks apart");
|
||||
}
|
||||
}
|
||||
|
||||
void verifyAllObservedWaterIsPlanned() {
|
||||
int[] observed = {0};
|
||||
int[] cane = {0};
|
||||
for (LevelChunk chunk : chunks.values()) {
|
||||
chunk.findBlocks(state -> state.getFluidState().is(FluidTags.WATER) || state.is(Blocks.SUGAR_CANE), (pos, state) -> {
|
||||
if (state.getFluidState().is(FluidTags.WATER)) {
|
||||
helper.assertTrue(expectedWater.contains(pos),
|
||||
"Water escaped its declared surface volume, possibly through a chunk seam or toward the void: " + pos);
|
||||
observed[0]++;
|
||||
}
|
||||
if (state.is(Blocks.SUGAR_CANE)) {
|
||||
helper.assertTrue(state.canSurvive(level, pos), "Generated shore sugar cane must survive at " + pos);
|
||||
cane[0]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
helper.assertTrue(observed[0] == expectedWater.size(), "Observed water must account for every planned water block");
|
||||
caneBlocks = cane[0];
|
||||
}
|
||||
|
||||
void startFluidWitness() {
|
||||
helper.assertTrue(level.shouldTickBlocksAt(witness), "The witness and its water-site chunk must actually tick");
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
for (int dy = -4; dy <= 0; dy++) {
|
||||
BlockPos p = witness.offset(dx, dy, dz);
|
||||
helper.assertTrue(level.getBlockState(p).isAir(), "The temporary simulation witness must use empty test space");
|
||||
}
|
||||
}
|
||||
}
|
||||
witnessPlaced = true;
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
for (int dy = -4; dy <= 0; dy++) {
|
||||
if (dx != 0 || dz != 0 || dy == -4) level.setBlock(witness.offset(dx, dy, dz), Blocks.STONE.defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
level.setBlock(witness, Blocks.WATER.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getFluidState(witness.below()).isEmpty(), "Witness starts with an empty cell below its source");
|
||||
level.scheduleTick(witness, Fluids.WATER, 1);
|
||||
for (BlockPos water : expectedWater) {
|
||||
helper.assertTrue(level.shouldTickBlocksAt(water), "Every actual water-site chunk must be simulated: " + water);
|
||||
level.scheduleTick(water, Fluids.WATER, 1);
|
||||
}
|
||||
allWaterChunksTicking = true;
|
||||
}
|
||||
|
||||
void verifyFluidWitnessAndRemoveIt() {
|
||||
try {
|
||||
helper.assertTrue(level.getFluidState(witness.below()).is(FluidTags.WATER),
|
||||
"Real fluid simulation must move the witness water down inside the forced island chunk");
|
||||
SanctuaryMod.LOGGER.info("Hydrology seed {}: fluid tick witness passed at {}", level.getSeed(), witness);
|
||||
} finally {
|
||||
removeWitness();
|
||||
}
|
||||
}
|
||||
|
||||
void removeWitness() {
|
||||
if (!witnessPlaced) return;
|
||||
// Empty the fluid column before opening its walls, keeping the test fixture contained.
|
||||
for (int dy = 0; dy >= -3; dy--) level.setBlock(witness.offset(0, dy, 0), Blocks.AIR.defaultBlockState(), 3);
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
for (int dy = -4; dy <= 0; dy++) level.setBlock(witness.offset(dx, dy, dz), Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
witnessPlaced = false;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
removeWitness();
|
||||
for (long packed : newlyForced) {
|
||||
ChunkPos chunk = ChunkPos.unpack(packed);
|
||||
level.setChunkForced(chunk.x(), chunk.z(), false);
|
||||
}
|
||||
}
|
||||
|
||||
float sample(int x, int y, int z) {
|
||||
return random.sampleBlockValueUncached(natural, x, y, z);
|
||||
}
|
||||
|
||||
void export(String phase) throws IOException {
|
||||
Path directory = Path.of("diagnostics");
|
||||
Files.createDirectories(directory);
|
||||
String stem = "surface-hydrology-seed-" + level.getSeed() + "-" + phase;
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("phase", phase);
|
||||
report.put("actual_water_blocks", expectedWater.size());
|
||||
report.put("actual_cane_blocks", caneBlocks);
|
||||
report.put("checked_chunk_seam_adjacencies", seams);
|
||||
report.put("all_water_site_chunks_confirmed_ticking", allWaterChunksTicking);
|
||||
report.put("inspected_full_chunks", chunks.values().stream().map(chunk ->
|
||||
Map.of("x", chunk.getPos().x(), "z", chunk.getPos().z())).toList());
|
||||
report.put("features", plan.features());
|
||||
report.put("shore_diagnostics_by_feature", shoreDiagnostics());
|
||||
report.put("planned_columns", plan.cells());
|
||||
report.put("scope", "actual blocks in completed site chunks and a one-chunk halo; uninspected map cells remain blank");
|
||||
Files.writeString(directory.resolve(stem + ".json"), new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
exportMap(directory.resolve(stem + ".png"), phase);
|
||||
}
|
||||
|
||||
Map<Long, Map<String, Object>> shoreDiagnostics() {
|
||||
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
|
||||
for (var feature : plan.features()) {
|
||||
int dryBanks = 0;
|
||||
int banksAtWaterLevel = 0;
|
||||
int baseCanSurvive = 0;
|
||||
int unobstructedBases = 0;
|
||||
int viableUnobstructedBases = 0;
|
||||
int actualCane = 0;
|
||||
for (var cell : plan.cells()) {
|
||||
if (cell.featureId() != feature.id() || cell.hasWater()) continue;
|
||||
dryBanks++;
|
||||
if (cell.bedY() == feature.waterY()) banksAtWaterLevel++;
|
||||
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
|
||||
boolean viable = Blocks.SUGAR_CANE.defaultBlockState().canSurvive(level, base);
|
||||
boolean clear = level.getBlockState(base).isAir() || level.getBlockState(base).is(Blocks.SUGAR_CANE);
|
||||
if (viable) baseCanSurvive++;
|
||||
if (clear) unobstructedBases++;
|
||||
if (viable && clear) viableUnobstructedBases++;
|
||||
for (int height = 0; height < 4; height++) {
|
||||
if (level.getBlockState(base.above(height)).is(Blocks.SUGAR_CANE)) actualCane++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
result.put(feature.id(), Map.of("kind", feature.kind().name(), "water_y", feature.waterY(),
|
||||
"dry_bank_columns", dryBanks, "bank_surface_at_water_level", banksAtWaterLevel,
|
||||
"bases_where_cane_can_survive", baseCanSurvive, "unobstructed_bases", unobstructedBases,
|
||||
"viable_unobstructed_bases", viableUnobstructedBases,
|
||||
"actual_cane_blocks_on_planned_banks", actualCane));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void exportMap(Path output, String phase) throws IOException {
|
||||
int minX = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMinBlockX()).min().orElseThrow();
|
||||
int maxX = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMaxBlockX()).max().orElseThrow();
|
||||
int minZ = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMinBlockZ()).min().orElseThrow();
|
||||
int maxZ = chunks.values().stream().mapToInt(chunk -> chunk.getPos().getMaxBlockZ()).max().orElseThrow();
|
||||
int scale = Math.max(1, Math.min(3, 1000 / Math.max(maxX - minX + 1, maxZ - minZ + 1)));
|
||||
int width = (maxX - minX + 1) * scale;
|
||||
int height = (maxZ - minZ + 1) * scale;
|
||||
BufferedImage image = new BufferedImage(Math.max(width + 40, 920), height + 110, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
graphics.setColor(new Color(0xF0F0EC));
|
||||
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
|
||||
graphics.setColor(new Color(0x17222C));
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 17));
|
||||
graphics.drawString("Actual FULL chunks | seed " + level.getSeed() + " | " + phase, 20, 24);
|
||||
graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
|
||||
graphics.drawString("Water blue; sand gold; clay teal; gravel brown; cane bright green; terrain height grey. Canopy skipped.", 20, 45);
|
||||
graphics.drawString("Not a Minecraft render. X=" + minX + ".." + maxX + ", Z=" + minZ + ".." + maxZ
|
||||
+ ". Dark blue = void; dark brown = not inspected; faint lines = chunk seams.", 20, 63);
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
LevelChunk chunk = chunks.get(ChunkPos.pack(x >> 4, z >> 4));
|
||||
int color = 0x3A3030;
|
||||
if (chunk != null) {
|
||||
color = 0x142738;
|
||||
for (int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE, x, z); y >= level.getMinY(); y--) {
|
||||
position.set(x, y, z);
|
||||
BlockState state = chunk.getBlockState(position);
|
||||
if (state.getFluidState().is(FluidTags.WATER)) { color = 0x207BD4; break; }
|
||||
if (state.is(Blocks.SUGAR_CANE)) { color = 0x7AFF38; break; }
|
||||
if (state.is(Blocks.SAND)) { color = 0xD5B668; break; }
|
||||
if (state.is(Blocks.CLAY)) { color = 0x6D9FAD; break; }
|
||||
if (state.is(Blocks.GRAVEL)) { color = 0x9B8878; break; }
|
||||
if (state.isAir() || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|
||||
|| state.getCollisionShape(level, position).isEmpty()) continue;
|
||||
int value = 40 + Math.clamp(y, 0, level.getHeight()) * 180 / level.getHeight();
|
||||
color = value << 16 | value << 8 | value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
graphics.setColor(new Color(color));
|
||||
graphics.fillRect(20 + (x - minX) * scale, 80 + (z - minZ) * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
graphics.setColor(new Color(255, 255, 255, 35));
|
||||
for (int x = minX; x <= maxX; x += 16) graphics.drawLine(20 + (x - minX) * scale, 80, 20 + (x - minX) * scale, 80 + height);
|
||||
for (int z = minZ; z <= maxZ; z += 16) graphics.drawLine(20, 80 + (z - minZ) * scale, 20 + width, 80 + (z - minZ) * scale);
|
||||
graphics.dispose();
|
||||
ImageIO.write(image, "png", output.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1308
File diff suppressed because it is too large
Load Diff
+911
@@ -0,0 +1,911 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
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.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionJournal;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityInteriors15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPalette;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPiece15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityStructures;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCitySurface15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCitySupportPiece15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground15;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUndergroundPiece15;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
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.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.entity.npc.villager.Villager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.DoorBlock;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BedPart;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Opt-in generation-15 proof: bounded natural prospects, native placement, fluid ticks and a second JVM.
|
||||
* It never imports an artificial city into the natural-coverage totals or opens a personal world. */
|
||||
public final class LostCity15WorldGameTests {
|
||||
private static final Set<LostCityUnderground15.Kind> REQUIRED_UNDERGROUND = Set.of(
|
||||
LostCityUnderground15.Kind.SEWER, LostCityUnderground15.Kind.POOLROOM, LostCityUnderground15.Kind.GARAGE);
|
||||
private static final Set<LostCityPlan.Kind> SURFACE_INTEREST = Set.of(
|
||||
LostCityPlan.Kind.BRIDGE, LostCityPlan.Kind.POOL, LostCityPlan.Kind.PARKING);
|
||||
private static final int MAX_DISTRICTS_PER_ISLAND = 6;
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void cityFragmentsAndUtilitiesPersistNatively(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Lost City15 requires the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 15, "Lost City15 must use a fresh generation-15 world");
|
||||
if (Boolean.getBoolean("sanctuary.test.expansionReload")) reload(helper, generator);
|
||||
else new Scenario(helper, generator).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final ExpansionRuntime.Session session;
|
||||
final Map<String, Object> report;
|
||||
final List<Map<String, Object>> sites = new ArrayList<>();
|
||||
final List<Map<String, Object>> prospects = new ArrayList<>();
|
||||
final List<Map<String, Object>> naturalPlans = new ArrayList<>();
|
||||
final List<LostCityUnderground15.Module> wetModules = new ArrayList<>();
|
||||
final List<LostCityPlan.Parcel> wetSurfacePools = new ArrayList<>();
|
||||
final Set<LostCityUnderground15.Kind> observedKinds = new HashSet<>();
|
||||
final Set<LostCityPlan.Kind> observedSurfaceKinds = new HashSet<>();
|
||||
final Set<ChunkPos> forced = new HashSet<>();
|
||||
final List<BlockPos> tickWitnesses = new ArrayList<>();
|
||||
int phase, round;
|
||||
String childId;
|
||||
long readinessWaitTick, simulationStartWorldTick = -1;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
|
||||
session = ExpansionRuntime.session(level); report = report(level);
|
||||
report.put("sites", sites); report.put("city_prospects", prospects); report.put("natural_plans", naturalPlans);
|
||||
}
|
||||
|
||||
void advance() {
|
||||
try {
|
||||
if (phase == 0) {
|
||||
verifyLegacy(helper, generator);
|
||||
report.put("saved_interior_models", verifyInteriorModels(helper));
|
||||
observeIsland(session.island("sanctuary"));
|
||||
// Always exercise one actual expansion; a second is reserved for missing natural utility types.
|
||||
childId = prospect();
|
||||
helper.assertTrue(childId != null, "The bounded natural prospects found no city with a building; see city_prospects");
|
||||
session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
phase = 1;
|
||||
}
|
||||
if (phase == 1 && session.island(childId).status().equals("ready")) {
|
||||
observeIsland(session.island(childId));
|
||||
if (!observedKinds.containsAll(REQUIRED_UNDERGROUND) && round < 2) {
|
||||
childId = prospect();
|
||||
helper.assertTrue(childId != null, "Second bounded search cannot supply a natural city reference");
|
||||
session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
} else {
|
||||
helper.assertTrue(observedKinds.containsAll(REQUIRED_UNDERGROUND),
|
||||
"Natural reference coverage must include sewer, poolroom and garage; found " + observedKinds);
|
||||
helper.assertTrue(sites.size() >= 2, "The reference must inspect several distinct native district starts");
|
||||
for (var module : wetModules) {
|
||||
forceBounds(LostCityUndergroundPiece15.bounds(module));
|
||||
}
|
||||
for (var pool : wetSurfacePools) {
|
||||
forceBounds(new LostCityPiece15(pool).getBoundingBox());
|
||||
}
|
||||
readinessWaitTick = helper.getTick(); phase = 2;
|
||||
}
|
||||
}
|
||||
if (phase == 2 && simulationStartWorldTick < 0) awaitFluidReadiness();
|
||||
if (phase == 2 && simulationStartWorldTick >= 0 && level.getGameTime() - simulationStartWorldTick >= 120) {
|
||||
verifyFluidWitnesses();
|
||||
report.put("natural_fluid_checks", wetModules.stream().map(m -> verifyWater(helper, m, true)).toList());
|
||||
report.put("natural_surface_pool_checks", wetSurfacePools.stream().map(p -> verifySurfacePool(helper, p, true)).toList());
|
||||
removeFluidWitnesses();
|
||||
var witnessSite = sites.stream().filter(site -> site.containsKey("edit_position")).findFirst().orElseThrow();
|
||||
@SuppressWarnings("unchecked") var at = (List<Integer>) witnessSite.get("edit_position");
|
||||
var gold = new BlockPos(at.get(0), at.get(1), at.get(2));
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", coords(gold));
|
||||
report.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
|
||||
report.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
|
||||
report.put("journal_sha256", hash(Files.readString(journal(level, generator))));
|
||||
releaseForced();
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush all native starts, utility bodies, supports, loot and gameplay edit");
|
||||
report.put("world_flushed_before_shutdown", true); report.put("passed", true);
|
||||
write(level, report, false); helper.succeed(); return;
|
||||
}
|
||||
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Lost City15 integration phase {}: {}", phase, session.progress());
|
||||
helper.runAtTickTime(helper.getTick() + 10, this::advance);
|
||||
} catch (Exception error) {
|
||||
removeFluidWitnesses();
|
||||
releaseForced(); report.put("passed", false); report.put("failure", error.toString());
|
||||
try { write(level, report, false); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Lost City15 native creation failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
void releaseForced() {
|
||||
for (var pos : forced) level.setChunkForced(pos.x(), pos.z(), false);
|
||||
forced.clear();
|
||||
}
|
||||
void forceBounds(BoundingBox bounds) {
|
||||
bounds.intersectingChunks().forEach(pos -> { level.setChunkForced(pos.x(), pos.z(), true); forced.add(pos); });
|
||||
}
|
||||
|
||||
void awaitFluidReadiness() throws IOException {
|
||||
var pending = forced.stream().filter(pos -> !level.isPositionTickingWithEntitiesLoaded(pos.pack())).toList();
|
||||
report.put("stage", "waiting_for_native_fluid_scheduler"); report.put("fluid_game_time", level.getGameTime());
|
||||
if (!pending.isEmpty()) {
|
||||
report.put("pending_fluid_chunks", pending.stream().map(pos -> Map.of("chunk", List.of(pos.x(), pos.z()),
|
||||
"ticket", level.shouldTickBlocksAt(pos.pack()), "entities", level.areEntitiesLoaded(pos.pack()),
|
||||
"ticking_future", level.getChunkSource().isPositionTicking(pos.pack()))).toList());
|
||||
helper.assertTrue(helper.getTick() - readinessWaitTick < 800,
|
||||
"Forced natural water chunks did not become scheduler-ready within800ticks: " + pending);
|
||||
if (helper.getTick() % 100 == 0) {
|
||||
SanctuaryMod.LOGGER.info("Lost City15 waiting for actual fluid scheduler: {} pending chunks at world time {}", pending.size(), level.getGameTime());
|
||||
write(level, report, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
report.remove("pending_fluid_chunks");
|
||||
// Establish retention before simulation, so a pre-existing leak cannot masquerade as a scheduling delay.
|
||||
report.put("natural_water_before_simulation", wetModules.stream().map(m -> verifyWater(helper, m, false)).toList());
|
||||
for (var p : wetSurfacePools) verifySurfacePool(helper, p, false);
|
||||
for (var pos : forced.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) placeFluidWitness(pos);
|
||||
for (var module : wetModules) for (var water : expectedWater(module)) level.scheduleTick(water, Fluids.WATER, 1);
|
||||
for (var pool : wetSurfacePools) for (var water : expectedSurfaceWater(pool)) level.scheduleTick(water, Fluids.WATER, 1);
|
||||
simulationStartWorldTick = level.getGameTime();
|
||||
report.put("stage", "observed_native_fluid_simulation"); report.put("fluid_simulation_start_game_time", simulationStartWorldTick);
|
||||
report.put("temporary_scheduler_witnesses", tickWitnesses.size()); write(level, report, false);
|
||||
}
|
||||
|
||||
void placeFluidWitness(ChunkPos chunk) {
|
||||
BlockPos witness = null;
|
||||
for (int localX : new int[]{4, 8, 12}) for (int localZ : new int[]{4, 8, 12}) {
|
||||
var candidate = new BlockPos(chunk.x() * 16 + localX, 378, chunk.z() * 16 + localZ);
|
||||
boolean empty = true;
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
empty &= level.getBlockState(candidate.offset(x, y, z)).isAir();
|
||||
if (empty && witness == null) witness = candidate;
|
||||
}
|
||||
helper.assertTrue(witness != null, "The independent fluid witness needs empty development sky in " + chunk);
|
||||
tickWitnesses.add(witness);
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
if (x != 0 || z != 0 || y == -4) level.setBlock(witness.offset(x, y, z), Blocks.STONE.defaultBlockState(), 3);
|
||||
helper.assertTrue(level.getFluidState(witness.below()).isEmpty(), "Independent witness starts with an empty cell below its source");
|
||||
level.setBlock(witness, Blocks.WATER.defaultBlockState(), 3); level.scheduleTick(witness, Fluids.WATER, 1);
|
||||
}
|
||||
|
||||
void verifyFluidWitnesses() {
|
||||
helper.assertTrue(forced.stream().allMatch(pos -> level.isPositionTickingWithEntitiesLoaded(pos.pack())),
|
||||
"Every observed natural water chunk must remain scheduler-ready throughout the measured interval");
|
||||
for (var witness : tickWitnesses) helper.assertTrue(level.getFluidState(witness.below()).is(net.minecraft.tags.FluidTags.WATER),
|
||||
"A real native water tick must flow downward in each forced natural-water chunk: " + witness);
|
||||
helper.assertTrue(!tickWitnesses.isEmpty(), "Fluid execution proof cannot be empty");
|
||||
report.put("fluid_execution_proof", Map.of("scope", "Independent enclosed temporary sky witnesses, excluded from natural city and water counts",
|
||||
"chunks", tickWitnesses.size(), "downward_flow_observed", true, "measured_world_ticks", level.getGameTime() - simulationStartWorldTick));
|
||||
}
|
||||
|
||||
void removeFluidWitnesses() {
|
||||
for (var witness : tickWitnesses) {
|
||||
// Empty the fluid before removing its enclosing walls; this leaves the originally empty sky unchanged.
|
||||
for (int y = 0; y >= -3; y--) level.setBlock(witness.offset(0, y, 0), Blocks.AIR.defaultBlockState(), 3);
|
||||
level.getFluidTicks().clearArea(new BoundingBox(witness.getX() - 1, witness.getY() - 4, witness.getZ() - 1,
|
||||
witness.getX() + 1, witness.getY(), witness.getZ() + 1));
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) for (int y = -4; y <= 0; y++)
|
||||
level.setBlock(witness.offset(x, y, z), Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
tickWitnesses.clear();
|
||||
}
|
||||
|
||||
String prospect() throws IOException {
|
||||
int bestScore = -1; String best = null;
|
||||
for (int candidateIndex = 0; candidateIndex < 3; candidateIndex++) {
|
||||
String id = "city15_" + round + "_" + candidateIndex;
|
||||
var candidate = session.candidate(id, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
try {
|
||||
var plan = generator.lostCityPlan(candidate);
|
||||
var underground = generator.undergroundPlan(candidate);
|
||||
var kinds = new HashSet<LostCityUnderground15.Kind>();
|
||||
underground.networks().forEach(n -> n.modules().forEach(m -> kinds.add(m.kind())));
|
||||
var surfaceKinds = new HashSet<LostCityPlan.Kind>();
|
||||
plan.districts().forEach(d -> d.parcels().forEach(p -> surfaceKinds.add(p.kind())));
|
||||
long buildings = plan.districts().stream().flatMap(d -> d.parcels().stream()).filter(p -> p.kind().building()).count();
|
||||
prospects.add(Map.of("id", id, "seed", candidate.seed(), "diameter", candidate.diameter(),
|
||||
"districts", plan.districts().size(), "buildings", buildings, "underground_networks", underground.networks().size(),
|
||||
"underground_kinds", kinds.stream().map(Enum::name).sorted().toList(), "read_only", true,
|
||||
"surface_kinds", surfaceKinds.stream().map(Enum::name).sorted().toList(), "viaduct_piers", generator.citySupports(candidate).size()));
|
||||
SanctuaryMod.LOGGER.info("Lost City15 read-only prospect {} seed {}: {} districts, {} buildings, utility kinds {}",
|
||||
id, candidate.seed(), plan.districts().size(), buildings, kinds);
|
||||
report.put("passed", false); report.put("stage", "read_only_prospects"); write(level, report, false);
|
||||
int score = (int) kinds.stream().filter(REQUIRED_UNDERGROUND::contains).filter(k -> !observedKinds.contains(k)).count() * 1000
|
||||
+ (kinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) && !observedKinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) ? 150 : 0)
|
||||
+ (int) surfaceKinds.stream().filter(SURFACE_INTEREST::contains).filter(k -> !observedSurfaceKinds.contains(k)).count() * 100
|
||||
+ Math.min(20, underground.networks().size() * 4) + Math.min(10, plan.districts().size());
|
||||
if (buildings > 0 && score > bestScore) { bestScore = score; best = id; }
|
||||
} finally { generator.discardUnpublishedContext(candidate); }
|
||||
}
|
||||
round++;
|
||||
return best;
|
||||
}
|
||||
|
||||
void observeIsland(ExpansionIsland island) throws IOException {
|
||||
helper.assertTrue(island.generation() == 15, "Reference islands must retain generation 15");
|
||||
var plan = generator.lostCityPlan(island);
|
||||
var underground = generator.undergroundPlan(island);
|
||||
Map<String, Integer> surfaceTypes = new TreeMap<>(), undergroundTypes = new TreeMap<>();
|
||||
plan.districts().forEach(d -> d.parcels().forEach(p -> surfaceTypes.merge(p.kind().name(), 1, Integer::sum)));
|
||||
underground.networks().forEach(n -> n.modules().forEach(m -> undergroundTypes.merge(m.kind().name(), 1, Integer::sum)));
|
||||
naturalPlans.add(Map.of("island", island.id(), "seed", island.seed(), "diameter", island.diameter(),
|
||||
"districts", plan.districts().size(), "surface_kinds", surfaceTypes, "viaduct_supports", generator.citySupports(island).size(),
|
||||
"underground_networks", underground.networks().size(), "underground_kinds", undergroundTypes));
|
||||
SanctuaryMod.LOGGER.info("Lost City15 natural reference {} seed {} before native inspection: {} districts, surface {}, utility {}, {} piers",
|
||||
island.id(), island.seed(), plan.districts().size(), surfaceTypes, undergroundTypes, generator.citySupports(island).size());
|
||||
report.put("passed", false); report.put("stage", "native_inspection_" + island.id()); write(level, report, false);
|
||||
List<LostCityPlan.District> selected = new ArrayList<>(plan.districts());
|
||||
if (selected.isEmpty()) {
|
||||
report.put(island.origin() ? "starter_city_absent" : island.id() + "_city_absent", true);
|
||||
return;
|
||||
}
|
||||
for (int inspected = 0; inspected < MAX_DISTRICTS_PER_ISLAND && !selected.isEmpty(); inspected++) {
|
||||
selected.sort(Comparator.comparingInt((LostCityPlan.District district) -> districtScore(district, underground)).reversed()
|
||||
.thenComparingInt(LostCityPlan.District::startChunkX).thenComparingInt(LostCityPlan.District::startChunkZ));
|
||||
var district = selected.removeFirst();
|
||||
var result = observe(helper, generator, island, district, underground);
|
||||
sites.add(result);
|
||||
district.parcels().forEach(p -> {
|
||||
observedSurfaceKinds.add(p.kind());
|
||||
if (p.kind() == LostCityPlan.Kind.POOL && !expectedSurfaceWater(p).isEmpty()) wetSurfacePools.add(p);
|
||||
});
|
||||
for (var network : networks(district, underground)) for (var module : network.modules()) {
|
||||
observedKinds.add(module.kind());
|
||||
if (!expectedWater(module).isEmpty()) wetModules.add(module);
|
||||
}
|
||||
}
|
||||
}
|
||||
int districtScore(LostCityPlan.District district, LostCityUnderground15.Plan underground) {
|
||||
Set<LostCityUnderground15.Kind> kinds = new HashSet<>();
|
||||
networks(district, underground).forEach(n -> n.modules().forEach(m -> kinds.add(m.kind())));
|
||||
var surfaceKinds = district.parcels().stream().map(LostCityPlan.Parcel::kind).distinct().toList();
|
||||
return (int) kinds.stream().filter(REQUIRED_UNDERGROUND::contains).filter(k -> !observedKinds.contains(k)).count() * 1000
|
||||
+ (kinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) && !observedKinds.contains(LostCityUnderground15.Kind.EXPANSION_HALL) ? 150 : 0)
|
||||
+ (int) surfaceKinds.stream().filter(SURFACE_INTEREST::contains).filter(k -> !observedSurfaceKinds.contains(k)).count() * 300
|
||||
+ (int) surfaceKinds.stream().filter(LostCityPlan.Kind::building).filter(k -> !observedSurfaceKinds.contains(k)).count() * 20
|
||||
+ (int) district.parcels().stream().filter(p -> p.kind().building()).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<LostCityUnderground15.Network> networks(LostCityPlan.District d, LostCityUnderground15.Plan plan) {
|
||||
return plan.networks().stream().filter(n -> n.startChunkX() == d.startChunkX() && n.startChunkZ() == d.startChunkZ()).toList();
|
||||
}
|
||||
|
||||
private static Map<String, Object> observe(GameTestHelper helper, SanctuaryChunkGenerator generator, ExpansionIsland island,
|
||||
LostCityPlan.District district, LostCityUnderground15.Plan underground) {
|
||||
var level = helper.getLevel();
|
||||
var holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
var startChunk = level.getChunkSource().getChunk(district.startChunkX(), district.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = startChunk.getStartForStructure(holder.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted generation-15 district needs a native start");
|
||||
int modules = networks(district, underground).stream().mapToInt(n -> n.modules().size()).sum();
|
||||
long piers = generator.citySupports(island).stream().filter(s -> s.startChunkX() == district.startChunkX()
|
||||
&& s.startChunkZ() == district.startChunkZ()).count();
|
||||
helper.assertTrue(start.getPieces().size() == district.parcels().size() + modules + piers,
|
||||
"Native start must save every surface parcel, utility module and viaduct pier");
|
||||
Set<Long> chunks = new HashSet<>();
|
||||
for (var piece : start.getPieces()) piece.getBoundingBox().intersectingChunks().forEach(pos -> {
|
||||
if (!chunks.add(pos.pack())) return;
|
||||
helper.assertTrue(chunks.size() <= 225, "District inspection must stay inside the native reference footprint");
|
||||
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk && chunk.getReferencesForStructure(holder.value()).contains(start.getChunkPos().pack()),
|
||||
"Every touched FULL chunk must retain the native structure reference");
|
||||
});
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var tag = start.createTag(context, start.getChunkPos());
|
||||
var loaded = StructureStart.loadStaticStart(context, tag, island.seed());
|
||||
helper.assertTrue(loaded != null && loaded.isValid() && loaded.createTag(context, loaded.getChunkPos()).equals(tag),
|
||||
"Native starts must round-trip all surface/underground fields, openings, supports and anchors");
|
||||
Map<String, Integer> surfaceKinds = new TreeMap<>(), moduleKinds = new TreeMap<>();
|
||||
List<Map<String, Object>> chests = new ArrayList<>(), rooms = new ArrayList<>(), surfaceAccess = new ArrayList<>();
|
||||
int biomes = 0, supports = 0;
|
||||
for (var piece : start.getPieces()) {
|
||||
if (piece instanceof LostCityPiece15 surface) {
|
||||
var p = surface.parcel(); surfaceKinds.merge(p.kind().name(), 1, Integer::sum);
|
||||
var centre = new BlockPos(p.minX() + p.width() / 2, p.floorY() + 1, p.minZ() + p.depth() / 2);
|
||||
if (level.getBiome(centre).is(SanctuaryMod.id("lost_city"))) biomes++;
|
||||
var monsters = generator.getMobsAt(level, level.structureManager(), MobCategory.MONSTER, centre).unwrap();
|
||||
helper.assertTrue(!monsters.isEmpty() && monsters.stream().allMatch(e -> e.value().type() == EntityTypes.ZOMBIE),
|
||||
"Native city monster overrides must contain vanilla zombies only");
|
||||
if (p.kind().building()) rooms.add(Map.of("kind", p.kind().name(), "min_x", p.minX(), "min_z", p.minZ(),
|
||||
"facing", p.facing(), "walkable_rooms", verifyInterior(helper, p, piece.getBoundingBox())));
|
||||
else if (p.kind() == LostCityPlan.Kind.BRIDGE || p.kind() == LostCityPlan.Kind.PARKING)
|
||||
surfaceAccess.add(verifySurfaceAccess(helper, p, piece.getBoundingBox()));
|
||||
scanBlocks(helper, piece.getBoundingBox(), p.kind().building() ? p.floorY() : Integer.MAX_VALUE, chests);
|
||||
} else if (piece instanceof LostCityUndergroundPiece15 utility) {
|
||||
var m = utility.module(); moduleKinds.merge(m.kind().name(), 1, Integer::sum);
|
||||
var pieceTag = utility.createTag(context);
|
||||
var decoded = new LostCityUndergroundPiece15(context, pieceTag);
|
||||
helper.assertTrue(decoded.module().equals(m) && decoded.createTag(context).equals(pieceTag), "Utility bodies and support records must persist exactly");
|
||||
supports += verifySupports(helper, m);
|
||||
scanBlocks(helper, new BoundingBox(m.minX(), m.minY(), m.minZ(), m.maxX(), m.maxY(), m.maxZ()), m.minY(), chests);
|
||||
} else if (piece instanceof LostCitySupportPiece15 pier) {
|
||||
var pieceTag = pier.createTag(context);
|
||||
var decoded = new LostCitySupportPiece15(context, pieceTag);
|
||||
helper.assertTrue(decoded.support().equals(pier.support()) && decoded.createTag(context).equals(pieceTag),
|
||||
"Viaduct pier geometry and footing must round-trip through native NBT");
|
||||
verifyPier(helper, pier); supports++;
|
||||
} else helper.assertTrue(false, "Generation 15 loaded a historical or unknown native piece type");
|
||||
}
|
||||
for (var network : networks(district, underground)) verifyNetworkAccess(helper, network);
|
||||
helper.assertTrue(chests.size() >= rooms.size(), "Every observed building needs its unopened deferred native loot chest");
|
||||
helper.assertTrue(biomes > 0, "At least one real saved city biome sample must exist per inspected district");
|
||||
var bounds = start.getBoundingBox();
|
||||
helper.assertTrue(level.getEntitiesOfClass(Villager.class, new AABB(bounds.minX(), bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX() + 1, bounds.maxY() + 1, bounds.maxZ() + 1)).isEmpty(), "The abandoned native district must not spawn villagers");
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(holder),
|
||||
new BlockPos(district.startChunkX() * 16 + 8, 220, district.startChunkZ() * 16 + 8), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(LostCityStructures.KEY), "Exact native locate must find Lost City");
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("island", island.id()); result.put("generation", island.generation()); result.put("island_seed", island.seed());
|
||||
result.put("start_chunk", List.of(district.startChunkX(), district.startChunkZ()));
|
||||
result.put("native_start_sha256", hash(tag.toString())); result.put("native_pieces", start.getPieces().size());
|
||||
result.put("full_chunks_inspected", chunks.size()); result.put("surface_kinds", surfaceKinds); result.put("underground_kinds", moduleKinds);
|
||||
result.put("supports_checked", supports); result.put("viaduct_piers", piers);
|
||||
result.put("walkable_buildings", rooms); result.put("biome_witnesses", biomes);
|
||||
result.put("native_surface_access", surfaceAccess);
|
||||
result.put("native_city_circulation", verifyCityCirculation(helper, district.parcels()));
|
||||
result.put("unopened_chests", chests); result.put("native_zombie_only_monster_spawns", true);
|
||||
result.put("native_locate", coords(located.getFirst()));
|
||||
district.parcels().stream().filter(p -> p.kind().building()).findFirst().ifPresent(first ->
|
||||
result.put("edit_position", List.of(first.minX(), first.floorY() + 1, first.minZ())));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void scanBlocks(GameTestHelper helper, BoundingBox box, int noOreFromY, List<Map<String, Object>> chests) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = box.minX(); x <= box.maxX(); x++) for (int z = box.minZ(); z <= box.maxZ(); z++)
|
||||
for (int y = box.minY(); y <= box.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); var block = level.getBlockState(pos);
|
||||
helper.assertTrue(!block.is(Blocks.SPAWNER), "Native city must not contain a zombie spawner");
|
||||
if (y >= noOreFromY) helper.assertTrue(!BuiltInRegistries.BLOCK.getKey(block.getBlock()).getPath().endsWith("_ore"),
|
||||
"Native ore decoration replaced protected masonry/interiors at " + pos);
|
||||
if (block.is(Blocks.CHEST) && level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity chest && chest.getLootTable() != null)
|
||||
chests.add(Map.of("position", coords(pos), "table", chest.getLootTable().identifier().toString(), "seed", chest.getLootTableSeed()));
|
||||
}
|
||||
}
|
||||
|
||||
private record StreetSection(String description, List<BlockPos> candidates) { }
|
||||
|
||||
/** Only drawn roads and the two front porch columns are walkable in this proof. Natural terrain,
|
||||
* building interiors and roofs cannot disguise disconnected streets or an isolated raised lot. */
|
||||
private static Map<String, Object> verifyCityCirculation(GameTestHelper helper, List<LostCityPlan.Parcel> parcels) {
|
||||
Set<BlockPos> permitted = new HashSet<>();
|
||||
List<StreetSection> sections = new ArrayList<>();
|
||||
List<Map<String, Object>> entrances = new ArrayList<>();
|
||||
List<BlockPos> targets = new ArrayList<>();
|
||||
int streets = 0;
|
||||
for (var parcel : parcels) {
|
||||
if (parcel.kind() == LostCityPlan.Kind.STREET || parcel.kind() == LostCityPlan.Kind.BRIDGE) {
|
||||
streets++;
|
||||
for (int x = 0; x < parcel.width(); x++) for (int z = 0; z < parcel.depth(); z++)
|
||||
for (int rise = 1; rise <= 2; rise++) permitted.add(new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + rise, parcel.minZ() + z));
|
||||
boolean eastWest = parcel.width() >= parcel.depth();
|
||||
int length = eastWest ? parcel.width() : parcel.depth();
|
||||
int breadth = eastWest ? parcel.depth() : parcel.width();
|
||||
for (int along = 0; along < length; along++) {
|
||||
List<BlockPos> options = new ArrayList<>();
|
||||
// A closed utility hatch may occupy the exact middle; the adjacent traffic lane
|
||||
// must still cross this complete street section without leaving the road.
|
||||
for (int across = Math.max(0, breadth / 2 - 1); across <= Math.min(breadth - 1, breadth / 2 + 1); across++)
|
||||
for (int rise = 1; rise <= 2; rise++) {
|
||||
int x = eastWest ? along : across, z = eastWest ? across : along;
|
||||
options.add(new BlockPos(parcel.minX() + x, parcel.surfaceY(x, z) + rise, parcel.minZ() + z));
|
||||
}
|
||||
sections.add(new StreetSection(parcel.kind() + " at " + parcel.minX() + "," + parcel.minZ() + " section " + along, options));
|
||||
}
|
||||
} else {
|
||||
BlockPos entrance = rotatedWorld(parcel, parcel.width() / 2, 1, 0);
|
||||
targets.add(entrance);
|
||||
entrances.add(Map.of("kind", parcel.kind().name(), "position", coords(entrance)));
|
||||
permitted.add(entrance);
|
||||
permitted.add(rotatedWorld(parcel, parcel.width() / 2 - 1, 1, 0));
|
||||
}
|
||||
}
|
||||
helper.assertTrue(streets > 0 && !targets.isEmpty(), "A native city must connect actual lots to drawn streets");
|
||||
var source = targets.getFirst();
|
||||
var reached = walk(helper, source, permitted::contains);
|
||||
for (var target : targets) helper.assertTrue(reached.contains(target),
|
||||
"Native city entrance is isolated from its road network: " + source + " to " + target);
|
||||
List<List<Integer>> witnessedSections = new ArrayList<>();
|
||||
for (var section : sections) {
|
||||
var witness = section.candidates().stream().filter(reached::contains).findFirst();
|
||||
helper.assertTrue(witness.isPresent(), "Native street is blocked or disconnected: " + section.description()
|
||||
+ "; candidates " + section.candidates());
|
||||
witnessedSections.add(coords(witness.orElseThrow()));
|
||||
}
|
||||
SanctuaryMod.LOGGER.info("Lost City15 native circulation: {} lots, {} street/bridge pieces, {} crossed sections, {} standing cells from {}",
|
||||
targets.size(), streets, sections.size(), reached.size(), source);
|
||||
return Map.of("scope", "Connected drawn streets and front porches only; no natural terrain, interior or roof shortcuts",
|
||||
"source", coords(source), "entrances", entrances, "street_pieces", streets,
|
||||
"crossed_street_sections", witnessedSections, "reachable_standing_cells", reached.size(), "connected", true);
|
||||
}
|
||||
|
||||
private static int verifyInterior(GameTestHelper helper, LostCityPlan.Parcel p, BoundingBox bounds) {
|
||||
var level = helper.getLevel();
|
||||
Map<BlockPos, BlockState> doors = openDoors(helper, bounds, false);
|
||||
try {
|
||||
var targets = LostCityInteriors15.rooms(p);
|
||||
Set<BlockPos> reached = walk(helper, world(p, targets.getFirst()), bounds::isInside);
|
||||
for (var room : targets) helper.assertTrue(reached.contains(world(p, room)),
|
||||
"Native " + p.kind() + " cannot reach room " + room.name() + " at " + world(p, room));
|
||||
helper.assertTrue(!doors.isEmpty(), "Detailed interiors need real two-block doors");
|
||||
for (var entry : doors.entrySet()) if (entry.getValue().getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.LOWER) {
|
||||
Direction normal = entry.getValue().getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
helper.assertTrue(reached.contains(entry.getKey().relative(normal)) && reached.contains(entry.getKey().relative(normal.getOpposite())),
|
||||
"A placed native door has an inaccessible side at " + entry.getKey());
|
||||
for (Direction side : new Direction[]{normal.getClockWise(), normal.getCounterClockWise()}) {
|
||||
var neighbor = doors.get(entry.getKey().relative(side));
|
||||
if (neighbor == null || neighbor.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) != DoubleBlockHalf.LOWER
|
||||
|| neighbor.getValue(BlockStateProperties.HORIZONTAL_FACING) != normal) continue;
|
||||
var opened = level.getBlockState(entry.getKey()).getCollisionShape(level, entry.getKey()).bounds();
|
||||
helper.assertTrue(switch (side) {
|
||||
case EAST -> opened.maxX <= .25; case WEST -> opened.minX >= .75;
|
||||
case SOUTH -> opened.maxZ <= .25; case NORTH -> opened.minZ >= .75;
|
||||
default -> false;
|
||||
}, "Native double door opens into its partner instead of against its outer jamb at " + entry.getKey());
|
||||
}
|
||||
}
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) for (int z = bounds.minZ(); z <= bounds.maxZ(); z++)
|
||||
for (int y = p.floorY() + 1; y <= p.maxY(); y++) {
|
||||
var at = new BlockPos(x, y, z); var state = level.getBlockState(at);
|
||||
if (state.is(Blocks.CHEST)) helper.assertTrue(adjacent(at, reached), "Native loot chest cannot be approached from its room");
|
||||
if (state.is(Blocks.LANTERN)) {
|
||||
var support = state.getValue(BlockStateProperties.HANGING) ? at.above() : at.below();
|
||||
helper.assertTrue(level.getBlockState(support).isCollisionShapeFullBlock(level, support) && state.canSurvive(level, at),
|
||||
"Native lantern floats against a slab or lacks a complete support at " + at);
|
||||
}
|
||||
if (state.is(Blocks.DARK_OAK_FENCE) && level.getBlockState(at.above()).is(Blocks.DARK_OAK_PRESSURE_PLATE)) {
|
||||
for (var side : HORIZONTAL) {
|
||||
var chair = at.relative(side); var seat = level.getBlockState(chair);
|
||||
if (!seat.is(Blocks.DARK_OAK_STAIRS)) continue;
|
||||
var shape = seat.getCollisionShape(level, chair).toAabbs();
|
||||
double nearX = .5 - side.getStepX() * .25, nearZ = .5 - side.getStepZ() * .25;
|
||||
double farX = .5 + side.getStepX() * .25, farZ = .5 + side.getStepZ() * .25;
|
||||
helper.assertTrue(shape.stream().noneMatch(box -> box.contains(nearX, .75, nearZ))
|
||||
&& shape.stream().anyMatch(box -> box.contains(farX, .75, farZ)),
|
||||
"Native seat has its back toward its table or desk at " + chair);
|
||||
}
|
||||
}
|
||||
if (!state.is(Blocks.BED.white())) continue;
|
||||
var part = state.getValue(BlockStateProperties.BED_PART);
|
||||
var facing = state.getValue(BlockStateProperties.HORIZONTAL_FACING);
|
||||
var partner = at.relative(part == BedPart.FOOT ? facing : facing.getOpposite());
|
||||
helper.assertTrue(level.getBlockState(partner).equals(state.setValue(BlockStateProperties.BED_PART,
|
||||
part == BedPart.FOOT ? BedPart.HEAD : BedPart.FOOT)), "Native placement severed a bed at " + at);
|
||||
helper.assertTrue(adjacent(at, reached) || adjacent(partner, reached), "Native bed cannot be approached");
|
||||
}
|
||||
return targets.size();
|
||||
} finally { doors.forEach((pos, state) -> level.setBlock(pos, state, 2)); }
|
||||
}
|
||||
|
||||
private static Map<BlockPos, BlockState> openDoors(GameTestHelper helper, BoundingBox bounds, boolean hatch) {
|
||||
var level = helper.getLevel(); Map<BlockPos, BlockState> originals = new HashMap<>();
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) for (int z = bounds.minZ(); z <= bounds.maxZ(); z++)
|
||||
for (int y = bounds.minY(); y <= bounds.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); var state = level.getBlockState(pos);
|
||||
if (state.getBlock() instanceof DoorBlock) {
|
||||
helper.assertTrue(state.is(Blocks.DARK_OAK_DOOR), "Room doors must be manually openable");
|
||||
boolean lower = state.getValue(BlockStateProperties.DOUBLE_BLOCK_HALF) == DoubleBlockHalf.LOWER;
|
||||
helper.assertTrue(level.getBlockState(lower ? pos.above() : pos.below()).equals(state.setValue(
|
||||
BlockStateProperties.DOUBLE_BLOCK_HALF, lower ? DoubleBlockHalf.UPPER : DoubleBlockHalf.LOWER)),
|
||||
"Native door halves disagree at " + pos);
|
||||
originals.put(pos, state);
|
||||
} else if (hatch && state.is(Blocks.DARK_OAK_TRAPDOOR)) originals.put(pos, state);
|
||||
}
|
||||
// The test opens both native halves, measures the route, then restores all original states.
|
||||
originals.forEach((pos, state) -> level.setBlock(pos, state.setValue(BlockStateProperties.OPEN, true), 2));
|
||||
return originals;
|
||||
}
|
||||
|
||||
private static void verifyNetworkAccess(GameTestHelper helper, LostCityUnderground15.Network network) {
|
||||
var access = network.modules().stream().filter(m -> m.kind() == LostCityUnderground15.Kind.ACCESS).findFirst().orElseThrow();
|
||||
var bounds = LostCityUndergroundPiece15.bounds(access);
|
||||
var doors = openDoors(helper, bounds, true);
|
||||
try {
|
||||
BlockPos hatch = doors.keySet().stream().filter(pos -> doors.get(pos).is(Blocks.DARK_OAK_TRAPDOOR)).findFirst().orElseThrow();
|
||||
Predicate<BlockPos> inside = pos -> network.modules().stream().anyMatch(m -> m.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
BlockPos approach = java.util.Arrays.stream(HORIZONTAL).map(hatch::relative)
|
||||
.filter(inside).filter(pos -> standable(helper.getLevel(), pos)).findFirst().orElseThrow();
|
||||
Set<BlockPos> reached = walk(helper, approach, inside);
|
||||
helper.assertTrue(reached.contains(hatch), "Surface hatch must be accessible from its street-level approach");
|
||||
for (var m : network.modules()) helper.assertTrue(reached.stream().anyMatch(pos -> pos.getY() == m.floorY() + 1
|
||||
&& pos.getX() > m.minX() && pos.getX() < m.maxX() && pos.getZ() > m.minZ() && pos.getZ() < m.maxZ()),
|
||||
"Native surface hatch cannot reach utility module " + m.kind() + " at " + m.centerX() + "," + m.centerZ());
|
||||
for (var m : network.modules()) if (m.kind() == LostCityUnderground15.Kind.EXPANSION_HALL) {
|
||||
helper.assertTrue(reached.contains(new BlockPos(m.centerX(), m.minY() + 3, m.centerZ())),
|
||||
"Native expansion hall's reserved central plinth cannot be reached from the surface hatch");
|
||||
for (int dx : new int[]{-6, 6}) for (int dz : new int[]{-6, 6})
|
||||
helper.assertTrue(reached.contains(new BlockPos(m.centerX() + dx, m.floorY() + 1, m.centerZ() + dz)),
|
||||
"Native expansion hall has an inaccessible quarter at " + m.centerX() + "," + m.centerZ());
|
||||
}
|
||||
} finally { doors.forEach((pos, state) -> helper.getLevel().setBlock(pos, state, 2)); }
|
||||
}
|
||||
|
||||
private static int verifySupports(GameTestHelper helper, LostCityUnderground15.Module m) {
|
||||
var level = helper.getLevel();
|
||||
for (var support : m.supports()) {
|
||||
helper.assertTrue(support.maxY() - support.minY() < 8, "Native support exceeds its bounded rock anchor distance");
|
||||
for (int y = support.minY(); y <= support.maxY(); y++) {
|
||||
var expected = support.type() == LostCityUnderground15.Type.PILLAR ? Blocks.STONE_BRICKS
|
||||
: y == support.maxY() ? Blocks.POLISHED_ANDESITE : Blocks.IRON_CHAIN;
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(support.x(), y, support.z())).is(expected),
|
||||
"Native supported module has a broken or overwritten pillar/chain");
|
||||
}
|
||||
int neighborY = support.type() == LostCityUnderground15.Type.PILLAR ? support.minY() - 1 : support.maxY() + 1;
|
||||
var neighbor = level.getBlockState(new BlockPos(support.x(), neighborY, support.z()));
|
||||
helper.assertTrue(neighbor.isSolid() && neighbor.getFluidState().isEmpty(), "Native support lost its rock anchor");
|
||||
}
|
||||
return m.supports().size();
|
||||
}
|
||||
|
||||
private static void verifyPier(GameTestHelper helper, LostCitySupportPiece15 piece) {
|
||||
var support = piece.support(); var level = helper.getLevel();
|
||||
helper.assertTrue(support.maxY() - support.minY() < 16, "Native viaduct grew an unbounded pier");
|
||||
for (int x = support.minX(); x <= support.maxX(); x++) for (int z = support.minZ(); z <= support.maxZ(); z++) {
|
||||
var anchor = level.getBlockState(new BlockPos(x, support.minY() - 1, z));
|
||||
helper.assertTrue(anchor.isSolid() && anchor.getFluidState().isEmpty(), "Native viaduct pier lost its natural footing");
|
||||
for (int y = support.minY(); y <= support.maxY(); y++) {
|
||||
var expected = y == support.minY() || y == support.maxY() ? Blocks.SMOOTH_STONE.defaultBlockState()
|
||||
: LostCityPalette.masonry(support.seed(), x - support.minX(), y - support.minY(), z - support.minZ());
|
||||
helper.assertTrue(level.getBlockState(new BlockPos(x, y, z)).equals(expected), "Native viaduct pier is interrupted or overwritten");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifySurfaceAccess(GameTestHelper helper, LostCityPlan.Parcel p, BoundingBox bounds) {
|
||||
BlockPos start, target;
|
||||
if (p.kind() == LostCityPlan.Kind.BRIDGE) {
|
||||
boolean eastWest = p.width() >= p.depth();
|
||||
int startX = eastWest ? 0 : p.width() / 2, startZ = eastWest ? p.depth() / 2 : 0;
|
||||
int endX = eastWest ? p.width() - 1 : p.width() / 2, endZ = eastWest ? p.depth() / 2 : p.depth() - 1;
|
||||
start = new BlockPos(p.minX() + startX, p.surfaceY(startX, startZ) + 1, p.minZ() + startZ);
|
||||
target = new BlockPos(p.minX() + endX, p.surfaceY(endX, endZ) + 1, p.minZ() + endZ);
|
||||
if (!standable(helper.getLevel(), start) && helper.getLevel().getBlockState(start).is(Blocks.STONE_BRICK_STAIRS)) start = start.above();
|
||||
if (!standable(helper.getLevel(), target) && helper.getLevel().getBlockState(target).is(Blocks.STONE_BRICK_STAIRS)) target = target.above();
|
||||
} else {
|
||||
start = rotatedWorld(p, 8, 1, 0);
|
||||
target = rotatedWorld(p, 8, p.foundationDepth() >= 8 ? -4 : 1, 10);
|
||||
}
|
||||
Set<BlockPos> reached = walk(helper, start, bounds::isInside);
|
||||
helper.assertTrue(reached.contains(target), "Native " + p.kind() + " lost its traversable access: " + start + " to " + target);
|
||||
return Map.of("kind", p.kind().name(), "start", coords(start), "destination", coords(target),
|
||||
"underground_garage", p.kind() == LostCityPlan.Kind.PARKING && p.foundationDepth() >= 8, "walkable", true);
|
||||
}
|
||||
|
||||
private static Set<BlockPos> expectedSurfaceWater(LostCityPlan.Parcel p) {
|
||||
return surfaceWater(p).sources();
|
||||
}
|
||||
|
||||
private record SurfaceWater(Set<BlockPos> sources, Set<BlockPos> flowEnvelope,
|
||||
Map<BlockPos, BlockState> shell, Map<BlockPos, BlockState> steps) { }
|
||||
|
||||
private static SurfaceWater surfaceWater(LostCityPlan.Parcel p) {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
LostCitySurface15.render(p, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
blocks.put(new BlockPos(p.minX() + x, p.floorY() + y, p.minZ() + z), state);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) { }
|
||||
});
|
||||
Set<BlockPos> sources = new HashSet<>(), basin = new HashSet<>();
|
||||
Map<BlockPos, BlockState> shell = new HashMap<>(), steps = new HashMap<>();
|
||||
blocks.forEach((pos, state) -> { if (!state.getFluidState().isEmpty()) sources.add(pos); });
|
||||
for (int x = 4; x <= p.width() - 5; x++) for (int z = 5; z <= p.depth() - 5; z++)
|
||||
for (int y = -3; y <= -1; y++) basin.add(rotatedWorld(p, x, y, z));
|
||||
// The submerged steps deliberately clear cells above their lower treads. Native water
|
||||
// may fill those cells; this finite closure permits that flow without permitting a leak.
|
||||
Set<BlockPos> envelope = new HashSet<>(sources);
|
||||
ArrayDeque<BlockPos> queue = new ArrayDeque<>(sources);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
if (!basin.contains(pos)) throw new IllegalStateException("Rendered pool has an open water boundary at " + pos);
|
||||
for (var direction : Direction.values()) if (direction != Direction.UP) {
|
||||
var next = pos.relative(direction);
|
||||
var state = blocks.getOrDefault(next, Blocks.AIR.defaultBlockState());
|
||||
if (state.isAir() || !state.getFluidState().isEmpty()) {
|
||||
if (envelope.add(next)) queue.addLast(next);
|
||||
} else shell.put(next, state);
|
||||
}
|
||||
}
|
||||
for (int step = 1; step <= 3; step++) for (int x = p.width() / 2 - 1; x <= p.width() / 2 + 1; x++) {
|
||||
var pos = rotatedWorld(p, x, -step, step + 4);
|
||||
if (!sources.isEmpty()) steps.put(pos, blocks.get(pos));
|
||||
}
|
||||
return new SurfaceWater(Set.copyOf(sources), Set.copyOf(envelope), Map.copyOf(shell), Map.copyOf(steps));
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifySurfacePool(GameTestHelper helper, LostCityPlan.Parcel p, boolean ticked) {
|
||||
var level = helper.getLevel(); var water = surfaceWater(p);
|
||||
helper.assertTrue(water.sources().size() >= 150, "Naturally planned surface pool must contain a substantial basin");
|
||||
var witness = water.sources().stream().sorted(BlockPos::compareTo).findFirst().orElseThrow();
|
||||
if (ticked) {
|
||||
helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(ChunkPos.containing(witness).pack()), "Natural surface pool must actually tick");
|
||||
}
|
||||
int count = 0;
|
||||
for (int x = p.minX(); x <= p.maxX(); x++) for (int z = p.minZ(); z <= p.maxZ(); z++)
|
||||
for (int y = p.minY(); y <= p.maxY(); y++) {
|
||||
var at = new BlockPos(x, y, z); boolean wet = !level.getFluidState(at).isEmpty();
|
||||
helper.assertTrue(!wet || water.flowEnvelope().contains(at), "Native surface pool leaked outside its sealed basin at " + at);
|
||||
helper.assertTrue(!wet || level.getFluidState(at).is(net.minecraft.tags.FluidTags.WATER), "Native surface pool contains a foreign fluid at " + at);
|
||||
helper.assertTrue(!water.sources().contains(at) || wet && level.getFluidState(at).isSource(),
|
||||
"Native surface pool lost an original water source at " + at);
|
||||
if (wet) count++;
|
||||
}
|
||||
water.shell().forEach((pos, expected) -> helper.assertTrue(level.getBlockState(pos).equals(expected),
|
||||
"Native surface pool lost its continuous floor or walls at " + pos));
|
||||
water.steps().forEach((pos, expected) -> helper.assertTrue(level.getBlockState(pos).equals(expected),
|
||||
"Native pool steps changed or lost their waterlogging after fluid ticks/reload at " + pos));
|
||||
return Map.of("scope", "Naturally planned surface pool, not an isolated fixture", "min", List.of(p.minX(), p.floorY(), p.minZ()),
|
||||
"facing", p.facing(), "retained_water_cells", count, "checked_after_observed_fluid_simulation", ticked,
|
||||
"contained", true, "steps_preserved", true, "original_water_cells", water.sources().size(),
|
||||
"flow_reachable_cells", water.flowEnvelope().size(), "new_intra_basin_water_cells", count - water.sources().size());
|
||||
}
|
||||
|
||||
private static Set<BlockPos> expectedWater(LostCityUnderground15.Module module) {
|
||||
Set<BlockPos> water = new HashSet<>();
|
||||
LostCityUnderground15.render(module, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
var pos = new BlockPos(module.minX() + x, module.minY() + y, module.minZ() + z);
|
||||
if (!state.getFluidState().isEmpty()) water.add(pos); else water.remove(pos);
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> loot, long seed) { }
|
||||
});
|
||||
return water;
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifyWater(GameTestHelper helper, LostCityUnderground15.Module m, boolean ticked) {
|
||||
var level = helper.getLevel(); var water = expectedWater(m);
|
||||
BlockPos witness = water.stream().sorted(BlockPos::compareTo).findFirst().orElseThrow();
|
||||
if (ticked) {
|
||||
helper.assertTrue(level.isPositionTickingWithEntitiesLoaded(ChunkPos.containing(witness).pack()), "Natural poolroom/sewer fixture must actually tick");
|
||||
}
|
||||
int count = 0;
|
||||
for (int x = m.minX(); x <= m.maxX(); x++) for (int z = m.minZ(); z <= m.maxZ(); z++)
|
||||
for (int y = m.minY(); y <= m.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z); boolean wet = !level.getFluidState(pos).isEmpty();
|
||||
helper.assertTrue(wet == water.contains(pos), "Natural utility water leaked or disappeared at " + pos + " in " + m.kind());
|
||||
if (wet) count++;
|
||||
}
|
||||
helper.assertTrue(count > 0, "Native wet module needs retained water");
|
||||
return Map.of("scope", "Naturally planned and natively generated utility module", "kind", m.kind().name(),
|
||||
"min", List.of(m.minX(), m.minY(), m.minZ()), "retained_water_cells", count,
|
||||
"checked_after_observed_fluid_simulation", ticked, "contained", true);
|
||||
}
|
||||
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel();
|
||||
helper.assertTrue(inside.test(start) && standable(level, start), "Native route has a blocked starting position: " + start);
|
||||
Set<BlockPos> reached = new HashSet<>(); ArrayDeque<BlockPos> queue = new ArrayDeque<>();
|
||||
reached.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (Direction d : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(d).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !passable(level.getBlockState(pos.above(2)))) continue;
|
||||
if (standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[]{-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0);
|
||||
if (!inside.test(next)) continue;
|
||||
if ((climbable(level.getBlockState(pos)) || climbable(level.getBlockState(next)) || climbable(level.getBlockState(pos.below())))
|
||||
&& standable(level, next) && reached.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(reached.size() < 100_000, "Native room traversal exceeded its bounded structural footprint");
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
var below = level.getBlockState(pos.below());
|
||||
return passable(level.getBlockState(pos)) && passable(level.getBlockState(pos.above()))
|
||||
&& (below.isSolid() || below.getBlock() instanceof StairBlock || climbable(level.getBlockState(pos)) || climbable(below));
|
||||
}
|
||||
private static boolean climbable(BlockState state) { return state.is(Blocks.LADDER); }
|
||||
private static boolean passable(BlockState state) {
|
||||
return state.isAir() || state.is(Blocks.LADDER) || state.is(Blocks.REDSTONE_WALL_TORCH)
|
||||
|| (state.is(Blocks.DARK_OAK_DOOR) || state.is(Blocks.DARK_OAK_TRAPDOOR)) && state.getValue(BlockStateProperties.OPEN);
|
||||
}
|
||||
private static boolean adjacent(BlockPos pos, Set<BlockPos> reached) {
|
||||
for (Direction direction : HORIZONTAL) if (reached.contains(pos.relative(direction))) return true;
|
||||
return false;
|
||||
}
|
||||
private static BlockPos world(LostCityPlan.Parcel p, LostCityInteriors15.Room room) {
|
||||
return new BlockPos(p.minX() + room.x(), p.floorY() + room.y(), p.minZ() + room.z());
|
||||
}
|
||||
private static BlockPos rotatedWorld(LostCityPlan.Parcel p, int x, int y, int z) {
|
||||
int rx = switch (p.facing()) { case 0 -> x; case 1 -> p.width() - 1 - z; case 2 -> p.width() - 1 - x; default -> z; };
|
||||
int rz = switch (p.facing()) { case 0 -> z; case 1 -> x; case 2 -> p.depth() - 1 - z; default -> p.depth() - 1 - x; };
|
||||
return new BlockPos(p.minX() + rx, p.floorY() + y, p.minZ() + rz);
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> verifyInteriorModels(GameTestHelper helper) {
|
||||
var context = StructurePieceSerializationContext.fromLevel(helper.getLevel());
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (var kind : LostCityPlan.Kind.values()) if (kind.building()) for (int facing = 0; facing < 4; facing++) {
|
||||
var parcel = new LostCityPlan.Parcel(kind, -32, 48, 17, 17, 160, 3, facing, 1515, 4, new int[0]);
|
||||
var piece = new LostCityPiece15(parcel); var tag = piece.createTag(context);
|
||||
var reloaded = new LostCityPiece15(context, tag);
|
||||
helper.assertTrue(reloaded.createTag(context).equals(tag), "Generation-15 interior parcel fields did not persist");
|
||||
helper.assertTrue(LostCityInteriors15.rooms(parcel).equals(LostCityInteriors15.rooms(reloaded.parcel())), "Reload changed room targets");
|
||||
result.add(Map.of("kind", kind.name(), "facing", facing, "native_piece_sha256", hash(tag.toString()),
|
||||
"scope", "Serialization fixture only; natural occurrence is recorded separately in sites"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void verifyLegacy(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, helper.getLevel().registryAccess());
|
||||
for (int generation : new int[]{13, 14}) {
|
||||
var json = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
json.addProperty("type", "sanctuary:island_v" + generation);
|
||||
json.getAsJsonObject("biome_source").addProperty("type", "sanctuary:island_v" + generation);
|
||||
var legacy = (SanctuaryChunkGenerator) ChunkGenerator.CODEC.parse(ops, json).getOrThrow();
|
||||
helper.assertTrue(legacy.rootGeneration() == generation && legacy.journalDirectory().equals("sanctuary-world-v" + generation),
|
||||
"Historical codecs must retain their exact generation and journal");
|
||||
helper.assertTrue(legacy.undergroundPlan(ExpansionIsland.origin(generator.initialDiameter(), helper.getLevel().getSeed(), generation)).isEmpty(),
|
||||
"Historical worlds must not acquire generation-15 underground utilities");
|
||||
}
|
||||
}
|
||||
|
||||
private static void reload(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var level = helper.getLevel(); var result = report(level);
|
||||
try {
|
||||
var previous = JsonParser.parseString(Files.readString(path(level, false))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload requires the successful creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must run in a second JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed() && previous.get("world_directory").getAsString().equals(
|
||||
level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString()), "Reload must use the same development world and seed");
|
||||
var session = ExpansionRuntime.session(level);
|
||||
helper.assertTrue(previous.get("islands").equals(ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow()), "Island identities changed on reload");
|
||||
helper.assertTrue(hash(Files.readString(journal(level, generator))).equals(previous.get("journal_sha256").getAsString()), "Expansion journal bytes changed on reload");
|
||||
var gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Existing gameplay edit was lost or regenerated");
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
int savedPieces = 0, unopened = 0, roomTargets = 0, supports = 0;
|
||||
List<Map<String, Object>> fluids = new ArrayList<>(), surfacePools = new ArrayList<>(), surfaceAccess = new ArrayList<>(), circulation = new ArrayList<>();
|
||||
Set<String> checkedNetworks = new HashSet<>();
|
||||
for (var element : previous.getAsJsonArray("sites")) {
|
||||
var site = element.getAsJsonObject(); var pos = site.getAsJsonArray("start_chunk");
|
||||
var chunk = level.getChunk(pos.get(0).getAsInt(), pos.get(1).getAsInt(), ChunkStatus.FULL, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Saved generation-15 native start was lost");
|
||||
helper.assertTrue(hash(start.createTag(context, start.getChunkPos()).toString()).equals(site.get("native_start_sha256").getAsString()),
|
||||
"Native pieces, utility bodies, openings or support anchors changed on reload");
|
||||
savedPieces += start.getPieces().size();
|
||||
for (var piece : start.getPieces()) {
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> {
|
||||
var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(start.getChunkPos().pack()), "Saved FULL reference disappeared");
|
||||
});
|
||||
if (piece instanceof LostCityPiece15 surface) {
|
||||
var p = surface.parcel();
|
||||
if (p.kind().building()) roomTargets += verifyInterior(helper, p, piece.getBoundingBox());
|
||||
else if (p.kind() == LostCityPlan.Kind.POOL && !expectedSurfaceWater(p).isEmpty()) surfacePools.add(verifySurfacePool(helper, p, false));
|
||||
else if (p.kind() == LostCityPlan.Kind.BRIDGE || p.kind() == LostCityPlan.Kind.PARKING)
|
||||
surfaceAccess.add(verifySurfaceAccess(helper, p, piece.getBoundingBox()));
|
||||
} else if (piece instanceof LostCityUndergroundPiece15 utility) {
|
||||
supports += verifySupports(helper, utility.module());
|
||||
if (!expectedWater(utility.module()).isEmpty()) fluids.add(verifyWater(helper, utility.module(), false));
|
||||
} else if (piece instanceof LostCitySupportPiece15 pier) { verifyPier(helper, pier); supports++; }
|
||||
}
|
||||
var savedParcels = start.getPieces().stream().filter(LostCityPiece15.class::isInstance)
|
||||
.map(LostCityPiece15.class::cast).map(LostCityPiece15::parcel).toList();
|
||||
circulation.add(verifyCityCirculation(helper, savedParcels));
|
||||
for (var chestElement : site.getAsJsonArray("unopened_chests")) {
|
||||
var saved = chestElement.getAsJsonObject(); var at = readPos(saved.getAsJsonArray("position"));
|
||||
helper.assertTrue(level.getBlockEntity(at) instanceof RandomizableContainerBlockEntity, "Saved deferred chest entity disappeared");
|
||||
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(at);
|
||||
helper.assertTrue(chest.getLootTable() != null && chest.getLootTable().identifier().toString().equals(saved.get("table").getAsString())
|
||||
&& chest.getLootTableSeed() == saved.get("seed").getAsLong(), "Unopened loot table or seed changed on reload");
|
||||
unopened++;
|
||||
}
|
||||
var island = session.island(site.get("island").getAsString());
|
||||
for (var network : generator.undergroundPlan(island).networks()) {
|
||||
String identity = island.id() + "/" + network.startChunkX() + "/" + network.startChunkZ();
|
||||
if (network.startChunkX() == pos.get(0).getAsInt() && network.startChunkZ() == pos.get(1).getAsInt() && checkedNetworks.add(identity))
|
||||
verifyNetworkAccess(helper, network);
|
||||
}
|
||||
}
|
||||
var readback = new ExpansionJournal(journal(level, generator).getParent(), level.getSeed(), generator.initialDiameter(), 15);
|
||||
helper.assertTrue(readback.healthy() && readback.islands().equals(session.islands()), "Generation-15 journal failed its own strict reopen");
|
||||
result.put("saved_native_pieces", savedPieces); result.put("preserved_deferred_chests", unopened);
|
||||
result.put("walkable_room_targets", roomTargets); result.put("preserved_supports", supports);
|
||||
result.put("natural_surface_pool_checks", surfacePools); result.put("native_surface_access", surfaceAccess);
|
||||
result.put("native_city_circulation", circulation);
|
||||
result.put("natural_fluid_checks", fluids); result.put("gold_witness_preserved", true); result.put("journal_preserved", true);
|
||||
result.put("passed", true); write(level, result, true); helper.succeed();
|
||||
} catch (Exception error) {
|
||||
result.put("passed", false); result.put("failure", error.toString());
|
||||
try { write(level, result, true); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Lost City15 native reload failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> report(ServerLevel level) {
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("seed", level.getSeed()); result.put("process_id", ProcessHandle.current().pid());
|
||||
result.put("world_directory", level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString());
|
||||
result.put("scope", "Naturally planned generation-15 districts and utility networks in a disposable development world. "
|
||||
+ "At most six read-only prospects and two 256-block expansions; inspection loads at most six district footprints per island, not an entire island. "
|
||||
+ "Normal expansion preparation retains its own bounded chunk scope. Interior serialization fixtures are not natural occurrence evidence. "
|
||||
+ "Independent enclosed temporary sky witnesses prove native fluid execution in each forced chunk and are removed before saving; they are excluded from natural water counts. "
|
||||
+ "Native doors/hatches are temporarily opened and restored for player-sized route checks. No personal world is opened.");
|
||||
return result;
|
||||
}
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path journal(ServerLevel level, SanctuaryChunkGenerator generator) {
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data").resolve(generator.journalDirectory()).resolve("expansions.json");
|
||||
}
|
||||
private static Path path(ServerLevel level, boolean reload) {
|
||||
return Path.of("diagnostics", "lost-city-alpha15" + (reload ? "-reload" : "") + "-seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = path(level, reload); Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
}
|
||||
private static String hash(String text) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.expansion.ExpansionIsland;
|
||||
import fr.koka.sanctuary.worldgen.PopulationGenerationContext;
|
||||
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryDecorationProtection;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlanner16;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityProtection16;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityTerrain16;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Six isolated, real starter-noise witnesses. This helper never requests a chunk, attaches a
|
||||
* generator, mutates the active biome source or creates an expansion session. Hydrology is the
|
||||
* production planner and its exact protection mask, rather than an all-dry testing substitute.
|
||||
* Surface availability is asserted; underground coverage is reported as a survey, not a guarantee. */
|
||||
public final class LostCity16PlanningDiagnostics {
|
||||
private LostCity16PlanningDiagnostics() {}
|
||||
|
||||
public static List<Map<String, Object>> verify(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
helper.assertTrue(level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Native city matrix requires the Sanctuary source");
|
||||
var active = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
|
||||
helper.assertTrue(active.rootGeneration() == 16 && level.getMinY() == 0 && level.getHeight() == 384,
|
||||
"Native city matrix uses generation16 and its actual 384-block height");
|
||||
var originalIslands = active.source().islands();
|
||||
var access = level.registryAccess();
|
||||
var settings = access.lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
var noises = access.lookupOrThrow(Registries.NOISE);
|
||||
var reports = new ArrayList<Map<String, Object>>();
|
||||
for (int diameter : new int[] {512, 724, 1024}) for (long seed : new long[] {0, 1}) {
|
||||
long started = System.nanoTime();
|
||||
int capacity = diameter == 512 ? 5 : diameter == 724 ? 10 : 20;
|
||||
var key = ResourceKey.create(Registries.NOISE_SETTINGS, SanctuaryMod.id("unified_" + capacity));
|
||||
var registered = settings.getOrThrow(key);
|
||||
var island = ExpansionIsland.origin(diameter, seed, 16);
|
||||
var source = active.source().copy();
|
||||
helper.assertTrue(source != active.source() && source.generation() == 16, "Matrix shares a mutable biome source");
|
||||
source.setIslands(List.of(island));
|
||||
var generator = new NoiseBasedChunkGenerator(source, registered);
|
||||
var random = RandomState.create(noises, seed, registered.value());
|
||||
var binding = new PopulationGenerationContext.Binding(generator, random);
|
||||
Map<Long, SanctuaryDecorationProtection> protection = new HashMap<>();
|
||||
var terrain = new LostCityTerrain16(island, generator, random, level, position -> {
|
||||
int cx = position.getX() >> 4, cz = position.getZ() >> 4;
|
||||
return protection.computeIfAbsent(key(cx, cz), ignored -> SanctuaryDecorationProtection.create(binding, cx, cz))
|
||||
.protects(position);
|
||||
});
|
||||
var plan = LostCityPlanner16.plan(seed, 0, 0, diameter, true, terrain);
|
||||
int plannedColumns = terrain.columnsUsed();
|
||||
helper.assertTrue(plan.columns() == plannedColumns && plan.columns() <= LostCityPlanner16.MAX_COLUMNS,
|
||||
label(diameter, seed) + " exceeds or bypasses its surface column budget");
|
||||
helper.assertTrue(plan.volumeChecks() <= LostCityPlanner16.MAX_VOLUME_CHECKS,
|
||||
label(diameter, seed) + " exceeds its volume-check budget");
|
||||
var report = verifyPlan(helper, diameter, seed, island, plan, terrain);
|
||||
helper.assertTrue(terrain.columnsUsed() == plannedColumns,
|
||||
label(diameter, seed) + " accepted a foundation that the planner never sampled");
|
||||
report.put("surface_elapsed_ms", (System.nanoTime() - started) / 1_000_000);
|
||||
var underground = surveyUnderground(seed, plan, terrain);
|
||||
report.put("underground", underground);
|
||||
report.put("noise_settings", key.identifier().toString());
|
||||
report.put("columns", plan.columns());
|
||||
report.put("total_columns", terrain.columnsUsed());
|
||||
report.put("volume_checks", plan.volumeChecks());
|
||||
report.put("hydrology_regions", PopulationHydrologyRuntime.cachedRegionCount(random));
|
||||
report.put("hydrology_mask_chunks", protection.size());
|
||||
report.put("native_noise", true);
|
||||
report.put("chunk_requests", 0);
|
||||
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000);
|
||||
reports.add(Map.copyOf(report));
|
||||
SanctuaryMod.LOGGER.info("Lost City16 matrix seed {} size {}: {} lots, fallback {}, {} anchored piles, {} columns, {} ms",
|
||||
seed, diameter, report.get("lots"), report.get("fallback"), report.get("foundation_anchors"),
|
||||
plan.columns(), report.get("elapsed_ms"));
|
||||
SanctuaryMod.LOGGER.info("Lost City16 utility survey seed {} size {}: types {}, missing {}, {} modules, {} garage levels, {} total columns, {} UG samples",
|
||||
seed, diameter, underground.get("types"), underground.get("missing_requested_types"),
|
||||
underground.get("modules"), underground.get("garage_levels"), terrain.columnsUsed(), underground.get("samples"));
|
||||
}
|
||||
helper.assertTrue(active.source().islands().equals(originalIslands), "Read-only city matrix changed the live island snapshot");
|
||||
return List.copyOf(reports);
|
||||
}
|
||||
|
||||
/** Mirrors SanctuaryChunkGenerator's generation16 adapter, sharing its sampled columns and
|
||||
* reserving exactly the same piers/architectural foundation before underground prospecting. */
|
||||
private static Map<String, Object> surveyUnderground(long seed, LostCityPlanner16.Result placement, LostCityTerrain16 sampled) {
|
||||
long started = System.nanoTime();
|
||||
int surfaceColumns = sampled.columnsUsed();
|
||||
var supports = new LostCityProtection16.Builder();
|
||||
for (var support : placement.supports()) supports.addViaductSupport(support);
|
||||
if (placement.reservation() != null) supports.addReservation(placement.reservation());
|
||||
var supportMask = supports.build();
|
||||
var underground = LostCityUnderground16.plan(seed, placement.surface(), new LostCityUnderground16.Terrain() {
|
||||
@Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); }
|
||||
@Override public boolean owns(int x, int z) { return sampled.owns(x, z); }
|
||||
@Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); }
|
||||
@Override public boolean protectedAt(int x, int y, int z) {
|
||||
return supportMask.contains(x, y, z) || sampled.protectedAt(x, y, z);
|
||||
}
|
||||
});
|
||||
Map<String, Integer> types = new TreeMap<>();
|
||||
var details = new ArrayList<Map<String, Object>>();
|
||||
int garageLevels = 0, largestGarage = 0;
|
||||
for (var network : underground.networks()) for (var module : network.modules()) {
|
||||
types.merge(module.kind().name(), 1, Integer::sum);
|
||||
var floors = module.parkingFloors();
|
||||
garageLevels += floors.size(); largestGarage = Math.max(largestGarage, floors.size());
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("kind", module.kind().name());
|
||||
detail.put("bounds", List.of(module.minX(), module.minY(), module.minZ(), module.maxX(), module.maxY(), module.maxZ()));
|
||||
detail.put("floor_y", module.floorY()); detail.put("parking_floors", floors);
|
||||
detail.put("supports", module.supports().size());
|
||||
if (module.kind() == LostCityUnderground16.Kind.SEWER_OUTLET) {
|
||||
var mouth = module.outletMouth();
|
||||
detail.put("outlet_mouth", List.of(mouth.getX(), mouth.getY(), mouth.getZ()));
|
||||
detail.put("outlet_direction", module.outletDirection());
|
||||
}
|
||||
details.add(Map.copyOf(detail));
|
||||
}
|
||||
var requested = List.of(LostCityUnderground16.Kind.SEWER, LostCityUnderground16.Kind.POOLROOM,
|
||||
LostCityUnderground16.Kind.GARAGE, LostCityUnderground16.Kind.EXPANSION_HALL, LostCityUnderground16.Kind.SEWER_OUTLET);
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("mode", "survey"); report.put("networks", underground.networks().size());
|
||||
report.put("types", types); report.put("modules", details.size()); report.put("module_details", details);
|
||||
report.put("missing_requested_types", requested.stream().map(Enum::name).filter(kind -> !types.containsKey(kind)).toList());
|
||||
report.put("garage_levels", garageLevels); report.put("largest_garage_levels", largestGarage);
|
||||
report.put("surface_columns", surfaceColumns); report.put("total_columns", sampled.columnsUsed());
|
||||
report.put("additional_columns", sampled.columnsUsed() - surfaceColumns);
|
||||
report.put("samples", underground.samples()); report.put("sample_limit", LostCityUnderground16.MAX_SAMPLES);
|
||||
report.put("column_limit", LostCityTerrain16.MAX_COLUMNS);
|
||||
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000);
|
||||
return Map.copyOf(report);
|
||||
}
|
||||
|
||||
private static Map<String, Object> verifyPlan(GameTestHelper helper, int diameter, long seed, ExpansionIsland island,
|
||||
LostCityPlanner16.Result result, LostCityTerrain16 terrain) {
|
||||
String label = label(diameter, seed);
|
||||
helper.assertTrue(result.surface().districts().size() == 1, label + " must contain exactly one starter town");
|
||||
var district = result.surface().districts().getFirst();
|
||||
helper.assertTrue(district.maxX() - district.minX() + 1 <= 71 && district.maxZ() - district.minZ() + 1 <= 71,
|
||||
label + " escapes the compact starter envelope");
|
||||
Map<Long, Street> streets = new HashMap<>();
|
||||
Set<Long> written = new HashSet<>();
|
||||
var kinds = new TreeMap<String, Integer>();
|
||||
int lots = 0, buildings = 0;
|
||||
for (var parcel : district.parcels()) {
|
||||
kinds.merge(parcel.kind().name(), 1, Integer::sum);
|
||||
boolean street = parcel.kind() == LostCityPlan.Kind.STREET || parcel.kind() == LostCityPlan.Kind.BRIDGE;
|
||||
if (!street) lots++;
|
||||
if (parcel.kind().building()) buildings++;
|
||||
helper.assertTrue(parcel.minY() >= 0 && parcel.maxY() <= 383
|
||||
&& Math.abs((parcel.minX() >> 4) - district.startChunkX()) <= 7
|
||||
&& Math.abs((parcel.maxX() >> 4) - district.startChunkX()) <= 7
|
||||
&& Math.abs((parcel.minZ() >> 4) - district.startChunkZ()) <= 7
|
||||
&& Math.abs((parcel.maxZ() >> 4) - district.startChunkZ()) <= 7,
|
||||
label + " has a parcel outside native height/reference bounds");
|
||||
for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) for (int x = parcel.minX(); x <= parcel.maxX(); x++) {
|
||||
helper.assertTrue(written.add(key(x, z)) && island.contains(x, z),
|
||||
label + " has overlapping parcels or an unowned column");
|
||||
int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ());
|
||||
if (result.reservation() == null) {
|
||||
var ground = terrain.ground(x, z);
|
||||
helper.assertTrue(ground.top() <= floor + LostCityPlanner16.MAX_CUT,
|
||||
label + " requires an unapproved natural terrain cut");
|
||||
if (parcel.kind() != LostCityPlan.Kind.BRIDGE) {
|
||||
int bottom = floor - parcel.foundationDepth();
|
||||
helper.assertTrue(ground.dry() && ground.top() >= bottom
|
||||
&& ground.top() - bottom + 1 <= ground.solidDepth(), label + " has an unsupported natural floor");
|
||||
}
|
||||
}
|
||||
if (street) {
|
||||
boolean railing = parcel.kind() == LostCityPlan.Kind.BRIDGE && (parcel.width() > parcel.depth()
|
||||
? z == parcel.minZ() || z == parcel.maxZ() : x == parcel.minX() || x == parcel.maxX());
|
||||
streets.put(key(x, z), new Street(x, z, floor, parcel, !railing));
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.assertTrue(lots >= 3 && buildings >= (lots * 3 + 4) / 5, label + " is not a populated starter town");
|
||||
Set<Long> reached = walk(helper, label, streets);
|
||||
for (var parcel : district.parcels()) if (parcel.kind() != LostCityPlan.Kind.STREET && parcel.kind() != LostCityPlan.Kind.BRIDGE) {
|
||||
helper.assertTrue(parcel.facing() == 0, label + " rotates its entrance away from its certified road");
|
||||
for (int dx = 7; dx <= 9; dx++) {
|
||||
long threshold = key(parcel.minX() + dx, parcel.minZ() - 1);
|
||||
var road = streets.get(threshold);
|
||||
helper.assertTrue(road != null && road.walkable() && road.y() == parcel.floorY() && reached.contains(threshold),
|
||||
label + " has an isolated or obstructed entrance");
|
||||
}
|
||||
for (int x = parcel.minX(); x <= parcel.maxX(); x++)
|
||||
helper.assertTrue(streets.containsKey(key(x, parcel.minZ() - 1)) && streets.containsKey(key(x, parcel.maxZ() + 1)),
|
||||
label + " leaves natural soil between facades and streets");
|
||||
for (int z = parcel.minZ(); z <= parcel.maxZ(); z++)
|
||||
helper.assertTrue(streets.containsKey(key(parcel.minX() - 1, z)) && streets.containsKey(key(parcel.maxX() + 1, z)),
|
||||
label + " lacks a side street beside an admitted lot");
|
||||
}
|
||||
for (var support : result.supports()) for (int z = support.minZ(); z <= support.maxZ(); z++)
|
||||
for (int x = support.minX(); x <= support.maxX(); x++) {
|
||||
var deck = streets.get(key(x, z));
|
||||
helper.assertTrue(deck != null && deck.parcel().kind() == LostCityPlan.Kind.BRIDGE && deck.y() == support.maxY() + 1,
|
||||
label + " has a pier that misses its bridge deck");
|
||||
helper.assertTrue(terrain.solid(x, support.minY(), z) && terrain.solid(x, support.minY() - 1, z),
|
||||
label + " has a viaduct pier without native rock at its foot");
|
||||
}
|
||||
var reservation = result.reservation();
|
||||
if (reservation != null) {
|
||||
helper.assertTrue(reservation.width() == 49 && reservation.depth() == 49 && lots == 4
|
||||
&& reservation.minX() == district.minX() && reservation.maxX() == district.maxX()
|
||||
&& reservation.minZ() == district.minZ() && reservation.maxZ() == district.maxZ(),
|
||||
label + " fallback is not the explicit compact four-lot reservation");
|
||||
for (var anchor : reservation.anchors()) for (int z = anchor.minZ(); z <= anchor.maxZ(); z++)
|
||||
for (int x = anchor.minX(); x <= anchor.maxX(); x++)
|
||||
helper.assertTrue(terrain.solid(x, anchor.minY(), z) && terrain.solid(x, anchor.minY() + 1, z),
|
||||
label + " fallback invents a two-block native rock footing");
|
||||
}
|
||||
Map<String, Object> report = new LinkedHashMap<>();
|
||||
report.put("generation", 16); report.put("diameter", diameter); report.put("seed", seed);
|
||||
report.put("districts", 1); report.put("lots", lots); report.put("buildings", buildings); report.put("kinds", kinds);
|
||||
report.put("bounds", List.of(district.minX(), district.minZ(), district.maxX(), district.maxZ()));
|
||||
report.put("start_chunk", List.of(district.startChunkX(), district.startChunkZ()));
|
||||
report.put("road_columns", streets.size()); report.put("walkable_road_columns", reached.size());
|
||||
report.put("connected_roads", true); report.put("flush_entrances", true);
|
||||
report.put("fallback", reservation != null); report.put("viaduct_piers", result.supports().size());
|
||||
report.put("foundation_anchors", reservation == null ? 0 : reservation.anchors().size());
|
||||
report.put("passed", true);
|
||||
return report;
|
||||
}
|
||||
|
||||
private static Set<Long> walk(GameTestHelper helper, String label, Map<Long, Street> streets) {
|
||||
Set<Long> walkable = new HashSet<>();
|
||||
streets.forEach((position, street) -> { if (street.walkable()) walkable.add(position); });
|
||||
helper.assertTrue(!walkable.isEmpty(), label + " has no walkable street");
|
||||
Set<Long> reached = new HashSet<>(); var queue = new ArrayDeque<Long>();
|
||||
long start = walkable.iterator().next(); reached.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var current = streets.get(queue.removeFirst());
|
||||
for (int[] delta : new int[][] {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}) {
|
||||
long position = key(current.x() + delta[0], current.z() + delta[1]);
|
||||
var next = streets.get(position);
|
||||
if (next == null || !next.walkable()) continue;
|
||||
helper.assertTrue(Math.abs(current.y() - next.y()) <= 1, label + " contains a street cliff");
|
||||
if (current.parcel() != next.parcel()) helper.assertTrue(current.y() == next.y(),
|
||||
label + " requires an unpainted stair between two independent pieces");
|
||||
if (reached.add(position)) queue.addLast(position);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(reached.equals(walkable), label + " contains disconnected streets");
|
||||
return reached;
|
||||
}
|
||||
private record Street(int x, int z, int y, LostCityPlan.Parcel parcel, boolean walkable) {}
|
||||
private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); }
|
||||
private static String label(int diameter, long seed) { return "Native city size=" + diameter + " seed=" + seed; }
|
||||
}
|
||||
+1022
File diff suppressed because it is too large
Load Diff
+409
@@ -0,0 +1,409 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
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.ExpansionIsland;
|
||||
import fr.koka.sanctuary.expansion.ExpansionJournal;
|
||||
import fr.koka.sanctuary.expansion.ExpansionRuntime;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPiece;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityPlan;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityStructures;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderSet;
|
||||
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.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.entity.npc.villager.Villager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Opt-in native engine proof on a development world, including a separate-process reload. */
|
||||
public final class LostCityWorldGameTests {
|
||||
@GameTest(maxTicks = 6000)
|
||||
public void cityDistrictsAreNativePersistentStructures(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"Lost City checks require the unified generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 14, "Lost City must use new generation 14");
|
||||
if (Boolean.getBoolean("sanctuary.test.expansionReload")) {
|
||||
reload(helper, generator);
|
||||
return;
|
||||
}
|
||||
new Scenario(helper, generator).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final ExpansionRuntime.Session session;
|
||||
final Map<String, Object> report;
|
||||
final List<Map<String, Object>> sites = new ArrayList<>();
|
||||
int phase;
|
||||
long poolPlacedTick;
|
||||
String childId;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator;
|
||||
session = ExpansionRuntime.session(level); report = report(level);
|
||||
}
|
||||
|
||||
void advance() {
|
||||
try {
|
||||
if (phase == 0) {
|
||||
report.put("archetypes", verifySavedArchetypes(helper));
|
||||
verifyLegacyCodec(helper, generator);
|
||||
placePoolFixture(helper, generator);
|
||||
poolPlacedTick = helper.getTick();
|
||||
var root = session.island("sanctuary");
|
||||
helper.assertTrue(root.generation() == 14, "New root must keep generation 14 in its journal");
|
||||
sites.add(observe(helper, generator, root));
|
||||
var prospects = new ArrayList<Map<String, Object>>();
|
||||
report.put("city_prospects", prospects);
|
||||
for (String id : List.of("city_test", "city_harbor", "city_gardens", "city_heights", "city_works")) {
|
||||
var candidate = session.candidate(id, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
try {
|
||||
var plan = generator.lostCityPlan(candidate);
|
||||
prospects.add(Map.of("id", id, "seed", candidate.seed(), "diameter", candidate.diameter(),
|
||||
"districts", plan.districts().size(), "read_only", true));
|
||||
if (!plan.isEmpty()) { childId = id; break; }
|
||||
} finally { generator.discardUnpublishedContext(candidate); }
|
||||
}
|
||||
helper.assertTrue(childId != null, "Five bounded read-only prospects must find a naturally supported city reference");
|
||||
var child = session.create(childId, "sanctuary", "east", "lost_city", 256, "natural", 2048);
|
||||
helper.assertTrue(child.generation() == 14 && child.climate().equals("lost_city"),
|
||||
"City expansion must have its own saved generation and climate");
|
||||
phase = 1;
|
||||
}
|
||||
if (phase == 1 && session.island(childId).status().equals("ready") && helper.getTick() - poolPlacedTick >= 60) {
|
||||
report.put("native_pool_fixture", verifyPoolFixture(helper, true));
|
||||
forcePoolFixture(level, false);
|
||||
sites.add(observe(helper, generator, session.island(childId)));
|
||||
report.put("sites", sites);
|
||||
var first = generator.lostCityPlan(session.island("sanctuary")).districts().getFirst().parcels().getFirst();
|
||||
var marker = new BlockPos(first.minX(), first.floorY() + 1, first.minZ());
|
||||
level.setBlock(marker, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", List.of(marker.getX(), marker.getY(), marker.getZ()));
|
||||
report.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
|
||||
report.put("generator_codec", ChunkGenerator.CODEC.encodeStart(
|
||||
RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
|
||||
report.put("journal_sha256", hash(Files.readString(journal(level, generator))));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush city chunks, deferred loot and marker before restart");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
report.put("passed", true);
|
||||
write(level, report, false);
|
||||
helper.succeed();
|
||||
return;
|
||||
}
|
||||
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Lost City integration phase {}: {}", phase, session.progress());
|
||||
helper.runAtTickTime(helper.getTick() + 10, this::advance);
|
||||
} catch (Exception exception) {
|
||||
forcePoolFixture(level, false);
|
||||
report.put("passed", false); report.put("failure", exception.toString()); report.put("sites", sites);
|
||||
try { write(level, report, false); } catch (IOException ignored) { /* Keep the original test failure. */ }
|
||||
throw new IllegalStateException("Lost City native integration failed", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately isolated in development void, apart from the two naturally planned city witnesses.
|
||||
private static final int POOL_X = 6000, POOL_Z = 0, POOL_Y = 366;
|
||||
private static void forcePoolFixture(ServerLevel level, boolean forced) {
|
||||
for (int x = (POOL_X - 1) >> 4; x <= (POOL_X + 17) >> 4; x++)
|
||||
for (int z = (POOL_Z - 1) >> 4; z <= (POOL_Z + 17) >> 4; z++) level.setChunkForced(x, z, forced);
|
||||
}
|
||||
private static void placePoolFixture(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
forcePoolFixture(level, true);
|
||||
var parcel = new LostCityPlan.Parcel(LostCityPlan.Kind.POOL, POOL_X, POOL_Z, 17, 17, POOL_Y, 0, 0, 42, 8, new int[0]);
|
||||
var piece = new LostCityPiece(parcel);
|
||||
piece.getBoundingBox().intersectingChunks().forEach(pos -> {
|
||||
level.getChunk(pos.x(), pos.z());
|
||||
var clip = new net.minecraft.world.level.levelgen.structure.BoundingBox(pos.x() * 16, 0, pos.z() * 16,
|
||||
pos.x() * 16 + 15, 383, pos.z() * 16 + 15);
|
||||
piece.postProcess(level, level.structureManager(), generator, net.minecraft.util.RandomSource.create(42), clip, pos, BlockPos.ZERO);
|
||||
});
|
||||
level.scheduleTick(new BlockPos(POOL_X + 8, POOL_Y - 1, POOL_Z + 10), net.minecraft.world.level.material.Fluids.WATER, 5);
|
||||
}
|
||||
private static Map<String, Object> verifyPoolFixture(GameTestHelper helper, boolean requireTicks) {
|
||||
var level = helper.getLevel();
|
||||
var witness = new BlockPos(POOL_X + 8, POOL_Y - 1, POOL_Z + 10);
|
||||
if (requireTicks) {
|
||||
helper.assertTrue(level.shouldTickBlocksAt(ChunkPos.containing(witness).pack()), "Pool fixture must actually tick in Minecraft");
|
||||
helper.assertTrue(!level.getFluidTicks().hasScheduledTick(witness, net.minecraft.world.level.material.Fluids.WATER),
|
||||
"The explicitly scheduled water tick must have executed");
|
||||
}
|
||||
int water = 0;
|
||||
for (int x = -1; x <= 17; x++) for (int z = -1; z <= 17; z++) for (int y = -9; y <= 1; y++) {
|
||||
var pos = new BlockPos(POOL_X + x, POOL_Y + y, POOL_Z + z);
|
||||
if (!level.getFluidState(pos).isEmpty()) {
|
||||
water++;
|
||||
helper.assertTrue(x >= 4 && x <= 12 && z >= 5 && z <= 12 && y >= -3 && y <= -1,
|
||||
"Native pool water escaped its intended basin after ticks or reload: " + pos);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(water >= 180, "The native pool lost its retained water");
|
||||
for (int step = 1; step <= 3; step++) helper.assertTrue(level.getBlockState(
|
||||
new BlockPos(POOL_X + 8, POOL_Y - step, POOL_Z + step + 4)).is(net.minecraft.tags.BlockTags.STAIRS),
|
||||
"Native submerged access steps must survive fluid ticks");
|
||||
return Map.of("scope", "Isolated native piece fixture, separate from random city placement", "retained_water_cells", water,
|
||||
"scheduled_water_tick_executed", requireTicks, "contained", true, "steps_preserved", true);
|
||||
}
|
||||
|
||||
private static Map<String, Object> observe(GameTestHelper helper, SanctuaryChunkGenerator generator, ExpansionIsland island) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
LostCityPlan plan = generator.lostCityPlan(island);
|
||||
helper.assertTrue(!plan.isEmpty(), "This reference seed must demonstrate an accepted city district on " + island.id());
|
||||
var district = plan.districts().getFirst();
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
var holder = registry.getOrThrow(LostCityStructures.KEY);
|
||||
var startChunk = level.getChunkSource().getChunk(district.startChunkX(), district.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
StructureStart start = startChunk.getStartForStructure(holder.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted district must have a native StructureStart");
|
||||
helper.assertTrue(start.getPieces().size() == district.parcels().size(), "Every accepted parcel must be saved as a native piece");
|
||||
Map<Long, LevelChunk> chunks = new TreeMap<>();
|
||||
for (var piece : start.getPieces()) piece.getBoundingBox().intersectingChunks().forEach(pos -> {
|
||||
if (!chunks.containsKey(pos.pack())) {
|
||||
var chunk = level.getChunkSource().getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "City inspection requires FULL chunks");
|
||||
chunks.put(pos.pack(), (LevelChunk) chunk);
|
||||
}
|
||||
});
|
||||
helper.assertTrue(chunks.size() <= 81, "One district is bounded; the check must not generate an entire island");
|
||||
for (var chunk : chunks.values()) helper.assertTrue(chunk.getReferencesForStructure(holder.value()).contains(start.getChunkPos().pack()),
|
||||
"Every touched FULL chunk must retain the native structure reference");
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var tag = start.createTag(context, start.getChunkPos());
|
||||
var decoded = StructureStart.loadStaticStart(context, tag, island.seed());
|
||||
helper.assertTrue(decoded != null && decoded.isValid() && decoded.createTag(context, decoded.getChunkPos()).equals(tag),
|
||||
"Native start and complete parcel parameters must round-trip through saved NBT");
|
||||
Map<String, Long> blocks = new TreeMap<>();
|
||||
Map<String, Integer> kinds = new TreeMap<>();
|
||||
List<Map<String, Object>> chests = new ArrayList<>();
|
||||
int biomeWitnesses = 0, interiorWitnesses = 0;
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof LostCityPiece, "Lost City must load the registered native parcel type");
|
||||
var parcel = ((LostCityPiece) piece).parcel();
|
||||
kinds.merge(parcel.kind().name(), 1, Integer::sum);
|
||||
var box = piece.getBoundingBox();
|
||||
for (int x = box.minX(); x <= box.maxX(); x++) for (int z = box.minZ(); z <= box.maxZ(); z++)
|
||||
for (int y = box.minY(); y <= box.maxY(); y++) {
|
||||
var pos = new BlockPos(x, y, z);
|
||||
var state = level.getBlockState(pos);
|
||||
if (!state.isAir()) blocks.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
|
||||
if (parcel.kind().building() && y >= parcel.floorY()) helper.assertTrue(
|
||||
!BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath().endsWith("_ore"),
|
||||
"Native ore features must not replace a city's masonry after structure placement");
|
||||
helper.assertTrue(!state.is(Blocks.SPAWNER), "City buildings contain no zombie spawner");
|
||||
if (state.is(Blocks.CHEST) && level.getBlockEntity(pos) instanceof RandomizableContainerBlockEntity chest
|
||||
&& chest.getLootTable() != null) {
|
||||
chests.add(Map.of("position", List.of(x, y, z), "table", chest.getLootTable().identifier().toString(), "seed", chest.getLootTableSeed()));
|
||||
}
|
||||
}
|
||||
var centre = new BlockPos(parcel.minX() + parcel.width() / 2, parcel.floorY() + 1, parcel.minZ() + parcel.depth() / 2);
|
||||
if (level.getBiome(centre).is(SanctuaryMod.id("lost_city"))) biomeWitnesses++;
|
||||
var entrance = entrance(parcel);
|
||||
if (parcel.kind().building() && level.getBlockState(entrance).isAir()
|
||||
&& level.getBlockState(entrance.above()).isAir() && level.getBlockState(entrance.below()).isSolid()) interiorWitnesses++;
|
||||
var monsters = generator.getMobsAt(level, level.structureManager(), MobCategory.MONSTER, centre).unwrap();
|
||||
helper.assertTrue(!monsters.isEmpty() && monsters.stream().allMatch(entry -> entry.value().type() == EntityTypes.ZOMBIE),
|
||||
"The effective native monster spawn list inside a city piece contains only vanilla zombies");
|
||||
}
|
||||
helper.assertTrue(biomeWitnesses > 0, "The city must exist as a real saved biome, not only as placed buildings");
|
||||
helper.assertTrue(interiorWitnesses > 0, "At least one observed contemporary building has a two-block-high traversable interior");
|
||||
helper.assertTrue(blocks.getOrDefault("minecraft:stone", 0L) > 0 && blocks.getOrDefault("minecraft:smooth_stone", 0L) > 0
|
||||
&& blocks.getOrDefault("minecraft:stone_bricks", 0L) > 0, "The actual city contains stone, smooth stone and restrained stone bricks");
|
||||
helper.assertTrue(!chests.isEmpty(), "An observed building must contain unconsumed deferred native loot");
|
||||
var bounds = start.getBoundingBox();
|
||||
helper.assertTrue(level.getEntitiesOfClass(Villager.class, new AABB(bounds.minX(), bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX() + 1, bounds.maxY() + 1, bounds.maxZ() + 1)).isEmpty(), "The abandoned district contains no generated villagers");
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(holder),
|
||||
new BlockPos(district.startChunkX() * 16 + 8, 220, district.startChunkZ() * 16 + 8), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(LostCityStructures.KEY), "The native locate path finds Lost City");
|
||||
helper.assertTrue(!LostCityStructures.isPlacing(), "The scoped decoration bypass must be cleared after placement");
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("island", island.id()); result.put("generation", island.generation());
|
||||
result.put("planned_districts", plan.districts().size()); result.put("start_chunk", List.of(start.getChunkPos().x(), start.getChunkPos().z()));
|
||||
result.put("full_chunks", chunks.size()); result.put("parcel_kinds", kinds); result.put("blocks", blocks);
|
||||
result.put("native_start_sha256", hash(tag.toString())); result.put("unopened_chests", chests);
|
||||
result.put("biome_witnesses", biomeWitnesses); result.put("traversable_interiors", interiorWitnesses);
|
||||
result.put("native_zombie_only_monster_spawns", true); result.put("native_locate", List.of(located.getFirst().getX(), located.getFirst().getY(), located.getFirst().getZ()));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> verifySavedArchetypes(GameTestHelper helper) {
|
||||
var context = StructurePieceSerializationContext.fromLevel(helper.getLevel());
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (var kind : LostCityPlan.Kind.values()) for (int facing = 0; facing < 4; facing++) {
|
||||
var parcel = new LostCityPlan.Parcel(kind, -32, 48, 17, 17, 220, kind.building() ? 3 : 1,
|
||||
facing, 0xA14C17L + kind.ordinal(), 4, new int[0]);
|
||||
var piece = new LostCityPiece(parcel);
|
||||
var tag = piece.createTag(context);
|
||||
var decoded = new LostCityPiece(context, tag);
|
||||
helper.assertTrue(decoded.createTag(context).equals(tag), "Every archetype and orientation must persist without changing parcel fields");
|
||||
var original = render(helper, parcel); var reloaded = render(helper, decoded.parcel());
|
||||
helper.assertTrue(!original.isEmpty() && original.equals(reloaded), "Reloaded parcels render exactly the same blocks and deferred loot seeds");
|
||||
results.add(Map.of("kind", kind.name(), "facing", facing, "positions", original.size(), "sha256", hash(original.toString())));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static BlockPos entrance(LostCityPlan.Parcel parcel) {
|
||||
int x = parcel.width() / 2, z = 1;
|
||||
int rotatedX = switch (parcel.facing()) {
|
||||
case 1 -> parcel.width() - 1 - z;
|
||||
case 2 -> parcel.width() - 1 - x;
|
||||
case 3 -> z;
|
||||
default -> x;
|
||||
};
|
||||
int rotatedZ = switch (parcel.facing()) {
|
||||
case 1 -> x;
|
||||
case 2 -> parcel.depth() - 1 - z;
|
||||
case 3 -> parcel.depth() - 1 - x;
|
||||
default -> z;
|
||||
};
|
||||
return new BlockPos(parcel.minX() + rotatedX, parcel.floorY() + 1, parcel.minZ() + rotatedZ);
|
||||
}
|
||||
|
||||
private static Map<String, String> render(GameTestHelper helper, LostCityPlan.Parcel parcel) {
|
||||
Map<String, String> rendered = new TreeMap<>();
|
||||
LostCityRenderer.render(parcel, new LostCityRenderer.BlockWriter() {
|
||||
private void put(int x, int y, int z, String value) {
|
||||
helper.assertTrue(x >= 0 && x < parcel.width() && z >= 0 && z < parcel.depth()
|
||||
&& parcel.floorY() + y >= parcel.minY() && parcel.floorY() + y <= parcel.maxY(),
|
||||
"Every renderer write must fit the persisted parcel bounds: " + parcel.kind() + " at " + x + "," + y + "," + z);
|
||||
rendered.put(x + "," + y + "," + z, value);
|
||||
}
|
||||
@Override public void set(int x, int y, int z, BlockState state) {
|
||||
helper.assertTrue(!state.is(Blocks.SPAWNER), "No original archetype emits a spawner");
|
||||
put(x, y, z, state.toString());
|
||||
}
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
put(x, y, z, "loot:" + table.identifier() + ":" + seed);
|
||||
}
|
||||
});
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private static void verifyLegacyCodec(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, helper.getLevel().registryAccess());
|
||||
JsonObject json = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
json.addProperty("type", "sanctuary:island_v13");
|
||||
json.getAsJsonObject("biome_source").addProperty("type", "sanctuary:island_v13");
|
||||
var old = (SanctuaryChunkGenerator) ChunkGenerator.CODEC.parse(ops, json).getOrThrow();
|
||||
helper.assertTrue(old.rootGeneration() == 13 && old.journalDirectory().equals("sanctuary-world-v13"),
|
||||
"The registered generation-13 codec keeps its journal and never enables city generation");
|
||||
helper.assertTrue(old.lostCityPlan(ExpansionIsland.origin(generator.initialDiameter(), helper.getLevel().getSeed(), 13)).isEmpty(),
|
||||
"Existing generation-13 worlds never acquire a city plan");
|
||||
}
|
||||
|
||||
private static void reload(GameTestHelper helper, SanctuaryChunkGenerator generator) {
|
||||
var level = helper.getLevel(); var result = report(level);
|
||||
try {
|
||||
JsonObject previous = JsonParser.parseString(Files.readString(path(level, false))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload requires a completed first process");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Native city reload must run in a second JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed()
|
||||
&& previous.get("world_directory").getAsString().equals(level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString()),
|
||||
"Reload must read the exact same saved development world and seed");
|
||||
var session = ExpansionRuntime.session(level);
|
||||
helper.assertTrue(previous.get("islands").equals(ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow()),
|
||||
"The ready root and Lost City expansion identities must persist across process restart");
|
||||
helper.assertTrue(hash(Files.readString(journal(level, generator))).equals(previous.get("journal_sha256").getAsString()),
|
||||
"Reload must preserve the existing expansion journal bytes");
|
||||
var witness = previous.getAsJsonArray("gold_witness");
|
||||
var gold = new BlockPos(witness.get(0).getAsInt(), witness.get(1).getAsInt(), witness.get(2).getAsInt());
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "A saved edit inside the city must survive without structure regeneration");
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(LostCityStructures.KEY);
|
||||
int savedPieces = 0, deferredChests = 0;
|
||||
for (var element : previous.getAsJsonArray("sites")) {
|
||||
var site = element.getAsJsonObject(); var pos = site.getAsJsonArray("start_chunk");
|
||||
var chunk = level.getChunk(pos.get(0).getAsInt(), pos.get(1).getAsInt(), ChunkStatus.FULL, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Native start must load from the existing saved chunk");
|
||||
helper.assertTrue(hash(start.createTag(context, start.getChunkPos()).toString()).equals(site.get("native_start_sha256").getAsString()),
|
||||
"Saved native structure pieces, layout and decoration seeds must remain byte-equivalent after restart");
|
||||
savedPieces += start.getPieces().size();
|
||||
for (var chestElement : site.getAsJsonArray("unopened_chests")) {
|
||||
var saved = chestElement.getAsJsonObject(); var at = saved.getAsJsonArray("position");
|
||||
var chestPos = new BlockPos(at.get(0).getAsInt(), at.get(1).getAsInt(), at.get(2).getAsInt());
|
||||
level.getChunk(chestPos);
|
||||
helper.assertTrue(level.getBlockEntity(chestPos) instanceof RandomizableContainerBlockEntity,
|
||||
"Deferred chest block entity must survive native region storage");
|
||||
var chest = (RandomizableContainerBlockEntity) level.getBlockEntity(chestPos);
|
||||
helper.assertTrue(chest.getLootTable() != null && chest.getLootTable().identifier().toString().equals(saved.get("table").getAsString())
|
||||
&& chest.getLootTableSeed() == saved.get("seed").getAsLong(), "Unopened loot table and seed must remain unconsumed after restart");
|
||||
deferredChests++;
|
||||
}
|
||||
}
|
||||
var readback = new ExpansionJournal(journal(level, generator).getParent(), level.getSeed(), generator.initialDiameter(), 14);
|
||||
helper.assertTrue(readback.healthy() && readback.islands().equals(session.islands()), "The generation-14 journal reopens with its exact immutable identities");
|
||||
result.put("saved_native_pieces", savedPieces); result.put("preserved_deferred_chests", deferredChests);
|
||||
result.put("native_pool_fixture", verifyPoolFixture(helper, false));
|
||||
result.put("gold_witness_preserved", true); result.put("journal_preserved", true); result.put("passed", true);
|
||||
write(level, result, true); helper.succeed();
|
||||
} catch (Exception exception) {
|
||||
result.put("passed", false); result.put("failure", exception.toString());
|
||||
try { write(level, result, true); } catch (IOException ignored) { /* Keep the original failure. */ }
|
||||
throw new IllegalStateException("Lost City native reload failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> report(ServerLevel level) {
|
||||
var report = new LinkedHashMap<String, Object>();
|
||||
report.put("seed", level.getSeed()); report.put("process_id", ProcessHandle.current().pid());
|
||||
report.put("world_directory", level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString());
|
||||
report.put("scope", "Actual native district generation on a fresh development root and one activated city expansion. "
|
||||
+ "Archetype coverage is a separate deterministic fixture; it does not claim every building occurs on every seed. "
|
||||
+ "Zombie checks inspect the effective spawn table, not a guaranteed daylight population. No personal world is opened.");
|
||||
return report;
|
||||
}
|
||||
|
||||
private static Path journal(ServerLevel level, SanctuaryChunkGenerator generator) {
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data").resolve(generator.journalDirectory()).resolve("expansions.json");
|
||||
}
|
||||
private static Path path(ServerLevel level, boolean reload) {
|
||||
return Path.of("diagnostics", "lost-city-alpha14" + (reload ? "-reload" : "") + "-seed-" + level.getSeed() + ".json");
|
||||
}
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
Path path = path(level, reload); Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
}
|
||||
private static String hash(String text) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+1030
File diff suppressed because it is too large
Load Diff
+140
@@ -0,0 +1,140 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.ExactInterpolationChecks;
|
||||
import fr.koka.sanctuary.worldgen.ExactInterpolationSampler;
|
||||
import fr.koka.sanctuary.worldgen.IslandCapacity;
|
||||
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
|
||||
/** Compares production against the frozen alpha.10 implementation; no chunks are created. */
|
||||
final class PopulationDensityEquivalenceDiagnostics {
|
||||
private record Pair(int players, long seed, DensitySampler reference, DensitySampler actual) {}
|
||||
|
||||
static void verify(GameTestHelper helper) throws Exception {
|
||||
Map<String, Object> adversarial = ExactInterpolationChecks.verify();
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
var noises = registries.lookupOrThrow(Registries.NOISE);
|
||||
var densities = registries.lookupOrThrow(Registries.DENSITY_FUNCTION);
|
||||
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
List<Map<String, Object>> reports = new ArrayList<>();
|
||||
List<Pair> pairs = new ArrayList<>();
|
||||
for (int players : new int[]{5, 10, 20, 100}) {
|
||||
var key = SanctuaryMod.id("population_" + players);
|
||||
var registered = densities.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, key)).value();
|
||||
helper.assertTrue(registered instanceof PopulationIslandDensity, "Compare the real registered Population density");
|
||||
var density = (PopulationIslandDensity) registered;
|
||||
helper.assertTrue(ExactInterpolationSampler.supportsTerrain(density.terrain()),
|
||||
"The shipped coordinate-only terrain must actually use the exact cache, not the fallback");
|
||||
var reference = new Alpha10PopulationDensityReference(density.terrain(), density.distortion(), density.sculpt(),
|
||||
density.detail(), density.underside(), density.riftWarp(), density.riftDetail(), density.capacity());
|
||||
for (long seed : new long[]{0, 42, 8675309}) {
|
||||
var state = RandomState.create(noises, seed,
|
||||
settings.getOrThrow(ResourceKey.create(Registries.NOISE_SETTINGS, key)).value());
|
||||
var pair = new Pair(players, seed, state.getSampler(reference), state.getSampler(density));
|
||||
pairs.add(pair);
|
||||
SamplerContext context = SamplerContext.builder().enableCaches().build();
|
||||
long comparisons = 0, digest = 0xcbf29ce484222325L;
|
||||
for (var volume : volumes(IslandCapacity.fromPlayers(players))) {
|
||||
DensityBuffer expected = DensityBuffer.createUnpooled(volume.size());
|
||||
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
|
||||
pair.reference().sampleVolume(SamplerContext.EMPTY_UNCACHED, expected, volume);
|
||||
pair.actual().sampleVolume(context, actual, volume);
|
||||
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
|
||||
int index = volume.indexUnchecked(x, y, z);
|
||||
int wx = volume.blockX(x), wy = volume.blockY(y), wz = volume.blockZ(z);
|
||||
same(pair, expected.get(index), actual.get(index), wx, wy, wz, "bulk");
|
||||
same(pair, expected.get(index), pair.actual().sampleValue(context, wx, wy, wz), wx, wy, wz, "scalar after bulk");
|
||||
comparisons += 2;
|
||||
digest = (digest ^ Float.floatToRawIntBits(expected.get(index))) * 0x100000001b3L;
|
||||
}
|
||||
}
|
||||
Random order = new Random(seed ^ players);
|
||||
int limit = density.capacity().terrainLimit();
|
||||
for (int i = 0; i < 512; i++) {
|
||||
int x = order.nextInt(limit * 2 + 33) - limit - 16;
|
||||
int y = order.nextInt(400) - 8;
|
||||
int z = order.nextInt(limit * 2 + 33) - limit - 16;
|
||||
float expected = pair.reference().sampleValue(SamplerContext.EMPTY_UNCACHED, x, y, z);
|
||||
same(pair, expected, pair.actual().sampleValue(SamplerContext.EMPTY_UNCACHED, x, y, z), x, y, z, "uncached context");
|
||||
same(pair, expected, pair.actual().sampleValue(context, x, y, z), x, y, z, "context switch");
|
||||
comparisons += 2;
|
||||
}
|
||||
reports.add(Map.of("players", players, "seed", seed, "bitwise_comparisons", comparisons,
|
||||
"reference_density_digest", Long.toUnsignedString(digest, 16), "optimized_path_supported", true));
|
||||
SanctuaryMod.LOGGER.info("Exact density equivalence: players {}, seed {}, {} bitwise comparisons", players, seed, comparisons);
|
||||
}
|
||||
}
|
||||
long concurrentComparisons = 0;
|
||||
try (var executor = Executors.newFixedThreadPool(3)) {
|
||||
List<Callable<Long>> tasks = new ArrayList<>();
|
||||
for (int worker = 0; worker < 3; worker++) {
|
||||
int id = worker;
|
||||
tasks.add(() -> {
|
||||
long count = 0;
|
||||
SamplerContext local = SamplerContext.builder().enableCaches().build();
|
||||
// All workers share the same samplers and alternate worlds/seeds.
|
||||
// Only their scratch buffers and sampler contexts are thread-local.
|
||||
for (int round = 0; round < 3; round++) for (var pair : pairs) {
|
||||
var volume = new DensityVolume(9, 17, 7, -13 + id, 89 + round, -7 - id);
|
||||
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
|
||||
pair.actual().sampleVolume(local, actual, volume);
|
||||
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
|
||||
int wx = volume.blockX(x), wy = volume.blockY(y), wz = volume.blockZ(z);
|
||||
same(pair, pair.reference().sampleValue(local, wx, wy, wz),
|
||||
actual.get(volume.indexUnchecked(x, y, z)), wx, wy, wz, "concurrent bulk");
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
});
|
||||
}
|
||||
for (var result : executor.invokeAll(tasks)) concurrentComparisons += result.get();
|
||||
}
|
||||
Path report = Path.of("diagnostics", "population-alpha10-density-equivalence.json");
|
||||
Files.createDirectories(report.getParent());
|
||||
Files.writeString(report, new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
|
||||
"reference_commit", "a26b72f", "reference_source_sha256", "fcc5a8a39b7c4d181d74ce4f9183a1ca93646137955ac64bab9d22e6db0752a1",
|
||||
"comparison", "Float.floatToRawIntBits; no tolerance", "profiles", reports, "adversarial", adversarial,
|
||||
"concurrent_comparisons", concurrentComparisons, "concurrent_workers", 3, "passed", true,
|
||||
"scope", "Real compiled scalar/bulk densities, 4 capacities x 3 seeds (5/20/100 compatibility and new10 using the same parameterized algorithm), signed coordinates, region/coast/height boundaries, context switches and concurrency; no block changes")) + "\n");
|
||||
}
|
||||
|
||||
private static List<DensityVolume> volumes(IslandCapacity capacity) {
|
||||
int limit = capacity.terrainLimit(), middle = (int) capacity.radius() / 2;
|
||||
return List.of(
|
||||
new DensityVolume(16, 384, 16, -8, 0, -8),
|
||||
new DensityVolume(19, 11, 17, -263, 61, -259),
|
||||
new DensityVolume(17, 17, 17, 249, 124, 249),
|
||||
new DensityVolume(13, 21, 11, 761, 167, -9),
|
||||
new DensityVolume(13, 21, 11, -775, 103, 503),
|
||||
new DensityVolume(17, 49, 5, limit - 12, 96, -2),
|
||||
new DensityVolume(17, 49, 5, -limit - 4, 96, -2),
|
||||
new DensityVolume(12, 33, 12, middle - 7, 90, -middle / 2 - 5),
|
||||
new DensityVolume(5, 13, 5, -2, -4, -2),
|
||||
new DensityVolume(5, 13, 5, -2, 378, -2),
|
||||
new DensityVolume(9, 13, 7, -257, 61, -9, 3, 5, 2));
|
||||
}
|
||||
|
||||
private static void same(Pair pair, float expected, float actual, int x, int y, int z, String phase) {
|
||||
if (Float.floatToRawIntBits(expected) != Float.floatToRawIntBits(actual))
|
||||
throw new AssertionError("Density changed from alpha10 in " + phase + ": players=" + pair.players() + ", seed=" + pair.seed()
|
||||
+ ", position=" + x + "," + y + "," + z + ", expected=" + Float.toHexString(expected) + ", actual=" + Float.toHexString(actual));
|
||||
}
|
||||
}
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.*;
|
||||
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.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.MultifaceBlock;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** Bounded engine probes at the centre AND in newly available terrain. No whole-island scan. */
|
||||
public final class PopulationDiagnostics {
|
||||
public static int regionsAtInitialSpawn;
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST};
|
||||
private PopulationDiagnostics() {}
|
||||
|
||||
static void verifyLegacyIsolation(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
var binding = binding(level);
|
||||
var generator = binding.generator();
|
||||
var capacity = IslandCapacity.forGenerator(generator);
|
||||
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)
|
||||
|| RiftHydrologyRuntime.enabled(generator) || CavernHydrologyRuntime.enabled(generator),
|
||||
"Only the population hydrology pipeline may run with the new settings");
|
||||
var settings = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
for (var key : List.of(SanctuarySpawn.SETTINGS, SanctuarySpawn.RAISED_SETTINGS, SanctuarySpawn.HYDROLOGY_SETTINGS,
|
||||
SanctuarySpawn.NATURAL_SETTINGS, SanctuarySpawn.LAYERED_SETTINGS, SanctuarySpawn.WOODLAND_SETTINGS,
|
||||
SanctuarySpawn.RIFT_SETTINGS, SanctuarySpawn.CAVERN_SETTINGS)) {
|
||||
var old = new NoiseBasedChunkGenerator(generator.getBiomeSource(), settings.getOrThrow(key));
|
||||
helper.assertFalse(PopulationHydrologyRuntime.enabled(old), "A saved settings key must stay isolated: " + key);
|
||||
helper.assertFalse(new PopulationGrovesFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO),
|
||||
"New trees must refuse older saved worlds");
|
||||
helper.assertFalse(new PopulationDecorationsFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO),
|
||||
"New cave decorations must refuse older saved worlds");
|
||||
}
|
||||
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
|
||||
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
|
||||
&& generator.getBiomeSource().possibleBiomes().size() == (((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).source().citiesEnabled() ? 45 : 44)
|
||||
&& generator.getBiomeSource().possibleBiomes().stream().map(b -> b.getRegisteredName()).collect(java.util.stream.Collectors.toSet()).containsAll(SanctuaryBiomeSource.REQUIRED_BIOMES),
|
||||
"The unified generator retains all 44 historical biome holders, with Lost City added in city-enabled generations");
|
||||
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 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 = binding.random();
|
||||
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), random.seed(), generator.generatorSettings().value());
|
||||
int step = Math.max(8, (int) Math.ceil(capacity.terrainLimit() / 32.0));
|
||||
int extent = (int) Math.ceil(capacity.terrainLimit() / (double) step) * step;
|
||||
int solidColumns = 0, outerColumns = 0, emptyColumns = 0;
|
||||
List<int[]> landCoordinates = new ArrayList<>();
|
||||
Set<Integer> bottomHeights = new TreeSet<>();
|
||||
StringBuilder map = new StringBuilder("x,z,bottom_y,top_y\n");
|
||||
for (int x = -extent; x <= extent; x += step) for (int z = -extent; z <= extent; z += step) {
|
||||
int bottom = -1, top = -1;
|
||||
for (int y = 8; y < 384; y += 8) {
|
||||
float value = random.sampleBlockValueUncached(density, x, y, z);
|
||||
float raw = random.sampleBlockValueUncached(island.terrain(), x, y - 64, z);
|
||||
helper.assertTrue(Float.isFinite(value), "Density remains finite at every sampled position");
|
||||
helper.assertTrue(value <= 0 || raw > 0, "Enlarging the envelope must never invent terrain inside natural holes");
|
||||
if (value > 0) { if (bottom < 0) bottom = y; top = y; }
|
||||
if (x % (step * 8) == 0 && z % (step * 8) == 0)
|
||||
helper.assertTrue(Float.floatToIntBits(value) == Float.floatToIntBits(replay.sampleBlockValueUncached(density, x, y, z)),
|
||||
"Seed and capacity must replay identically without depending on region request order");
|
||||
}
|
||||
if (top >= 0) {
|
||||
solidColumns++;
|
||||
landCoordinates.add(new int[]{x, z});
|
||||
bottomHeights.add(bottom);
|
||||
if (Math.hypot(x, z) > IslandShape.TERRAIN_LIMIT + 16) outerColumns++;
|
||||
helper.assertTrue(Math.hypot(x, z) < capacity.terrainLimit(), "Terrain stays inside its finite declared boundary");
|
||||
} else emptyColumns++;
|
||||
map.append(x).append(',').append(z).append(',').append(bottom).append(',').append(top).append('\n');
|
||||
}
|
||||
helper.assertTrue(solidColumns > 100 && emptyColumns > 100 && bottomHeights.size() >= 4,
|
||||
"The sampled island must contain natural masses, holes and a varied underside");
|
||||
int additionalColumns = 0;
|
||||
if (capacity == IslandCapacity.TEN) {
|
||||
var small = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
|
||||
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_5"))).value();
|
||||
for (int[] coordinate : landCoordinates) {
|
||||
boolean presentInSmall = false;
|
||||
for (int y = 8; y < 384 && !presentInSmall; y += 8)
|
||||
presentInSmall = random.sampleBlockValueUncached(small, coordinate[0], y, coordinate[1]) > 0;
|
||||
if (!presentInSmall) additionalColumns++;
|
||||
}
|
||||
helper.assertTrue(additionalColumns > 0,
|
||||
"Large must add real land columns compared with Small on the same seed and sample grid");
|
||||
} else helper.assertTrue(capacity.players() == 5 ? outerColumns == 0 : outerColumns > 100,
|
||||
"The historical large capacities must contain land beyond the old island boundary");
|
||||
Path directory = Path.of("diagnostics");
|
||||
Files.createDirectories(directory);
|
||||
String prefix = "population-" + capacity.players() + "-seed-" + level.getSeed();
|
||||
Files.writeString(directory.resolve(prefix + "-density.csv"), map.toString());
|
||||
Files.writeString(directory.resolve(prefix + "-density.json"), json(Map.of("players", capacity.players(),
|
||||
"seed", level.getSeed(), "step", step, "solid_columns", solidColumns, "empty_columns", emptyColumns,
|
||||
"solid_columns_beyond_old_boundary", outerColumns, "underside_heights", bottomHeights,
|
||||
"scope", "sparse actual density samples; no resource extrapolation", "passed", true,
|
||||
"additional_columns_vs_small", capacity == IslandCapacity.TEN ? additionalColumns : -1)));
|
||||
SanctuaryMod.LOGGER.info("Capacity {} density seed {}: {} land columns, {} beyond the old envelope",
|
||||
capacity.players(), level.getSeed(), solidColumns, outerColumns);
|
||||
}
|
||||
|
||||
static void verifyExterior(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
var capacity = IslandCapacity.forGenerator(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]);
|
||||
for (var section : chunk.getSections()) helper.assertTrue(section.hasOnlyAir(),
|
||||
"Completed terrain and decoration must remain void beyond this capacity: " + chunk.getPos());
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyLowerMargin(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int[] point : new int[][]{{0,0},{-8,0},{8,0},{0,-8},{0,8}}) {
|
||||
var chunk = full(helper, level, point[0], point[1]);
|
||||
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) for (int y = 0; y < 16; y++) {
|
||||
var pos = new BlockPos(point[0]*16+x, y, point[1]*16+z);
|
||||
var block = chunk.getBlockState(pos);
|
||||
helper.assertTrue(block.isAir() || block.getFluidState().is(FluidTags.WATER) || block.getFluidState().is(FluidTags.LAVA),
|
||||
"The increased horizontal size must not add a lower floor: " + pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void start(GameTestHelper helper) throws IOException {
|
||||
var context = new Context(helper);
|
||||
try { context.prepare(); }
|
||||
catch (RuntimeException | Error | IOException e) { context.cleanup(); throw e; }
|
||||
helper.onEachTick(() -> {
|
||||
try { context.tick(); }
|
||||
catch (RuntimeException | Error e) { context.cleanup(); throw e; }
|
||||
});
|
||||
}
|
||||
|
||||
private static final class Context {
|
||||
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<>();
|
||||
final Set<Long> forced = new HashSet<>();
|
||||
final Set<BlockPos> expectedWater = new HashSet<>();
|
||||
final Map<BlockPos, PopulationHydrology.Cell> selectedCells = new LinkedHashMap<>();
|
||||
final Map<BlockPos, BlockState> naturalLogs = new HashMap<>();
|
||||
final Set<BlockPos> declaredWater = new HashSet<>();
|
||||
final Set<BlockPos> expectedLava = new HashSet<>();
|
||||
final Set<BlockPos> lavaSupports = new HashSet<>();
|
||||
final List<PopulationLavaDeposit.Cell> pocketCells = new ArrayList<>();
|
||||
final List<PopulationLavaFlows.Fall> lavaFalls = new ArrayList<>();
|
||||
final List<Map<String,Object>> flowReports = new ArrayList<>();
|
||||
int reentryWitnesses;
|
||||
final List<PopulationHydrology.Spring> springs = new ArrayList<>();
|
||||
final List<PopulationHydrology.Spill> spills = new ArrayList<>();
|
||||
final List<Map<String,Object>> regionReports = new ArrayList<>();
|
||||
long startTime;
|
||||
boolean running, closed;
|
||||
|
||||
Context(GameTestHelper helper) {
|
||||
this.helper = helper; level = helper.getLevel();
|
||||
var binding = binding(level);
|
||||
generator = binding.generator();
|
||||
random = binding.random();
|
||||
capacity = IslandCapacity.forGenerator(generator);
|
||||
}
|
||||
|
||||
void prepare() throws IOException {
|
||||
helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= 4,
|
||||
"Initial spawn must plan only nearby regions, not materialize the large island: " + regionsAtInitialSpawn);
|
||||
List<IslandCapacity.Region> targets = new ArrayList<>();
|
||||
targets.add(new IslandCapacity.Region(0,0));
|
||||
if (capacity.players() == 20) targets.add(new IslandCapacity.Region(1,0));
|
||||
if (capacity.players() == 100) {
|
||||
targets.add(new IslandCapacity.Region(1,1));
|
||||
targets.add(new IslandCapacity.Region(2,0));
|
||||
}
|
||||
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, random, target);
|
||||
regions.add(region);
|
||||
var plan = region.water();
|
||||
checkOwnership(region);
|
||||
var ids = new HashSet<Long>();
|
||||
plan.features().stream().filter(f -> f.kind() == PopulationHydrology.Kind.LAKE || f.kind() == PopulationHydrology.Kind.POND)
|
||||
.max(Comparator.comparingInt(PopulationHydrology.Feature::waterCells)).ifPresent(f -> ids.add(f.id()));
|
||||
if (!plan.terraces().isEmpty()) ids.addAll(plan.terraces().getFirst().basinIds());
|
||||
if (ids.isEmpty()) plan.features().stream().findFirst().ifPresent(f -> ids.add(f.id()));
|
||||
for (var cell : plan.cells()) if (ids.contains(cell.featureId())) {
|
||||
selectedCells.put(new BlockPos(cell.x(), cell.bedY(), cell.z()), cell);
|
||||
request(requested, cell.x() >> 4, cell.z() >> 4, 1);
|
||||
if (cell.hasWater()) for (int y = cell.bedY()+1; y <= cell.waterY(); y++) expectedWater.add(new BlockPos(cell.x(),y,cell.z()));
|
||||
}
|
||||
for (var spill : plan.spills()) if (ids.contains(spill.fromFeatureId())) {
|
||||
spills.add(spill); request(requested, spill.flowBounds());
|
||||
}
|
||||
if (!plan.springs().isEmpty()) {
|
||||
var spring = plan.springs().getFirst(); springs.add(spring);
|
||||
request(requested, spring.flowBounds()); expectedWater.add(pos(spring.source()));
|
||||
}
|
||||
// Inspect the finite pocket and at most one volcanic fall per selected region.
|
||||
var lava = region.lava();
|
||||
Set<BlockPos> allFallSources = new HashSet<>(), allFallSupports = new HashSet<>();
|
||||
for (var fall : lava.falls()) {
|
||||
allFallSources.add(lavaPos(fall.source()));
|
||||
fall.supports().forEach(p -> allFallSupports.add(lavaPos(p)));
|
||||
}
|
||||
for (var cell : lava.cells()) {
|
||||
BlockPos p = new BlockPos(cell.x(), cell.y(), cell.z());
|
||||
if (allFallSources.contains(p)) continue;
|
||||
pocketCells.add(cell);
|
||||
request(requested, cell.x() >> 4, cell.z() >> 4, 1);
|
||||
if (cell.lava()) expectedLava.add(p);
|
||||
}
|
||||
for (var support : lava.supports()) if (!allFallSupports.contains(lavaPos(support))) {
|
||||
lavaSupports.add(lavaPos(support));
|
||||
request(requested, support.x() >> 4, support.z() >> 4, 1);
|
||||
}
|
||||
if (!lava.falls().isEmpty()) {
|
||||
var fall = lava.falls().getFirst(); lavaFalls.add(fall);
|
||||
request(requested, fall.flowBounds()); expectedLava.add(lavaPos(fall.source()));
|
||||
fall.supports().forEach(p -> lavaSupports.add(lavaPos(p)));
|
||||
}
|
||||
// A small, independently selected patch proves full ecology beyond the former radius.
|
||||
int ox = target.originX(), oz = target.originZ();
|
||||
int bestX = ox, bestZ = oz; int highest = -1;
|
||||
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,random);
|
||||
if (top > highest) { highest=top; bestX=x; bestZ=z; }
|
||||
}
|
||||
helper.assertTrue(highest > 48, "Every inspected region must contain real natural terrain");
|
||||
request(requested, bestX >> 4, bestZ >> 4, 2);
|
||||
regionReports.add(new LinkedHashMap<>(Map.of("region_x",target.x(),"region_z",target.z(),
|
||||
"planned_features",plan.features().size(),"planned_cells",plan.cells().size(),
|
||||
"planned_springs",plan.springs().size(),"planned_terraces",plan.terraces().size(),
|
||||
"ecology_patch_x",bestX,"ecology_patch_z",bestZ)));
|
||||
}
|
||||
helper.assertTrue(!expectedWater.isEmpty(), "The test must inspect retained production water");
|
||||
if (capacity.players() > 10) helper.assertTrue(regions.stream().skip(1).anyMatch(r -> !r.water().cells().isEmpty()),
|
||||
"Hydrology must occur on newly available terrain, not only around the old central island");
|
||||
SanctuaryMod.LOGGER.info("Capacity {}: preparing {} bounded FULL chunks",capacity.players(),requested.size());
|
||||
for (long packed : requested) {
|
||||
var p=ChunkPos.unpack(packed);
|
||||
if (level.setChunkForced(p.x(),p.z(),true)) forced.add(packed);
|
||||
}
|
||||
for (long packed : requested) {
|
||||
var p=ChunkPos.unpack(packed); chunks.put(packed,full(helper,level,p.x(),p.z()));
|
||||
}
|
||||
for (long packed : requested) level.waitForEntities(ChunkPos.unpack(packed),0);
|
||||
declaredWater.addAll(expectedWater);
|
||||
captureNaturalLogs();
|
||||
captureEcology();
|
||||
export(false,0);
|
||||
}
|
||||
|
||||
void checkOwnership(PopulationHydrologyRuntime.RegionPlan region) {
|
||||
var owner=region.region(); var plan=region.water();
|
||||
Set<Long> ids=new HashSet<>();
|
||||
for(var feature:plan.features()) helper.assertTrue(ids.add(feature.id()),"Feature IDs must be unique within a region");
|
||||
for(var cell:plan.cells()) {
|
||||
helper.assertTrue(cell.x()>=owner.minX() && cell.x()<=owner.maxX() && cell.z()>=owner.minZ() && cell.z()<=owner.maxZ(),
|
||||
"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(random.sampleBlockValueUncached(
|
||||
generator.generatorSettings().value().noiseRouter().finalDensity(),cell.x(),y,cell.z())>0,
|
||||
"Regional translation must retain the real natural support below sediment: "+cell);
|
||||
}
|
||||
for(var terrace:plan.terraces()) helper.assertTrue(ids.containsAll(terrace.basinIds()),
|
||||
"Admission must retain or reject a complete group of water terraces");
|
||||
for(var spill:plan.spills()) helper.assertTrue(ids.contains(spill.fromFeatureId())
|
||||
&& (spill.toFeatureId()==-1 || ids.contains(spill.toFeatureId())),
|
||||
"No cascade may refer to a basin removed by region admission");
|
||||
for(var bounds:region.lava().flowBounds()) helper.assertTrue(bounds.minX()>=owner.minX() && bounds.maxX()<=owner.maxX()
|
||||
&& bounds.minZ()>=owner.minZ() && bounds.maxZ()<=owner.maxZ(),"Volcanic flows must respect the same ownership");
|
||||
}
|
||||
|
||||
void captureEcology() {
|
||||
for (int i=0;i<regions.size();i++) {
|
||||
var region=regions.get(i).region(); var counts=new TreeMap<String,Long>();
|
||||
long logs=0,ores=0,nonAir=0;
|
||||
for(var chunk:chunks.values()) {
|
||||
int x=chunk.getPos().getMinBlockX(),z=chunk.getPos().getMinBlockZ();
|
||||
if (!capacity.regionAt(x,z).equals(region)) continue;
|
||||
for(var section:chunk.getSections()) section.getStates().count((state,count)->{
|
||||
if(!state.isAir()) counts.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(),(long)count,Long::sum);
|
||||
});
|
||||
}
|
||||
for(var entry:counts.entrySet()) {
|
||||
nonAir+=entry.getValue();
|
||||
if(entry.getKey().endsWith("_log")) logs+=entry.getValue();
|
||||
if(entry.getKey().endsWith("_ore")) ores+=entry.getValue();
|
||||
}
|
||||
helper.assertTrue(nonAir>1000,"Distant FULL chunks must contain the new island");
|
||||
helper.assertTrue(logs>0,"Trees must decorate each inspected region, including outside the old envelope");
|
||||
helper.assertTrue(ores>0,"Ore features must also decorate the new distant terrain");
|
||||
regionReports.get(i).put("observed_blocks",counts);
|
||||
regionReports.get(i).put("observed_logs",logs);
|
||||
regionReports.get(i).put("observed_ores",ores);
|
||||
}
|
||||
}
|
||||
|
||||
void tick() {
|
||||
if(closed || running || helper.getTick()<20) return;
|
||||
if(!chunks.keySet().stream().allMatch(level::isPositionTickingWithEntitiesLoaded)) {
|
||||
helper.assertTrue(helper.getTick()<800,"All forced inspection chunks must become ready for real fluid ticks");
|
||||
return;
|
||||
}
|
||||
running=true; startTime=level.getGameTime();
|
||||
verifyWater();
|
||||
verifyFinishedShell();
|
||||
verifyLava();
|
||||
helper.runAfterDelay(1800,()->{
|
||||
try {
|
||||
helper.assertTrue(chunks.keySet().stream().allMatch(level::isPositionTickingWithEntitiesLoaded),
|
||||
"Chunks must remain scheduler-ready throughout the measured fluid interval");
|
||||
verifyWater();
|
||||
verifyFinishedShell();
|
||||
verifyLava();
|
||||
for (var spring : springs) verifySpring(spring);
|
||||
for(var spill:spills) {
|
||||
Set<BlockPos> destination=null;
|
||||
if(spill.toFeatureId()!=-1) {
|
||||
destination=new HashSet<>();
|
||||
for(var region:regions) for(var cell:region.water().cells()) if(cell.featureId()==spill.toFeatureId() && cell.hasWater())
|
||||
destination.add(new BlockPos(cell.x(),cell.waterY(),cell.z()));
|
||||
}
|
||||
int drop=connectedDescent(pos(spill.source()),pos(spill.outlet()),destination);
|
||||
helper.assertTrue(drop >= (destination==null?12:4),"Each retained cascade must connect downhill to its basin or fall: "+spill);
|
||||
}
|
||||
for (var fall : lavaFalls) {
|
||||
int descent = connectedDescent(lavaPos(fall.source()), lavaPos(fall.outlet()), null, true);
|
||||
helper.assertTrue(descent >= 8, "A translated volcanic fall must descend through real lava ticks: " + fall.source());
|
||||
}
|
||||
verifyPostProcessingReentry();
|
||||
export(true,level.getGameTime()-startTime);
|
||||
helper.succeed();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try { export(false, level.getGameTime() - startTime); }
|
||||
catch (IOException e) { failure.addSuppressed(e); }
|
||||
throw failure;
|
||||
} catch(IOException e) {throw new IllegalStateException(e);}
|
||||
finally {cleanup();}
|
||||
});
|
||||
}
|
||||
|
||||
PopulationHydrology.Plan waterPlanAt(int x, int z) {
|
||||
var owner = capacity.regionAt(x, z);
|
||||
return regions.stream().filter(r -> r.region().equals(owner)).map(PopulationHydrologyRuntime.RegionPlan::water)
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
PopulationHydrology.Cell cellAt(BlockPos p) {
|
||||
var plan = waterPlanAt(p.getX(), p.getZ());
|
||||
return plan == null ? null : plan.cellAt(p.getX(), p.getY(), p.getZ());
|
||||
}
|
||||
|
||||
float natural(BlockPos p) {
|
||||
return random.sampleBlockValueUncached(
|
||||
generator.generatorSettings().value().noiseRouter().finalDensity(), p.getX(), p.getY(), p.getZ());
|
||||
}
|
||||
|
||||
void captureNaturalLogs() {
|
||||
for (BlockPos p : declaredWater) {
|
||||
BlockState state = level.getBlockState(p);
|
||||
if (state.getFluidState().is(FluidTags.WATER) && state.getFluidState().isSource()) continue;
|
||||
var cell = cellAt(p);
|
||||
boolean horizontal = state.is(BlockTags.LOGS) && state.hasProperty(BlockStateProperties.AXIS)
|
||||
&& state.getValue(BlockStateProperties.AXIS) != Direction.Axis.Y;
|
||||
boolean lowerWater = declaredWater.contains(p.below())
|
||||
&& level.getFluidState(p.below()).is(FluidTags.WATER) && level.getFluidState(p.below()).isSource();
|
||||
boolean bed = cell != null && cell.bedY() == p.getY() - 1
|
||||
&& level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below());
|
||||
boolean beside = Arrays.stream(HORIZONTAL).map(p::relative).anyMatch(side -> declaredWater.contains(side)
|
||||
&& level.getFluidState(side).is(FluidTags.WATER) && level.getFluidState(side).isSource());
|
||||
helper.assertTrue(cell != null && horizontal && p.getY() == cell.waterY() && (lowerWater || bed) && beside,
|
||||
"Only a supported horizontal surface log beside real retained source water may occupy a planned water voxel: " + p + "=" + state);
|
||||
naturalLogs.put(p.immutable(), state); expectedWater.remove(p);
|
||||
}
|
||||
helper.assertTrue(expectedWater.size() + naturalLogs.size() == declaredWater.size(),
|
||||
"Every selected water voxel must have an exact retained source or recorded log");
|
||||
}
|
||||
|
||||
void verifyFinishedShell() {
|
||||
for (var cell : selectedCells.values()) {
|
||||
var plan = waterPlanAt(cell.x(), cell.z());
|
||||
var kind = plan.features().stream().filter(f -> f.id() == cell.featureId()).findFirst().orElseThrow().kind();
|
||||
int cut = !cell.hasWater() ? kind == PopulationHydrology.Kind.RIVER ? PopulationHydrology.MAX_RIVER_BANK_CARVE : 2
|
||||
: kind == PopulationHydrology.Kind.TERRACE ? PopulationTerraces.MAX_CARVE
|
||||
: kind == PopulationHydrology.Kind.LAKE ? PopulationHydrology.MAX_LAKE_CARVE : PopulationHydrology.MAX_CARVE;
|
||||
helper.assertTrue(cell.sedimentDepth() >= 3 && cell.sedimentDepth() <= 5 && cell.carveTop() - cell.bedY() <= cut,
|
||||
"Regional sediment and carving must keep their feature-specific budgets: " + cell);
|
||||
for (int d = 0; d <= cell.sedimentDepth() + 1; d++) {
|
||||
BlockPos p = new BlockPos(cell.x(), cell.bedY() - d, cell.z());
|
||||
BlockState actual = level.getBlockState(p);
|
||||
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, 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)
|
||||
|| actual.is(Blocks.GOLD_ORE) || actual.is(Blocks.DEEPSLATE_GOLD_ORE)
|
||||
|| actual.is(Blocks.REDSTONE_ORE) || actual.is(Blocks.DEEPSLATE_REDSTONE_ORE)
|
||||
|| actual.is(Blocks.LAPIS_ORE) || actual.is(Blocks.DEEPSLATE_LAPIS_ORE)
|
||||
|| actual.is(Blocks.DIAMOND_ORE) || actual.is(Blocks.DEEPSLATE_DIAMOND_ORE)
|
||||
|| actual.is(Blocks.EMERALD_ORE) || actual.is(Blocks.DEEPSLATE_EMERALD_ORE);
|
||||
helper.assertTrue(actual.equals(expected) || expected.is(BlockTags.BASE_STONE_OVERWORLD) && ore
|
||||
|| expected.is(Blocks.GRASS_BLOCK) && actual.is(Blocks.DIRT),
|
||||
"The selected regional sediment profile must survive decoration: " + p + " expected=" + expected + " actual=" + actual);
|
||||
}
|
||||
if (!cell.hasWater()) continue;
|
||||
int maxDepth = kind == PopulationHydrology.Kind.TERRACE ? PopulationTerraces.MAX_DEPTH : PopulationHydrology.MAX_DEPTH;
|
||||
helper.assertTrue(cell.waterY() > cell.bedY() && cell.waterY() - cell.bedY() <= maxDepth,
|
||||
"Retained water must respect its feature depth limit");
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) for (Direction direction : HORIZONTAL) {
|
||||
BlockPos wall = new BlockPos(cell.x(), y, cell.z()).relative(direction);
|
||||
var next = cellAt(wall);
|
||||
boolean plannedWater = next != null && next.hasWater() && y > next.bedY() && y <= next.waterY();
|
||||
if (plannedWater || plan.isSpillOpening(wall.getX(), wall.getY(), wall.getZ())) continue;
|
||||
helper.assertTrue(natural(wall) > 0 && level.getBlockState(wall).isCollisionShapeFullBlock(level, wall),
|
||||
"A retained basin must preserve its actual natural underwater shell, except its declared outlet: " + wall);
|
||||
}
|
||||
if (kind == PopulationHydrology.Kind.TERRACE) {
|
||||
for (int y = cell.carveTop() + 1; y <= cell.carveTop() + 4; y++)
|
||||
helper.assertTrue(natural(new BlockPos(cell.x(), y, cell.z())) <= 0, "A cave terrace needs four natural headroom blocks");
|
||||
boolean roof = false;
|
||||
for (int y = cell.carveTop() + 5; y < 384 && !roof; y++) roof = natural(new BlockPos(cell.x(), y, cell.z())) > 0;
|
||||
helper.assertTrue(roof, "A lower terrace must remain below an existing natural roof");
|
||||
} else for (int y = cell.carveTop() + 1; y < 384; y++)
|
||||
helper.assertTrue(natural(new BlockPos(cell.x(), y, cell.z())) <= 0, "A surface basin must be exposed to the natural sky");
|
||||
}
|
||||
}
|
||||
|
||||
void verifyLava() {
|
||||
for (BlockPos p : expectedLava) helper.assertTrue(natural(p) > 0
|
||||
&& level.getFluidState(p).is(FluidTags.LAVA) && level.getFluidState(p).isSource(),
|
||||
"Every inspected lava source must replace natural rock and survive actual fluid ticks: " + p);
|
||||
for (BlockPos p : lavaSupports) {
|
||||
BlockState block = level.getBlockState(p);
|
||||
helper.assertTrue(natural(p) > 0 && block.isCollisionShapeFullBlock(level, p) && !block.ignitedByLava(),
|
||||
"A selected volcanic niche must keep its actual nonflammable natural support: " + p);
|
||||
}
|
||||
for (var cell : pocketCells) if (!cell.lava()) {
|
||||
BlockPos p = new BlockPos(cell.x(), cell.y(), cell.z());
|
||||
BlockState block = level.getBlockState(p);
|
||||
// Wall lichen can light the excavated chamber without obstructing its dry access.
|
||||
helper.assertTrue(natural(p) > 0 && (block.isAir() || block.is(Blocks.GLOW_LICHEN))
|
||||
&& block.getCollisionShape(level, p).isEmpty() && block.getFluidState().isEmpty(),
|
||||
"The lava pocket must keep its excavated two-block approach and chamber accessible: " + p + " = " + block);
|
||||
}
|
||||
}
|
||||
|
||||
void verifySpring(PopulationHydrology.Spring spring) {
|
||||
var plan = waterPlanAt(spring.outlet().x(), spring.outlet().z());
|
||||
var channel = plan == null ? null : plan.cellAt(spring.outlet().x(), spring.outlet().z());
|
||||
if (channel != null && plan.isRiverCell(channel) && spring.outlet().y() > channel.waterY()
|
||||
&& spring.outlet().y() <= channel.carveTop()) {
|
||||
int expectedDrop = spring.source().y() - channel.waterY();
|
||||
helper.assertTrue(expectedDrop >= 5, "A river inlet must start above its retained channel");
|
||||
Set<BlockPos> destination = new HashSet<>();
|
||||
for (var cell : plan.cells()) if (cell.featureId() == channel.featureId() && cell.hasWater())
|
||||
destination.add(new BlockPos(cell.x(), cell.waterY(), cell.z()));
|
||||
helper.assertTrue(connectedDescent(pos(spring.source()), pos(spring.outlet()), destination) >= expectedDrop,
|
||||
"The translated inlet must actually connect downhill into its river");
|
||||
} else {
|
||||
int predicted = spring.source().y() - spring.flowPath().stream().mapToInt(PopulationHydrology.Position::y).min().orElse(spring.source().y());
|
||||
helper.assertTrue(connectedDescent(pos(spring.source()), pos(spring.outlet()), null) >= Math.max(8, Math.min(16, predicted)),
|
||||
"An independent spring must actually descend through vanilla ticks: " + spring.source());
|
||||
}
|
||||
}
|
||||
|
||||
void verifyPostProcessingReentry() {
|
||||
var outlets = new LinkedHashSet<BlockPos>();
|
||||
if (!springs.isEmpty()) outlets.add(pos(springs.getFirst().outlet()));
|
||||
if (!spills.isEmpty()) outlets.add(pos(spills.getFirst().outlet()));
|
||||
if (!lavaFalls.isEmpty()) outlets.add(lavaPos(lavaFalls.getFirst().outlet()));
|
||||
for (BlockPos outlet : outlets) {
|
||||
LevelChunk chunk = chunks.get(ChunkPos.pack(outlet));
|
||||
helper.assertTrue(chunk != null && Arrays.stream(chunk.getPostProcessing()).allMatch(m -> m == null || m.isEmpty()),
|
||||
"Re-entry uses a finished inspected chunk with consumed generation markers");
|
||||
helper.assertTrue(PopulationSpringOutlets.capturePending(level, chunk).isEmpty(),
|
||||
"An already generated chunk has no pending population outlet work");
|
||||
BlockState original = level.getBlockState(outlet);
|
||||
BlockState witness = Blocks.GLOW_LICHEN.defaultBlockState().setValue(MultifaceBlock.getFaceProperty(Direction.UP), true);
|
||||
try {
|
||||
level.setBlock(outlet, witness, 2);
|
||||
chunk.postProcessGeneration(level);
|
||||
helper.assertTrue(level.getBlockState(outlet).equals(witness),
|
||||
"Post-processing re-entry must preserve a later construction at a completed outlet: " + outlet);
|
||||
} finally { level.setBlock(outlet, original, 2); }
|
||||
helper.assertTrue(level.getBlockState(outlet).equals(original), "Restore the development-world witness exactly");
|
||||
reentryWitnesses++;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isFluid(BlockPos p, boolean lava) {
|
||||
return lava ? level.getFluidState(p).is(FluidTags.LAVA) : level.getFluidState(p).is(FluidTags.WATER);
|
||||
}
|
||||
|
||||
void verifyWater() {
|
||||
for (var entry : naturalLogs.entrySet()) {
|
||||
BlockPos p = entry.getKey();
|
||||
helper.assertTrue(level.getBlockState(p).equals(entry.getValue()), "A recorded natural log must retain its exact state");
|
||||
helper.assertTrue(level.getBlockState(p.below()).isCollisionShapeFullBlock(level, p.below())
|
||||
|| level.getFluidState(p.below()).is(FluidTags.WATER) && level.getFluidState(p.below()).isSource(),
|
||||
"A horizontal log must retain its original bed or source water underneath");
|
||||
helper.assertTrue(Arrays.stream(HORIZONTAL).map(p::relative).anyMatch(side -> expectedWater.contains(side)
|
||||
&& level.getFluidState(side).is(FluidTags.WATER) && level.getFluidState(side).isSource()),
|
||||
"A horizontal log must remain beside actual retained water");
|
||||
}
|
||||
for(var p:expectedWater) helper.assertTrue(level.getFluidState(p).is(FluidTags.WATER)
|
||||
&& level.getFluidState(p).isSource(),"Retained water must survive generation and vanilla fluid ticks: "+p+"="+level.getBlockState(p));
|
||||
}
|
||||
|
||||
int connectedDescent(BlockPos source,BlockPos outlet,Set<BlockPos> destination) {
|
||||
return connectedDescent(source, outlet, destination, false);
|
||||
}
|
||||
|
||||
int connectedDescent(BlockPos source,BlockPos outlet,Set<BlockPos> destination, boolean lava) {
|
||||
helper.assertTrue(source.distManhattan(outlet)==1,"A natural outlet must adjoin its source");
|
||||
helper.assertTrue(isFluid(outlet, lava),"The production post-processing must leave a flowing outlet: "+outlet);
|
||||
var seen=new HashSet<BlockPos>(); var queue=new ArrayDeque<BlockPos>();
|
||||
seen.add(outlet);queue.add(outlet);int drop=0;boolean reached=destination==null;
|
||||
while(!queue.isEmpty() && seen.size()<150000) {
|
||||
var at=queue.removeFirst(); drop=Math.max(drop,source.getY()-at.getY());
|
||||
if(destination!=null && destination.contains(at)) reached=true;
|
||||
for(var direction:new Direction[]{Direction.DOWN,Direction.NORTH,Direction.SOUTH,Direction.EAST,Direction.WEST}) {
|
||||
var next=at.relative(direction);
|
||||
if(next.getY()<0 || !chunks.containsKey(ChunkPos.pack(next)) || seen.contains(next))continue;
|
||||
if(isFluid(next, lava)) {seen.add(next);queue.addLast(next);}
|
||||
}
|
||||
}
|
||||
helper.assertTrue(queue.isEmpty(), "The finite flow inspection must exhaust its search budget");
|
||||
helper.assertTrue(reached,"The actual downhill water component must reach its declared receiving basin");
|
||||
flowReports.add(Map.of("source", source, "outlet", outlet, "fluid", lava ? "lava" : "water",
|
||||
"actual_descending_blocks", seen.size(), "actual_drop", drop, "reaches_destination", reached));
|
||||
return drop;
|
||||
}
|
||||
|
||||
void export(boolean passed,long elapsed) throws IOException {
|
||||
Path output=Path.of("diagnostics","population-"+capacity.players()+"-seed-"+level.getSeed()+"-engine.json");
|
||||
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(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);
|
||||
report.put("selected_sediment_columns", selectedCells.size());
|
||||
report.put("natural_horizontal_logs", naturalLogs.size());
|
||||
report.put("retained_lava_sources", expectedLava.size()); report.put("tested_lava_falls", lavaFalls.size());
|
||||
report.put("flows", flowReports); report.put("postprocessing_reentry_witnesses", reentryWitnesses);
|
||||
report.put("scope","Selected basins, terraces, springs and ecology patches in explicit regions; no whole-island inventory or multiplayer load test");
|
||||
report.put("passed",passed);Files.writeString(output,json(report));
|
||||
if(passed) SanctuaryMod.LOGGER.info("Capacity {} seed {} passed: {} FULL chunks, {} retained water blocks, {} fluid ticks",
|
||||
capacity.players(),level.getSeed(),chunks.size(),expectedWater.size(),elapsed);
|
||||
}
|
||||
void cleanup() {
|
||||
if(closed)return;closed=true;
|
||||
for(long key:forced) {var p=ChunkPos.unpack(key);level.setChunkForced(p.x(),p.z(),false);}
|
||||
}
|
||||
}
|
||||
|
||||
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());}
|
||||
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,"The inspected chunk must be fully generated");return(LevelChunk)chunk;
|
||||
}
|
||||
private static void request(Set<Long> requested,int x,int z,int halo) {
|
||||
for(int dx=-halo;dx<=halo;dx++)for(int dz=-halo;dz<=halo;dz++)requested.add(ChunkPos.pack(x+dx,z+dz));
|
||||
}
|
||||
private static void request(Set<Long> requested,PopulationHydrology.FlowBounds b) {
|
||||
for(int x=(b.minX()>>4)-1;x<=(b.maxX()>>4)+1;x++)for(int z=(b.minZ()>>4)-1;z<=(b.maxZ()>>4)+1;z++)requested.add(ChunkPos.pack(x,z));
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.IslandCapacity;
|
||||
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.tags.WorldPresetTags;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.WorldDimensions;
|
||||
import net.minecraft.world.level.levelgen.WorldGenSettings;
|
||||
import net.minecraft.world.level.levelgen.WorldOptions;
|
||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||
|
||||
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;
|
||||
|
||||
/** Uses the real 26.3 saved-data writer and reader; never opens the user's saves. */
|
||||
final class PopulationPersistenceDiagnostics {
|
||||
static void verify(GameTestHelper helper) throws IOException {
|
||||
var level = helper.getLevel();
|
||||
var access = level.registryAccess();
|
||||
var presets = access.lookupOrThrow(Registries.WORLD_PRESET);
|
||||
var standard = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
|
||||
SanctuaryMod.id("sanctuary"))).value();
|
||||
helper.assertTrue(standard.createWorldDimensions().overworld() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified && unified.initialDiameter() == 724,
|
||||
"The public Sanctuary preset defaults to the unified medium geometry");
|
||||
var visible = presets.getOrThrow(WorldPresetTags.NORMAL).stream().map(holder -> holder.getRegisteredName())
|
||||
.filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet());
|
||||
helper.assertTrue(visible.equals(Set.of("sanctuary:sanctuary")),
|
||||
"Only Sanctuary is public; saved historical presets remain registered");
|
||||
helper.assertTrue(presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("expansion_lab")))
|
||||
.value().createWorldDimensions().overworld() instanceof fr.koka.sanctuary.expansion.ExpansionChunkGenerator,
|
||||
"The laboratory must use its own codec, without changing the existing presets");
|
||||
var oldDensity = (PopulationIslandDensity) access.lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_20"))).value();
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, access);
|
||||
var serialized = PopulationIslandDensity.CODEC.codec().encodeStart(ops, oldDensity).getOrThrow().getAsJsonObject();
|
||||
serialized.remove("players");
|
||||
helper.assertTrue(PopulationIslandDensity.CODEC.codec().parse(ops, serialized).getOrThrow().capacity() == IslandCapacity.TWENTY,
|
||||
"An omitted alpha10 density field must keep its historical default, independently of the new world selector");
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
Path testRoot = Path.of("diagnostics", "population-persistence-seed-" + level.getSeed()).toAbsolutePath();
|
||||
Files.createDirectories(testRoot);
|
||||
LevelStorageSource storage = LevelStorageSource.createDefault(testRoot);
|
||||
for (int players : new int[]{5, 10, 20, 100}) {
|
||||
String presetId = "sanctuary_" + players;
|
||||
WorldDimensions dimensions = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
|
||||
SanctuaryMod.id(presetId))).value().createWorldDimensions();
|
||||
helper.assertTrue(IslandCapacity.forGenerator(dimensions.overworld()) == IslandCapacity.fromPlayers(players),
|
||||
"The selectable world type must resolve its declared capacity");
|
||||
var original = new WorldGenSettings(new WorldOptions(level.getSeed(), true, false), dimensions);
|
||||
String name = "players-" + players;
|
||||
try (var save = storage.createAccess(name)) {
|
||||
LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath(name), original);
|
||||
var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow();
|
||||
var generator = (NoiseBasedChunkGenerator) restored.dimensions().overworld();
|
||||
helper.assertTrue(IslandCapacity.forGenerator(generator) == IslandCapacity.fromPlayers(players),
|
||||
"Saved generator settings must preserve the chosen capacity after reading the world from disk");
|
||||
helper.assertTrue(generator.generatorSettings().getRegisteredName().equals("sanctuary:population_" + players),
|
||||
"The saved world must retain its explicit versioned settings key");
|
||||
helper.assertTrue(restored.options().seed() == level.getSeed(), "The seed must survive storage unchanged");
|
||||
results.add(Map.of("players", players, "preset", "sanctuary:" + presetId,
|
||||
"restored_settings", generator.generatorSettings().getRegisteredName(), "disk_round_trip", true));
|
||||
}
|
||||
}
|
||||
var old = new NoiseBasedChunkGenerator(standard.createWorldDimensions().overworld().getBiomeSource(),
|
||||
access.lookupOrThrow(Registries.NOISE_SETTINGS).getOrThrow(SanctuarySpawn.CAVERN_SETTINGS));
|
||||
var savedOld = new WorldGenSettings(new WorldOptions(level.getSeed(), true, false),
|
||||
standard.createWorldDimensions().replaceOverworldGenerator(access, old));
|
||||
try (var save = storage.createAccess("legacy-alpha9")) {
|
||||
LevelStorageSource.writeWorldGenSettings(access, storage.getLevelPath("legacy-alpha9"), savedOld);
|
||||
var restored = LevelStorageSource.readExistingSavedData(save, access, WorldGenSettings.TYPE).getOrThrow();
|
||||
var generator = (NoiseBasedChunkGenerator) restored.dimensions().overworld();
|
||||
helper.assertTrue(generator.generatorSettings().is(SanctuarySpawn.CAVERN_SETTINGS)
|
||||
&& IslandCapacity.forGenerator(generator) == null,
|
||||
"An existing alpha.9 settings reference must not acquire the new default capacity");
|
||||
}
|
||||
Files.writeString(testRoot.resolve("round-trip.json"), new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
|
||||
"seed", level.getSeed(), "default_players", 10, "profiles", results,
|
||||
"legacy_settings_preserved", "sanctuary:sanctuary_cavern", "visible_presets", visible,
|
||||
"omitted_density_capacity_preserved", 20, "passed", true)) + "\n");
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -4,6 +4,7 @@ import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.IslandShape;
|
||||
import fr.koka.sanctuary.worldgen.IslandCapacity;
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.SharedConstants;
|
||||
@@ -58,7 +59,9 @@ public final class ResourceSurveyDiagnostics {
|
||||
long initialGameTime = level.getGameTime();
|
||||
int centreX = initialSpawn.pos().getX() >> 4;
|
||||
int centreZ = initialSpawn.pos().getZ() >> 4;
|
||||
int envelopeRadius = IslandShape.TERRAIN_LIMIT + DECORATION_MARGIN;
|
||||
IslandCapacity capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator());
|
||||
int terrainRadius = capacity == null ? IslandShape.TERRAIN_LIMIT : capacity.terrainLimit();
|
||||
int envelopeRadius = terrainRadius + DECORATION_MARGIN;
|
||||
int minimumIslandChunk = Math.floorDiv(-envelopeRadius, 16);
|
||||
int maximumIslandChunk = Math.floorDiv(envelopeRadius - 1, 16);
|
||||
int minimumX = mode.equals("island") ? minimumIslandChunk : centreX - 1;
|
||||
@@ -135,7 +138,7 @@ public final class ResourceSurveyDiagnostics {
|
||||
report.put("mode", mode);
|
||||
report.put("seed", level.getSeed());
|
||||
report.put("minecraft_version", SharedConstants.getCurrentVersion().name());
|
||||
report.put("generator", Map.of("preset", "sanctuary:sanctuary", "noise_settings", settingsHolder.getRegisteredName(),
|
||||
report.put("generator", Map.of("preset", capacity == null || capacity.players() == 20 ? "sanctuary:sanctuary" : "sanctuary:sanctuary_" + capacity.players(), "noise_settings", settingsHolder.getRegisteredName(),
|
||||
"mod_version", modVersion, "version", "sanctuary@" + modVersion,
|
||||
"noise_min_y", settings.noiseSettings().minY(),
|
||||
"noise_max_y_exclusive", settings.noiseSettings().minY() + settings.noiseSettings().height()));
|
||||
@@ -149,7 +152,7 @@ public final class ResourceSurveyDiagnostics {
|
||||
"min_y_inclusive", level.getMinY(), "max_y_exclusive", level.getMinY() + level.getHeight(),
|
||||
"min_chunk_x_inclusive", minimumX, "max_chunk_x_inclusive", maximumX,
|
||||
"min_chunk_z_inclusive", minimumZ, "max_chunk_z_inclusive", maximumZ));
|
||||
report.put("declared_island_envelope", Map.of("terrain_radius_blocks", IslandShape.TERRAIN_LIMIT,
|
||||
report.put("declared_island_envelope", Map.of("terrain_radius_blocks", terrainRadius,
|
||||
"decoration_margin_blocks", DECORATION_MARGIN, "bounding_rectangle_radius_blocks", envelopeRadius));
|
||||
report.put("completeness", Map.of("requested_chunks", expectedChunks, "inspected_full_chunks", chunks.size(),
|
||||
"complete_within_requested_bounds", true, "covers_declared_island_envelope", mode.equals("island"),
|
||||
@@ -172,7 +175,7 @@ public final class ResourceSurveyDiagnostics {
|
||||
"scope", "snapshot after FULL generation; no resource quotas or terrain correction applied"));
|
||||
Path directory = Path.of("diagnostics");
|
||||
Files.createDirectories(directory);
|
||||
Path output = directory.resolve("resource-survey-seed-" + level.getSeed() + "-" + mode + ".json");
|
||||
Path output = directory.resolve("resource-survey-" + (capacity == null ? "legacy" : "players-" + capacity.players()) + "-seed-" + level.getSeed() + "-" + mode + ".json");
|
||||
Files.writeString(output, JSON.toJson(report) + "\n");
|
||||
SanctuaryMod.LOGGER.info("Resource survey {} complete: {} FULL chunks, {} non-air blocks; {}",
|
||||
mode, chunks.size(), totalCells - air, output.toAbsolutePath());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+351
@@ -0,0 +1,351 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.city.LostCityRenderer;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer17;
|
||||
import fr.koka.sanctuary.worldgen.ruins.RuinsStructures17;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
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.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Opt-in native proof confined to the new generation17 starter island in a disposable development world. */
|
||||
public final class Ruins17WorldGameTests {
|
||||
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
|
||||
|
||||
@GameTest(maxTicks = 12000)
|
||||
public void forgottenNetworksPersistNatively(GameTestHelper helper) {
|
||||
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
|
||||
"The forgotten-networks proof requires a Sanctuary generator");
|
||||
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(generator.rootGeneration() == 17, "Use a fresh generation17 test world; do not convert old saves");
|
||||
new Scenario(helper, generator, Boolean.getBoolean("sanctuary.test.expansionReload")).advance();
|
||||
}
|
||||
|
||||
private static final class Scenario {
|
||||
final GameTestHelper helper;
|
||||
final ServerLevel level;
|
||||
final SanctuaryChunkGenerator generator;
|
||||
final RuinsPlan17 plan;
|
||||
final boolean reload;
|
||||
final Map<String, Object> report = new LinkedHashMap<>();
|
||||
final List<ChunkPos> chunks = new ArrayList<>();
|
||||
final List<StructureStart> starts = new ArrayList<>();
|
||||
JsonObject previous;
|
||||
BlockPos gold;
|
||||
int nextChunk;
|
||||
boolean prepared;
|
||||
|
||||
Scenario(GameTestHelper helper, SanctuaryChunkGenerator generator, boolean reload) {
|
||||
this.helper = helper; this.level = helper.getLevel(); this.generator = generator; this.reload = reload;
|
||||
this.plan = generator.ruinsPlan17();
|
||||
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
|
||||
report.put("process_id", ProcessHandle.current().pid()); report.put("world_directory", world(level).toString());
|
||||
report.put("experimental_structures", generator.experimentalStructures());
|
||||
report.put("structures_enabled", level.structureManager().shouldGenerateStructures());
|
||||
report.put("scope", "Native starts, references, room blocks, player-sized routes and unopened loot on the starter island only. "
|
||||
+ "No expansion is created and no personal save is opened. A single outer-wall gold edit in this disposable world "
|
||||
+ "is saved and checked in a second JVM. Open hatches are restored after traversal; no free-fluid simulation is claimed.");
|
||||
}
|
||||
void advance() {
|
||||
try {
|
||||
if (!prepared) {
|
||||
prepare(); prepared = true;
|
||||
if (plan.isEmpty()) { finishDisabled(); return; }
|
||||
}
|
||||
if (nextChunk < chunks.size()) {
|
||||
var pos = chunks.get(nextChunk++);
|
||||
var loaded = level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(loaded instanceof LevelChunk, "Every room footprint must reach native FULL: " + pos);
|
||||
if (nextChunk % 10 == 0) SanctuaryMod.LOGGER.info("Forgotten networks17: loaded {}/{} footprint chunks", nextChunk, chunks.size());
|
||||
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
|
||||
}
|
||||
verifyPlaced();
|
||||
report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold),
|
||||
"Gameplay witness must replace a solid outer wall away from every doorway");
|
||||
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
|
||||
report.put("gold_witness", coords(gold));
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native structures, deferred loot and the gold edit");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
} else {
|
||||
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the existing wall edit");
|
||||
report.put("gold_witness", coords(gold)); report.put("gold_witness_preserved", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
} catch (Exception failure) {
|
||||
report.put("passed", false); report.put("failure", failure.toString());
|
||||
try { write(level, report, reload); } catch (IOException ignored) { }
|
||||
throw new IllegalStateException("Forgotten networks17 native " + (reload ? "reload" : "creation") + " failed", failure);
|
||||
}
|
||||
}
|
||||
void prepare() throws IOException {
|
||||
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
|
||||
var encoded = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
|
||||
var decoded = ChunkGenerator.CODEC.parse(ops, encoded).getOrThrow();
|
||||
helper.assertTrue(decoded instanceof SanctuaryChunkGenerator copy && copy.rootGeneration() == 17
|
||||
&& copy.experimentalStructures() == generator.experimentalStructures(), "The generation17 codec must preserve its experimental switch");
|
||||
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan)); report.put("planning_samples", plan.samples());
|
||||
helper.assertTrue(plan.samples() <= RuinsPlan17.MAX_SAMPLES, "Forgotten-network planning exceeded its budget");
|
||||
if (reload) {
|
||||
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
|
||||
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(),
|
||||
"Reload requires the successful, flushed creation proof");
|
||||
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must run in a separate JVM");
|
||||
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed()
|
||||
&& previous.get("size").getAsInt() == generator.initialDiameter()
|
||||
&& previous.get("world_directory").getAsString().equals(world(level).toString()), "Reload used another world, seed or size");
|
||||
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)),
|
||||
"Saved switch or planned generation17 room identities changed on reload");
|
||||
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
|
||||
}
|
||||
boolean enabled = generator.experimentalStructures() && level.structureManager().shouldGenerateStructures();
|
||||
if (!enabled) {
|
||||
helper.assertTrue(plan.isEmpty(), "Experimental=false or generateStructures=false must publish no forgotten network");
|
||||
return;
|
||||
}
|
||||
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no experimental network");
|
||||
var families = EnumSet.noneOf(RuinsPlan17.Family.class);
|
||||
plan.networks().forEach(n -> families.add(n.family()));
|
||||
report.put("families", families.stream().map(Enum::name).toList()); report.put("networks", plan.networks().size());
|
||||
if (level.getSeed() == 0) helper.assertTrue(families.equals(EnumSet.allOf(RuinsPlan17.Family.class)),
|
||||
"Seed0 reference must demonstrate all three experimental families; found " + families);
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var origins = new HashSet<Long>(); var uniqueChunks = new HashSet<ChunkPos>();
|
||||
for (var network : plan.networks()) {
|
||||
long origin = new ChunkPos(network.startChunkX(), network.startChunkZ()).pack();
|
||||
if (!origins.add(origin)) continue;
|
||||
var chunk = level.getChunk(network.startChunkX(), network.startChunkZ(), ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start != null && start.isValid(), "Accepted experimental network has no native start: " + network.id());
|
||||
starts.add(start);
|
||||
for (var piece : start.getPieces()) {
|
||||
helper.assertTrue(piece instanceof RuinsPiece17, "Forgotten network loaded a foreign or historical piece type");
|
||||
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
|
||||
}
|
||||
}
|
||||
helper.assertTrue(uniqueChunks.size() <= 225, "The bounded root-only proof unexpectedly covers more than225 chunks");
|
||||
chunks.addAll(uniqueChunks.stream().sorted(java.util.Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
|
||||
}
|
||||
void finishDisabled() throws IOException {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var chunk = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true);
|
||||
var start = chunk.getStartForStructure(structure.value());
|
||||
helper.assertTrue(start == null || !start.isValid(), "Disabled experimental generation emitted a native start");
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located == null, "Disabled experimental structures must not be located or prepared");
|
||||
report.put("empty_plan_verified", true); report.put("passed", true);
|
||||
if (!reload) {
|
||||
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save the disabled experiment setting");
|
||||
report.put("world_flushed_before_shutdown", true);
|
||||
}
|
||||
write(level, report, reload); helper.succeed();
|
||||
}
|
||||
void verifyPlaced() {
|
||||
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
|
||||
var context = StructurePieceSerializationContext.fromLevel(level);
|
||||
var sites = new ArrayList<Map<String, Object>>();
|
||||
int blocks = 0, supports = 0, chests = 0, pieces = 0;
|
||||
for (var start : starts) {
|
||||
var origin = start.getChunkPos(); var saved = start.createTag(context, origin);
|
||||
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
|
||||
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "The native start changed on an NBT roundtrip");
|
||||
var expectedRooms = plan.networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z())
|
||||
.flatMap(n -> n.rooms().stream()).toList();
|
||||
var actualRooms = start.getPieces().stream().map(RuinsPiece17.class::cast).map(RuinsPiece17::room).toList();
|
||||
helper.assertTrue(actualRooms.size() == expectedRooms.size() && new HashSet<>(actualRooms).equals(new HashSet<>(expectedRooms)),
|
||||
"Native start did not persist every planned room exactly once");
|
||||
String nativeHash = hash(saved.toString());
|
||||
if (reload) {
|
||||
JsonObject prior = null;
|
||||
for (var value : previous.getAsJsonArray("starts")) {
|
||||
var site = value.getAsJsonObject(); var at = site.getAsJsonArray("start_chunk");
|
||||
if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = site;
|
||||
}
|
||||
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash),
|
||||
"Reload altered native room parameters, supports or reference count");
|
||||
}
|
||||
var loot = new ArrayList<Map<String, Object>>();
|
||||
for (var piece : start.getPieces()) {
|
||||
var roomPiece = (RuinsPiece17) piece; var room = roomPiece.room(); pieces++;
|
||||
piece.getBoundingBox().intersectingChunks().forEach(at -> {
|
||||
var full = level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true);
|
||||
helper.assertTrue(full.getReferencesForStructure(structure.value()).contains(origin.pack()), "A saved FULL chunk lost the native structure reference");
|
||||
});
|
||||
var roomTag = roomPiece.createTag(context);
|
||||
var roundtrip = new RuinsPiece17(context, roomTag);
|
||||
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(roomTag), "The native room failed its exact saved-field roundtrip");
|
||||
var corrupt = roomTag.copy(); var payload = corrupt.getCompound("SanctuaryRoom").orElseThrow(); payload.putInt("Schema", 99);
|
||||
boolean rejected = false;
|
||||
try { new RuinsPiece17(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
|
||||
helper.assertTrue(rejected, "An unknown generation17 piece schema must be refused");
|
||||
var expected = rendered(room); blocks += expected.blocks().size();
|
||||
for (var entry : expected.blocks().entrySet()) {
|
||||
var at = entry.getKey(); var wanted = entry.getValue();
|
||||
helper.assertTrue(RuinsPiece17.bounds(room).isInside(at), "Renderer escaped its saved room/support bounds");
|
||||
if (reload && at.equals(gold)) continue;
|
||||
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()),
|
||||
"Native clipping or later decoration changed " + room.kind() + " at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
|
||||
helper.assertTrue(!level.getBlockState(at).is(Blocks.SPAWNER), "Experimental architecture must not create a spawner");
|
||||
}
|
||||
for (var support : room.supports()) {
|
||||
supports++;
|
||||
var below = new BlockPos(support.x(), support.minY() - 1, support.z());
|
||||
helper.assertTrue(level.getBlockState(below).isSolid() && level.getFluidState(below).isEmpty(), "Room footing has lost its native rock anchor");
|
||||
}
|
||||
for (var chest : expected.chests()) {
|
||||
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred native chest block entity is missing");
|
||||
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
|
||||
helper.assertTrue(chest.table().equals(entity.getLootTable()) && entity.getLootTableSeed() == chest.seed(),
|
||||
"Native chest loot must remain deferred with its exact table and seed");
|
||||
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
|
||||
}
|
||||
if (!reload && gold == null && room.kind() != RuinsPlan17.Kind.ACCESS && room.kind() != RuinsPlan17.Kind.CORRIDOR)
|
||||
gold = new BlockPos(room.minX(), room.floorY() + 1, room.minZ() + 1);
|
||||
}
|
||||
sites.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash,
|
||||
"pieces", start.getPieces().size(), "unopened_chests", loot));
|
||||
}
|
||||
var traversal = new ArrayList<Map<String, Object>>();
|
||||
for (var network : plan.networks()) traversal.add(verifyAccess(helper, network));
|
||||
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
|
||||
helper.assertTrue(located != null && located.getSecond().is(RuinsStructures17.KEY), "Native locate cannot find a saved forgotten network");
|
||||
helper.assertTrue(plan.networks().stream().flatMap(n -> n.rooms().stream()).filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS)
|
||||
.anyMatch(r -> RuinsPiece17.locator(r).equals(located.getFirst())), "Locate must lead to a surface entrance rather than an underground floor");
|
||||
report.put("starts", sites); report.put("native_pieces", pieces); report.put("native_blocks_checked", blocks);
|
||||
report.put("full_chunks_checked", chunks.size()); report.put("rock_supports_checked", supports);
|
||||
report.put("unopened_deferred_chests", chests); report.put("native_routes", traversal); report.put("locate", coords(located.getFirst()));
|
||||
}
|
||||
}
|
||||
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
|
||||
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
|
||||
private static Rendered rendered(RuinsPlan17.Room room) {
|
||||
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
|
||||
RuinsRenderer17.render(room, new LostCityRenderer.BlockWriter() {
|
||||
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(at(room, x, y, z), state); }
|
||||
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
|
||||
var position = at(room, x, y, z); blocks.put(position, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(position, table, RandomSource.create(seed).nextLong()));
|
||||
}
|
||||
});
|
||||
return new Rendered(blocks, chests);
|
||||
}
|
||||
private static Map<String, Object> verifyAccess(GameTestHelper helper, RuinsPlan17.Network network) {
|
||||
var level = helper.getLevel(); var access = network.rooms().stream().filter(r -> r.kind() == RuinsPlan17.Kind.ACCESS).findFirst().orElseThrow();
|
||||
var hatches = new HashMap<BlockPos, BlockState>();
|
||||
rendered(access).blocks().forEach((at, state) -> { if (state.is(Blocks.DARK_OAK_TRAPDOOR)) hatches.put(at, level.getBlockState(at)); });
|
||||
helper.assertTrue(hatches.size() == 1, "Each network must have one usable wooden surface hatch");
|
||||
hatches.forEach((at, state) -> {
|
||||
helper.assertTrue(state.is(Blocks.DARK_OAK_TRAPDOOR) && state.canSurvive(level, at), "Surface hatch is missing its native support");
|
||||
level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2);
|
||||
});
|
||||
try {
|
||||
Predicate<BlockPos> inside = pos -> network.rooms().stream().anyMatch(r -> r.contains(pos.getX(), pos.getY(), pos.getZ()));
|
||||
var start = RuinsPiece17.locator(access); var reached = walk(helper, start, inside);
|
||||
int targets = 0;
|
||||
for (var room : network.rooms()) for (var target : RuinsRenderer17.targets(room)) {
|
||||
var point = at(room, target.x(), target.y(), target.z());
|
||||
helper.assertTrue(reached.contains(point), "Native network " + network.id() + " cannot reach " + room.kind() + "/" + target.name() + " at " + point);
|
||||
targets++;
|
||||
}
|
||||
for (var hatch : hatches.keySet()) helper.assertTrue(reached.contains(hatch), "Surface approach cannot enter its ladder shaft");
|
||||
return Map.of("id", network.id(), "family", network.family().name(), "rooms", network.rooms().size(),
|
||||
"entrance", coords(start), "walkable_targets", targets, "standing_cells", reached.size());
|
||||
} finally { hatches.forEach((at, state) -> level.setBlock(at, state, 2)); }
|
||||
}
|
||||
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
|
||||
var level = helper.getLevel(); helper.assertTrue(inside.test(start) && standable(level, start), "Blocked native surface approach: " + start);
|
||||
var seen = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>(); seen.add(start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
var pos = queue.removeFirst();
|
||||
for (var direction : HORIZONTAL) for (int dy = -1; dy <= 1; dy++) {
|
||||
var next = pos.relative(direction).offset(0, dy, 0);
|
||||
if (!inside.test(next) || dy > 0 && !clearBody(level, pos.above())) continue;
|
||||
if (standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
for (int dy : new int[] {-1, 1}) {
|
||||
var next = pos.offset(0, dy, 0); if (!inside.test(next)) continue;
|
||||
if ((level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER)
|
||||
|| level.getBlockState(pos.below()).is(Blocks.LADDER)) && standable(level, next) && seen.add(next)) queue.add(next);
|
||||
}
|
||||
helper.assertTrue(seen.size() <= 50_000, "Native traversal exceeded its room bounds");
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
private static boolean standable(ServerLevel level, BlockPos pos) {
|
||||
if (!clearBody(level, pos)) return false;
|
||||
if (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(pos.below()).is(Blocks.LADDER)) return true;
|
||||
var below = pos.below();
|
||||
return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
|
||||
.anyMatch(b -> b.maxY >= .5 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
|
||||
}
|
||||
private static boolean clearBody(ServerLevel level, BlockPos pos) {
|
||||
var body = new AABB(pos.getX() + .2, pos.getY(), pos.getZ() + .2, pos.getX() + .8, pos.getY() + 1.8, pos.getZ() + .8);
|
||||
for (var at : new BlockPos[] {pos, pos.above()}) {
|
||||
if (!level.getFluidState(at).isEmpty()) return false;
|
||||
for (var shape : level.getBlockState(at).getCollisionShape(level, at).toAabbs())
|
||||
if (shape.move(at.getX(), at.getY(), at.getZ()).intersects(body)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static BlockPos at(RuinsPlan17.Room room, int x, int y, int z) { return new BlockPos(room.minX() + x, room.minY() + y, room.minZ() + z); }
|
||||
private static List<Integer> coords(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
|
||||
private static BlockPos readPos(com.google.gson.JsonArray value) { return new BlockPos(value.get(0).getAsInt(), value.get(1).getAsInt(), value.get(2).getAsInt()); }
|
||||
private static Path world(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); }
|
||||
private static Path proof(ServerLevel level) { return world(level).resolve("ruins17-create-proof.json"); }
|
||||
private static String planHash(RuinsPlan17 plan) { return hash(plan.networks().toString()); }
|
||||
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
|
||||
var path = Path.of("diagnostics", "ruins17-" + (reload ? "reload" : "create") + ".json"); Files.createDirectories(path.getParent());
|
||||
var value = new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n";
|
||||
Files.writeString(path, value);
|
||||
if (!reload) Files.writeString(proof(level), value);
|
||||
}
|
||||
private static String hash(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
+108
-8
@@ -1,6 +1,15 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.SanctuarySpawn;
|
||||
import fr.koka.sanctuary.worldgen.IslandCapacity;
|
||||
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import fr.koka.sanctuary.worldgen.IslandShape;
|
||||
import fr.koka.sanctuary.worldgen.MainIslandDensity;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
@@ -23,6 +32,43 @@ import net.minecraft.world.level.storage.LevelData;
|
||||
public final class SanctuaryWorldGameTests {
|
||||
public static LevelData.RespawnData initialSpawn;
|
||||
|
||||
@GameTest(maxTicks = 3000)
|
||||
public void optimizedDensityPreservesSavedTerrain(GameTestHelper helper) throws Exception {
|
||||
PopulationDensityEquivalenceDiagnostics.verify(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void populationChoiceSurvivesSavingTheWorld(GameTestHelper helper) throws java.io.IOException {
|
||||
PopulationPersistenceDiagnostics.verify(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 3000)
|
||||
public void generatedSurfaceWaterRemainsContained(GameTestHelper helper) throws java.io.IOException {
|
||||
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
if (PopulationHydrologyRuntime.enabled(generator)) PopulationDiagnostics.start(helper);
|
||||
else if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.start(helper);
|
||||
else if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.start(helper);
|
||||
else if (WoodlandHydrologyRuntime.enabled(generator)) WoodlandDiagnostics.start(helper);
|
||||
else if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.start(helper);
|
||||
else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.start(helper);
|
||||
else HydrologyDiagnostics.start(helper);
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void hydrologyDoesNotActivateForSavedLegacySettings(GameTestHelper helper) {
|
||||
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
if (PopulationHydrologyRuntime.enabled(generator)) PopulationDiagnostics.verifyLegacyIsolation(helper);
|
||||
else if (CavernHydrologyRuntime.enabled(generator)) CavernDiagnostics.verifyLegacyIsolation(helper);
|
||||
else if (RiftHydrologyRuntime.enabled(generator)) RiftDiagnostics.verifyLegacyIsolation(helper);
|
||||
else if (WoodlandHydrologyRuntime.enabled(generator)) WoodlandDiagnostics.verifyLegacyIsolation(helper);
|
||||
else if (LayeredHydrologyRuntime.enabled(generator)) LayeredHydrologyDiagnostics.verifyLegacyIsolation(helper);
|
||||
else if (NaturalHydrologyRuntime.enabled(generator)) NaturalHydrologyDiagnostics.verifyLegacyIsolation(helper);
|
||||
else HydrologyDiagnostics.verifyLegacyIsolation(helper);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void generatedIslandAndInitialSpawn(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
@@ -31,12 +77,14 @@ public final class SanctuaryWorldGameTests {
|
||||
|
||||
helper.assertTrue(initialSpawn != null, "Capture the world's initial spawn before the test grid moves it");
|
||||
BlockPos spawn = initialSpawn.pos();
|
||||
helper.assertTrue(Math.hypot((double) spawn.getX(), (double) spawn.getZ()) < IslandShape.TERRAIN_LIMIT,
|
||||
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
|
||||
IslandCapacity capacity = IslandCapacity.forGenerator(generator);
|
||||
int terrainLimit = capacity == null ? IslandShape.TERRAIN_LIMIT : capacity.terrainLimit();
|
||||
helper.assertTrue(Math.hypot((double) spawn.getX(), (double) spawn.getZ()) < terrainLimit,
|
||||
"Shared spawn must lie on the finite natural island: " + spawn);
|
||||
helper.assertTrue(spawn.getY() > 1 && spawn.getY() < level.getMaxY() - 1,
|
||||
"Shared spawn must be inside the dimension height");
|
||||
var settings = level.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value();
|
||||
var settings = generator.generatorSettings().value();
|
||||
float spawnFloorDensity = level.getChunkSource().randomState().sampleBlockValueUncached(
|
||||
settings.noiseRouter().finalDensity(), spawn.getX(), spawn.getY() - 1, spawn.getZ());
|
||||
helper.assertTrue(spawnFloorDensity > 0.0F,
|
||||
@@ -62,7 +110,9 @@ public final class SanctuaryWorldGameTests {
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void sculptOnlyRemovesTerrain(GameTestHelper helper) throws java.io.IOException {
|
||||
DensityDiagnostics.verifyAndExport(helper, initialSpawn);
|
||||
if (IslandCapacity.forGenerator(helper.getLevel().getChunkSource().getGenerator()) != null)
|
||||
PopulationDiagnostics.verifyDensity(helper);
|
||||
else DensityDiagnostics.verifyAndExport(helper, initialSpawn);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@@ -90,11 +140,57 @@ public final class SanctuaryWorldGameTests {
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void completedLowerMarginIsVoid(GameTestHelper helper) {
|
||||
if (IslandCapacity.forGenerator(helper.getLevel().getChunkSource().getGenerator()) != null) {
|
||||
PopulationDiagnostics.verifyLowerMargin(helper);
|
||||
helper.succeed();
|
||||
return;
|
||||
}
|
||||
for (int[] coordinate : new int[][]{{0, 0}, {-8, 0}, {8, 0}, {0, -8}, {0, 8}}) {
|
||||
ChunkAccess chunk = helper.getLevel().getChunkSource().getChunk(
|
||||
coordinate[0], coordinate[1], ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk != null && chunk.getSection(0).hasOnlyAir(),
|
||||
"The fully generated island must leave Y0..15 empty, without a floor cut at the dimension bound");
|
||||
helper.assertTrue(chunk != null, "The lower-margin test needs a fully generated chunk");
|
||||
if (chunk.getSection(0).hasOnlyAir()) continue;
|
||||
var generator = (NoiseBasedChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
|
||||
helper.assertTrue(NaturalHydrologyRuntime.enabled(generator) || LayeredHydrologyRuntime.enabled(generator)
|
||||
|| WoodlandHydrologyRuntime.enabled(generator) || RiftHydrologyRuntime.enabled(generator) || CavernHydrologyRuntime.enabled(generator),
|
||||
"The legacy island must leave its lower margin entirely empty");
|
||||
for (int x = chunk.getPos().getMinBlockX(); x <= chunk.getPos().getMaxBlockX(); x++) {
|
||||
for (int z = chunk.getPos().getMinBlockZ(); z <= chunk.getPos().getMaxBlockZ(); z++) {
|
||||
for (int y = 0; y < 16; y++) {
|
||||
BlockPos p = new BlockPos(x, y, z);
|
||||
var state = chunk.getBlockState(p);
|
||||
if (CavernHydrologyRuntime.enabled(generator)) {
|
||||
boolean water = state.getFluidState().is(FluidTags.WATER)
|
||||
&& CavernDiagnostics.isWithinSpringInspectionFootprint(CavernHydrologyRuntime.cavernPlan(helper.getLevel()), x, z);
|
||||
final int bx = x, bz = z;
|
||||
boolean lava = state.getFluidState().is(FluidTags.LAVA)
|
||||
&& CavernHydrologyRuntime.lavaPlan(helper.getLevel()).flowBounds().stream().anyMatch(b ->
|
||||
bx >= (Math.floorDiv(b.minX(),16) - 1) * 16 && bx <= (Math.floorDiv(b.maxX(),16) + 2) * 16 - 1
|
||||
&& bz >= (Math.floorDiv(b.minZ(),16) - 1) * 16 && bz <= (Math.floorDiv(b.maxZ(),16) + 2) * 16 - 1);
|
||||
helper.assertTrue(state.isAir() || water || lava, "Lower margin must remain void except declared fluid falls: " + p);
|
||||
continue;
|
||||
}
|
||||
if (RiftHydrologyRuntime.enabled(generator)) {
|
||||
boolean water = state.getFluidState().is(FluidTags.WATER)
|
||||
&& RiftDiagnostics.isWithinSpringInspectionFootprint(RiftHydrologyRuntime.riftPlan(helper.getLevel()), x, z);
|
||||
final int bx = x, bz = z;
|
||||
boolean lava = state.getFluidState().is(FluidTags.LAVA)
|
||||
&& RiftHydrologyRuntime.lavaPlan(helper.getLevel()).flowBounds().stream().anyMatch(b ->
|
||||
bx >= (Math.floorDiv(b.minX(),16) - 1) * 16 && bx <= (Math.floorDiv(b.maxX(),16) + 2) * 16 - 1
|
||||
&& bz >= (Math.floorDiv(b.minZ(),16) - 1) * 16 && bz <= (Math.floorDiv(b.maxZ(),16) + 2) * 16 - 1);
|
||||
helper.assertTrue(state.isAir() || water || lava, "Lower margin must remain void except declared fluid falls: " + p);
|
||||
continue;
|
||||
}
|
||||
helper.assertTrue(state.isAir() || state.getFluidState().is(FluidTags.WATER)
|
||||
&& (WoodlandHydrologyRuntime.enabled(generator)
|
||||
? WoodlandDiagnostics.isWithinSpringInspectionFootprint(WoodlandHydrologyRuntime.woodlandPlan(helper.getLevel()), x, z)
|
||||
: LayeredHydrologyRuntime.enabled(generator)
|
||||
? LayeredHydrologyDiagnostics.isWithinSpringInspectionFootprint(LayeredHydrologyRuntime.layeredPlan(helper.getLevel()), x, z)
|
||||
: NaturalHydrologyDiagnostics.isWithinSpringInspectionFootprint(NaturalHydrologyRuntime.naturalPlan(helper.getLevel()), x, z)),
|
||||
"The lower margin has no artificial floor; water must lie in the finite spring inspection footprint, with provenance checked by the full fluid diagnostic: " + p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
@@ -107,6 +203,11 @@ public final class SanctuaryWorldGameTests {
|
||||
|
||||
@GameTest(maxTicks = 200)
|
||||
public void completedExteriorChunksAreVoid(GameTestHelper helper) {
|
||||
if (IslandCapacity.forGenerator(helper.getLevel().getChunkSource().getGenerator()) != null) {
|
||||
PopulationDiagnostics.verifyExterior(helper);
|
||||
helper.succeed();
|
||||
return;
|
||||
}
|
||||
ServerLevel level = helper.getLevel();
|
||||
// First row is just beyond the declared terrain/decorations envelope. The last
|
||||
// samples catch the legacy bug where the infinite archipelago returned farther out.
|
||||
@@ -130,8 +231,7 @@ public final class SanctuaryWorldGameTests {
|
||||
public void compiledWorldDensityUsesTheSeed(GameTestHelper helper) {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var registries = level.registryAccess();
|
||||
NoiseGeneratorSettings settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(SanctuarySpawn.RAISED_SETTINGS).value();
|
||||
NoiseGeneratorSettings settings = ((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator()).generatorSettings().value();
|
||||
var noises = registries.lookupOrThrow(Registries.NOISE);
|
||||
RandomState first = RandomState.create(noises, 0L, settings);
|
||||
RandomState replay = RandomState.create(noises, 0L, settings);
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
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 historical = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v13"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 13 && !old.source().citiesEnabled(),
|
||||
"Historical alpha.13 codec retains its original biome source and generation");
|
||||
var historical14 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v14"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical14.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 14 && old.source().generation() == 14,
|
||||
"Historical alpha.14 keeps its own biome codec and city generator");
|
||||
var historical15 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v15"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical15.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 15 && old.source().generation() == 15,
|
||||
"Historical alpha.15 keeps its own biome codec and city generator");
|
||||
var historical16 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v16"))).value().createWorldDimensions();
|
||||
helper.assertTrue(historical16.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 16
|
||||
&& old.source().generation() == 16 && !old.experimentalStructures(), "Historical16 never gains experimental structures");
|
||||
var generator = (SanctuaryChunkGenerator) standard.overworld();
|
||||
helper.assertTrue(generator.rootGeneration() == 17 && generator.source().generation() == 17 && generator.experimentalStructures(), "The public preset uses generation17 with its new-world option");
|
||||
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 activeCodec = generator.rootGeneration() == 17 ? SanctuaryChunkGenerator.RUINS17_CODEC : generator.rootGeneration() == 16 ? SanctuaryChunkGenerator.CITY16_CODEC : generator.rootGeneration() == 15 ? SanctuaryChunkGenerator.CITY15_CODEC : generator.rootGeneration() == 14 ? SanctuaryChunkGenerator.CITY_CODEC : SanctuaryChunkGenerator.CODEC;
|
||||
var serialized = activeCodec.codec().encodeStart(ops, generator).getOrThrow().getAsJsonObject();
|
||||
serialized.remove("experimental_structures");
|
||||
helper.assertTrue(activeCodec.codec().parse(ops, serialized).getOrThrow().experimentalStructures(),
|
||||
"Generation17 omitted experimental option defaults to enabled");
|
||||
helper.assertTrue(!((SanctuaryChunkGenerator) historical16.overworld()).withExperimentalStructures(true).experimentalStructures(),
|
||||
"The new-world switch cannot activate experimental generation in a historical16 generator");
|
||||
serialized.remove("initial_diameter");
|
||||
helper.assertTrue(activeCodec.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}) for (boolean experimental : new boolean[]{false, true}) {
|
||||
var chosen = generator.withInitialDiameter(diameter).withExperimentalStructures(experimental);
|
||||
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 + "-experimental-" + experimental;
|
||||
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 && reloaded.rootGeneration() == generator.rootGeneration() && reloaded.experimentalStructures() == experimental,
|
||||
"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, "experimental_structures", experimental, "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");
|
||||
}
|
||||
}
|
||||
+1773
File diff suppressed because it is too large
Load Diff
+114
@@ -0,0 +1,114 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.worldgen.IslandCapacity;
|
||||
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
|
||||
/** Opt-in, repeatable server generation workload; it does not measure client rendering or FPS. */
|
||||
public final class WorldgenPerformanceGameTests {
|
||||
@GameTest(maxTicks = 3000)
|
||||
public void spawnAreaAndExploration(GameTestHelper helper) throws Exception {
|
||||
ServerLevel level = helper.getLevel();
|
||||
var binding = fr.koka.sanctuary.worldgen.PopulationGenerationContext.of(level);
|
||||
var capacity = IslandCapacity.forGenerator(binding.generator());
|
||||
var spawn = SanctuaryWorldGameTests.initialSpawn.pos();
|
||||
int cx = spawn.getX() >> 4, cz = spawn.getZ() >> 4;
|
||||
var report = new LinkedHashMap<String, Object>();
|
||||
report.put("version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow()
|
||||
.getMetadata().getVersion().getFriendlyString());
|
||||
report.put("players", capacity.players()); report.put("seed", level.getSeed());
|
||||
var host = level.getChunkSource().getGenerator();
|
||||
String profile = "players-" + capacity.players();
|
||||
if (host instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified) {
|
||||
report.put("generation", 13); report.put("initial_diameter", unified.initialDiameter());
|
||||
profile = "unified-" + unified.initialDiameter();
|
||||
}
|
||||
report.put("spawn", List.of(spawn.getX(), spawn.getY(), spawn.getZ()));
|
||||
report.put("java", System.getProperty("java.version"));
|
||||
report.put("processors", Runtime.getRuntime().availableProcessors());
|
||||
report.put("max_heap_bytes", Runtime.getRuntime().maxMemory());
|
||||
List<Map<String, Object>> rings = new ArrayList<>();
|
||||
List<Long> chunkTimes = new ArrayList<>();
|
||||
int count = 0, land = 0;
|
||||
long areaStart = System.nanoTime();
|
||||
// Centre outwards, 17x17 FULL chunks: a fixed eight-chunk radius around natural spawn.
|
||||
for (int radius = 0; radius <= 8; radius++) {
|
||||
long start = System.nanoTime();
|
||||
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
|
||||
long before = System.nanoTime();
|
||||
var chunk = full(helper, level, cx + x, cz + z);
|
||||
chunkTimes.add(System.nanoTime() - before);
|
||||
count++;
|
||||
if (java.util.Arrays.stream(chunk.getSections()).anyMatch(s -> !s.hasOnlyAir())) land++;
|
||||
}
|
||||
long elapsed = System.nanoTime() - start;
|
||||
rings.add(Map.of("radius", radius, "elapsed_ms", millis(elapsed), "cumulative_chunks", count));
|
||||
SanctuaryMod.LOGGER.info("Generation benchmark {} seed {} ring {}: {} FULL chunks, {} ms",
|
||||
capacity.players(), level.getSeed(), radius, count, millis(elapsed));
|
||||
}
|
||||
report.put("spawn_area_ms", millis(System.nanoTime() - areaStart));
|
||||
report.put("spawn_area_full_chunks", count); report.put("spawn_area_nonempty_chunks", land);
|
||||
report.put("rings", rings); report.put("spawn_area_chunk_latency_ms", latency(chunkTimes));
|
||||
|
||||
// Continue east through a regional boundary, with a five-chunk-wide corridor.
|
||||
// This exposes stalls during exploration instead of timing only the initial region.
|
||||
List<Map<String, Object>> steps = new ArrayList<>();
|
||||
chunkTimes.clear();
|
||||
long exploreStart = System.nanoTime();
|
||||
for (int x = 9; x <= 24; x++) {
|
||||
long start = System.nanoTime();
|
||||
for (int z = -2; z <= 2; z++) {
|
||||
long before = System.nanoTime();
|
||||
full(helper, level, cx + x, cz + z);
|
||||
chunkTimes.add(System.nanoTime() - before);
|
||||
}
|
||||
long elapsed = System.nanoTime() - start;
|
||||
steps.add(Map.of("chunk_x", cx + x, "elapsed_ms", millis(elapsed)));
|
||||
SanctuaryMod.LOGGER.info("Generation benchmark {} seed {} exploration column {}: {} ms",
|
||||
capacity.players(), level.getSeed(), cx + x, millis(elapsed));
|
||||
}
|
||||
report.put("exploration_ms", millis(System.nanoTime() - exploreStart));
|
||||
report.put("exploration_full_chunks", 80); report.put("exploration_steps", steps);
|
||||
report.put("exploration_chunk_latency_ms", latency(chunkTimes));
|
||||
report.put("regions_cached", PopulationHydrologyRuntime.cachedRegionCount(binding.random()));
|
||||
report.put("scope", "Fresh development world; 289 FULL chunks around natural spawn, then 80 eastward FULL chunks. "
|
||||
+ "Synchronous server requests include dependent generation up to FULL, not guaranteed ticking readiness, client GPU, network, FPS or player load. "
|
||||
+ "World creation before the test is timed separately by Minecraft's startup log.");
|
||||
helper.assertTrue(count == 289 && land > 100, "The benchmark must generate actual playable terrain");
|
||||
report.put("passed", true);
|
||||
Path output = Path.of("diagnostics", "generation-performance-" + profile + "-seed-" + level.getSeed() + ".json");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
|
||||
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
|
||||
helper.assertTrue(chunk instanceof LevelChunk, "Benchmark requires completed chunk " + new ChunkPos(x, z));
|
||||
return (LevelChunk) chunk;
|
||||
}
|
||||
|
||||
private static double millis(long nanos) { return nanos / 1_000_000.0; }
|
||||
|
||||
private static Map<String, Double> latency(List<Long> times) {
|
||||
var sorted = times.stream().sorted().toList();
|
||||
return Map.of("median", millis(sorted.get(sorted.size() / 2)),
|
||||
"p95", millis(sorted.get((int) Math.ceil(sorted.size() * .95) - 1)),
|
||||
"max", millis(sorted.getLast()));
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -17,6 +17,19 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
/** Test-only: run the real Sanctuary datapack instead of the framework's flat fixture. */
|
||||
@Mixin(GameTestServer.class)
|
||||
public abstract class SanctuaryGameTestServerMixin {
|
||||
// The framework otherwise runs thousands of empty ticks per second, exhausting an async
|
||||
// generation test's deadline before its IO/workers can complete. This is test-only pacing.
|
||||
@Inject(method = "waitUntilNextTick", at = @At("RETURN"))
|
||||
private void sanctuary$paceExpansionWorkers(CallbackInfo ci) {
|
||||
if (Boolean.getBoolean("sanctuary.test.ruins17") || Boolean.getBoolean("sanctuary.test.expansion") || Boolean.getBoolean("sanctuary.test.lostCity")
|
||||
|| Boolean.getBoolean("sanctuary.test.lostCity16") || Boolean.getBoolean("sanctuary.test.lostCity15") || Boolean.getBoolean("sanctuary.test.voidReuse")) {
|
||||
long until = System.nanoTime() + 20_000_000L;
|
||||
long remaining;
|
||||
// Worker completion unparks the server; keep the pacing deadline despite those signals.
|
||||
while ((remaining = until - System.nanoTime()) > 0) java.util.concurrent.locks.LockSupport.parkNanos(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
@ModifyArg(method = "<clinit>", at = @At(value = "INVOKE", target =
|
||||
"Lnet/minecraft/world/level/levelgen/WorldOptions;<init>(JZZ)V"), index = 0)
|
||||
private static long sanctuary$testSeed(long original) {
|
||||
@@ -32,12 +45,44 @@ 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() {
|
||||
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary"));
|
||||
if (Boolean.getBoolean("sanctuary.test.unified")) {
|
||||
int generation = Integer.getInteger("sanctuary.test.generation", 17);
|
||||
if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17) throw new IllegalArgumentException("Unsupported test generation");
|
||||
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 17 ? "sanctuary" : "sanctuary_v" + generation));
|
||||
}
|
||||
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_10";
|
||||
case 20 -> "sanctuary_20";
|
||||
case 100 -> "sanctuary_100";
|
||||
default -> throw new IllegalArgumentException("Test geometry must be 5, 10, 20 or 100");
|
||||
};
|
||||
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(preset));
|
||||
}
|
||||
|
||||
@Redirect(method = "lambda$create$1", at = @At(value = "INVOKE", target =
|
||||
"Lnet/minecraft/world/level/levelgen/presets/WorldPreset;createWorldDimensions()Lnet/minecraft/world/level/levelgen/WorldDimensions;"))
|
||||
private static net.minecraft.world.level.levelgen.WorldDimensions sanctuary$customizeSize(WorldPreset preset,
|
||||
net.minecraft.world.level.LevelSettings settings, net.minecraft.server.WorldLoader.DataLoadContext context) {
|
||||
var dimensions = preset.createWorldDimensions();
|
||||
if (Boolean.getBoolean("sanctuary.test.unified")) {
|
||||
var generator = (fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator) dimensions.overworld();
|
||||
return dimensions.replaceOverworldGenerator(context.datapackWorldRegistries(),
|
||||
generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724))
|
||||
.withExperimentalStructures(Boolean.parseBoolean(System.getProperty("sanctuary.test.experimentalStructures", "true"))));
|
||||
}
|
||||
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;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.Interval;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.util.context.ContextKey;
|
||||
import net.minecraft.util.context.ContextMap;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.*;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.UnaryFunction;
|
||||
import net.minecraft.world.level.levelgen.synth.Noise;
|
||||
import net.minecraft.world.level.levelgen.synth.NormalNoise;
|
||||
|
||||
/** Adversarial checks against the actual vanilla scalar implementation, requiring no chunks. */
|
||||
public final class ExactInterpolationChecks {
|
||||
private static final ContextKey<Float> SHIFT = ContextKey.vanilla("sanctuary_test_interpolation_shift");
|
||||
private static final ContextKey<float[]> MUTABLE = ContextKey.vanilla("sanctuary_test_mutable_interpolation_shift");
|
||||
private static final float[] NO_MUTATION = {0};
|
||||
private static final DensityFunction.CompileContext COMPILE = new DensityFunction.CompileContext() {
|
||||
@Override public Noise createNoiseSampler(Holder<NormalNoise> noise) { throw new AssertionError("No noise needed"); }
|
||||
@Override public RandomSource createRandom(Identifier id) { throw new AssertionError("No random needed"); }
|
||||
@Override public RandomSource createEndIslandRandom() { throw new AssertionError("No End needed"); }
|
||||
};
|
||||
|
||||
private ExactInterpolationChecks() {}
|
||||
|
||||
public static Map<String, Object> verify() throws Exception {
|
||||
long checked = 0;
|
||||
for (int[] sizes : new int[][]{{8, 4}, {3, 5}, {1, 1}}) {
|
||||
var input = new Probe(31);
|
||||
DensityFunction expression = expression(input, sizes[0], sizes[1]);
|
||||
var reference = expression.compileSampler(COMPILE);
|
||||
var cached = cached(input, sizes[0], sizes[1]);
|
||||
SamplerContext first = context(0), second = context(0.0625f);
|
||||
var points = new ArrayList<int[]>();
|
||||
for (int x = -13; x <= 13; x++) for (int y = -7; y <= 11; y++) for (int z = -9; z <= 9; z++)
|
||||
points.add(new int[]{x, y, z});
|
||||
Collections.shuffle(points, new Random(73));
|
||||
for (var context : List.of(first, second, first)) for (var p : points) {
|
||||
same(reference.sampleValue(context, p[0], p[1], p[2]), cached.sampleValue(context, p[0], p[1], p[2]), "scalar/context");
|
||||
checked++;
|
||||
}
|
||||
var volume = new DensityVolume(13, 17, 11, -19, -9, -17, 3, 2, 5);
|
||||
DensityBuffer actual = DensityBuffer.createUnpooled(volume.size());
|
||||
cached.sampleVolume(first, actual, volume);
|
||||
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++) {
|
||||
same(reference.sampleValue(first, volume.blockX(x), volume.blockY(y), volume.blockZ(z)),
|
||||
actual.get(volume.indexUnchecked(x, y, z)), "non-unit volume");
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
|
||||
// A scalar and a bulk input intentionally differ in Probe. This catches accidental
|
||||
// replacement of the aligned scalar branch with one of the cached lattice corners.
|
||||
var counted = new Probe(91);
|
||||
var cached = cached(counted, 8, 4);
|
||||
var stable = context(0);
|
||||
for (int repeat = 0; repeat < 2; repeat++)
|
||||
for (int x = 1; x < 8; x++) for (int y = 1; y < 4; y++) for (int z = 1; z < 8; z++) cached.sampleValue(stable, x, y, z);
|
||||
require(counted.volumeCalls.get() == 1, "A reused cell must sample its eight corners only once");
|
||||
int alignedBefore = counted.scalarCalls.get();
|
||||
cached.sampleValue(stable, 0, 0, 0);
|
||||
require(counted.scalarCalls.get() == alignedBefore + 1, "Aligned nodes retain their separate scalar input path");
|
||||
|
||||
var reference = expression(counted, 8, 4).compileSampler(COMPILE);
|
||||
// More distinct cells than the bounded cache holds; collisions and replacement must
|
||||
// remain invisible to both forward and reverse traversals.
|
||||
for (int direction : new int[]{1, -1}) for (int index = 0; index < 9000; index++) {
|
||||
int i = direction > 0 ? index : 8999 - index;
|
||||
int x = (i % 113 - 56) * 8 + 1, y = ((i / 113) % 97 - 48) * 4 + 1, z = (i % 67 - 33) * 8 + 3;
|
||||
same(reference.sampleValue(stable, x, y, z), cached.sampleValue(stable, x, y, z), "cache replacement");
|
||||
checked++;
|
||||
}
|
||||
var other = cached(new Probe(999), 8, 4);
|
||||
var otherReference = expression(new Probe(999), 8, 4).compileSampler(COMPILE);
|
||||
for (int i = 0; i < 128; i++) {
|
||||
same(reference.sampleValue(stable, i - 67, 21, -3), cached.sampleValue(stable, i - 67, 21, -3), "sampler identity A");
|
||||
same(otherReference.sampleValue(stable, i - 67, 21, -3), other.sampleValue(stable, i - 67, 21, -3), "sampler identity B");
|
||||
checked += 2;
|
||||
}
|
||||
try (var executor = Executors.newFixedThreadPool(3)) {
|
||||
List<Callable<Integer>> tasks = new ArrayList<>();
|
||||
for (int worker = 0; worker < 3; worker++) {
|
||||
final int id = worker;
|
||||
tasks.add(() -> {
|
||||
var local = context(id * 0.03125f);
|
||||
for (int i = 0; i < 3000; i++) {
|
||||
int x = (i * 17 + id) % 251 - 125, y = i % 384, z = (i * 37 - id) % 257 - 128;
|
||||
same(reference.sampleValue(local, x, y, z), cached.sampleValue(local, x, y, z), "concurrent shared sampler");
|
||||
}
|
||||
return 3000;
|
||||
});
|
||||
}
|
||||
for (var result : executor.invokeAll(tasks)) checked += result.get();
|
||||
}
|
||||
var plain = new ProbeFunction(new Probe(42));
|
||||
require(ExactInterpolationSampler.compileTerrain(plain, COMPILE) == plain.probe,
|
||||
"Unrecognized expressions must preserve their original sampler");
|
||||
require(!ExactInterpolationSampler.supportsTerrain(expression(new Probe(42), 8, 4)),
|
||||
"An opaque/contextual input must not be cached merely because its outer expression matches");
|
||||
float[] payload = {0};
|
||||
var mutable = SamplerContext.builder().setUserFields(ContextMap.builder().set(MUTABLE, payload).build()).build();
|
||||
var fallback = ExactInterpolationSampler.compileTerrain(expression(new Probe(42), 8, 4), COMPILE);
|
||||
float before = fallback.sampleValue(mutable, 1, 2, 3);
|
||||
payload[0] = 0.125f;
|
||||
float after = fallback.sampleValue(mutable, 1, 2, 3);
|
||||
require(Float.floatToRawIntBits(before) != Float.floatToRawIntBits(after),
|
||||
"The fallback must observe mutable context payloads even when context identity stays the same");
|
||||
|
||||
var nestedInput = new Probe(817);
|
||||
var nested = cached(nestedInput, 8, 4);
|
||||
var nestedReference = expression(new Probe(817), 8, 4).compileSampler(COMPILE);
|
||||
var outerInput = new Probe(83);
|
||||
outerInput.nested = nested;
|
||||
var outer = cached(outerInput, 8, 4);
|
||||
var outerReference = expression(outerInput, 8, 4).compileSampler(COMPILE);
|
||||
for (int i = 0; i < 128; i++) {
|
||||
int x = i - 65;
|
||||
same(outerReference.sampleValue(stable, x, 23, -7), outer.sampleValue(stable, x, 23, -7), "nested outer request");
|
||||
same(nestedReference.sampleValue(stable, x, 23, -7), nested.sampleValue(stable, x, 23, -7), "nested cache identity");
|
||||
checked += 2;
|
||||
}
|
||||
return Map.of("bitwise_comparisons", checked, "repeated_cell_corner_batches", 1,
|
||||
"concurrent_workers", 3, "aligned_scalar_path_preserved", true, "passed", true);
|
||||
}
|
||||
|
||||
private static DensityFunction expression(Probe probe, int xz, int y) {
|
||||
return new UnaryFunction(UnaryFunction.Type.SQUEEZE, new InterpolatedFunction(new ProbeFunction(probe), xz, y));
|
||||
}
|
||||
|
||||
private static DensitySampler cached(Probe probe, int xz, int y) {
|
||||
return new UnaryFunction.SqueezeSampler(new ExactInterpolationSampler(probe, xz, y));
|
||||
}
|
||||
|
||||
private static SamplerContext context(float shift) {
|
||||
return SamplerContext.builder().setUserFields(ContextMap.builder().set(SHIFT, shift).build()).build();
|
||||
}
|
||||
|
||||
private record ProbeFunction(Probe probe) implements DensityFunction {
|
||||
@Override public DensitySampler compileSampler(CompileContext context) { return probe; }
|
||||
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) { return this; }
|
||||
@Override public Interval range() { return Interval.of(-2, 2); }
|
||||
@Override public int domainAxes() { return ALL_AXES; }
|
||||
@Override public MapCodec<ProbeFunction> codec() { return MapCodec.unit(this); }
|
||||
}
|
||||
|
||||
private static final class Probe implements DensitySampler {
|
||||
final int seed;
|
||||
final AtomicInteger scalarCalls = new AtomicInteger(), volumeCalls = new AtomicInteger();
|
||||
DensitySampler nested;
|
||||
Probe(int seed) { this.seed = seed; }
|
||||
private float value(SamplerContext context, int x, int y, int z) {
|
||||
int hash = (x * 73428767 ^ y * 912931 ^ z * 4382893 ^ seed);
|
||||
hash ^= hash >>> 15;
|
||||
float[] mutable = context.getFieldOrDefault(MUTABLE, NO_MUTATION);
|
||||
return Float.intBitsToFloat(0x3e000000 | (hash & 0x007fffff)) - 0.19f
|
||||
+ context.getFieldOrDefault(SHIFT, 0f) + mutable[0];
|
||||
}
|
||||
@Override public float sampleValue(SamplerContext context, int x, int y, int z) {
|
||||
scalarCalls.incrementAndGet();
|
||||
return value(context, x, y, z) + 0.03125f;
|
||||
}
|
||||
@Override public void sampleVolume(SamplerContext context, DensityBuffer buffer, DensityVolume volume) {
|
||||
volumeCalls.incrementAndGet();
|
||||
if (nested != null) nested.sampleValue(context, volume.minBlockX() + 1, volume.minBlockY() + 1, volume.minBlockZ() + 1);
|
||||
for (int z = 0; z < volume.sizeZ(); z++) for (int x = 0; x < volume.sizeX(); x++) for (int y = 0; y < volume.sizeY(); y++)
|
||||
buffer.set(volume.indexUnchecked(x, y, z), value(context, volume.blockX(x), volume.blockY(y), volume.blockZ(z)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void same(float expected, float actual, String label) {
|
||||
if (Float.floatToRawIntBits(expected) != Float.floatToRawIntBits(actual))
|
||||
throw new AssertionError(label + ": expected " + Float.toHexString(expected) + ", actual " + Float.toHexString(actual));
|
||||
}
|
||||
private static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"environment": "*",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"entrypoints": {
|
||||
"fabric-gametest": ["fr.koka.sanctuary.gametest.SanctuaryWorldGameTests"],
|
||||
"fabric-gametest": ["${gametest_entrypoint}"],
|
||||
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
|
||||
},
|
||||
"mixins": ["sanctuary-gametest.mixins.json"],
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
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;
|
||||
import fr.koka.sanctuary.worldgen.PopulationGrovesFeature;
|
||||
import fr.koka.sanctuary.worldgen.PopulationDecorationsFeature;
|
||||
import fr.koka.sanctuary.worldgen.PopulationShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.CavernGrovesFeature;
|
||||
import fr.koka.sanctuary.worldgen.CavernDecorationsFeature;
|
||||
import fr.koka.sanctuary.worldgen.CavernShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.RiftIslandDensity;
|
||||
import fr.koka.sanctuary.worldgen.RiftIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.RiftGrovesFeature;
|
||||
import fr.koka.sanctuary.worldgen.RiftShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.MainIslandDensity;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandGrovesFeature;
|
||||
import fr.koka.sanctuary.worldgen.NaturalShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.TemperateIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.LayeredIslandBiomeSource;
|
||||
import fr.koka.sanctuary.worldgen.LayeredShoreSugarCaneFeature;
|
||||
import fr.koka.sanctuary.worldgen.LowerTerraceVegetationFeature;
|
||||
import fr.koka.sanctuary.worldgen.ShoreSugarCaneFeature;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
@@ -18,7 +43,46 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionChunkGenerator.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
|
||||
fr.koka.sanctuary.expansion.ExpansionRuntime.register();
|
||||
fr.koka.sanctuary.expansion.ExpansionCommands.register();
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("main_island"), MainIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v13"), SanctuaryChunkGenerator.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v13"), SanctuaryBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v14"), SanctuaryChunkGenerator.CITY_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v14"), SanctuaryBiomeSource.CITY_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v15"), SanctuaryChunkGenerator.CITY15_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v15"), SanctuaryBiomeSource.CITY15_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v16"), SanctuaryChunkGenerator.CITY16_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v16"), SanctuaryBiomeSource.CITY16_CODEC);
|
||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v17"), SanctuaryChunkGenerator.RUINS17_CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v17"), SanctuaryBiomeSource.RUINS17_CODEC);
|
||||
fr.koka.sanctuary.worldgen.city.LostCityStructures.register();
|
||||
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.register();
|
||||
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);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_decorations"), PopulationDecorationsFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("population_shore_sugar_cane"), PopulationShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("shore_sugar_cane"), ShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("natural_shore_sugar_cane"), NaturalShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("temperate_island"), TemperateIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("layered_shore_sugar_cane"), LayeredShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("lower_terrace_vegetation"), LowerTerraceVegetationFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("layered_island"), LayeredIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("woodland_island"), WoodlandIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("woodland_shore_sugar_cane"), WoodlandShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("woodland_groves"), WoodlandGrovesFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("rift_island"), RiftIslandDensity.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("rift_island"), RiftIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("rift_groves"), RiftGrovesFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("rift_shore_sugar_cane"), RiftShoreSugarCaneFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("cavern_island"), CavernIslandBiomeSource.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_groves"), CavernGrovesFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_decorations"), CavernDecorationsFeature.CODEC);
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE, id("cavern_shore_sugar_cane"), CavernShoreSugarCaneFeature.CODEC);
|
||||
LOGGER.info("Sanctuary initialized: the Sanctuary world preset is available.");
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
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 size and the saved experiment switch of a pending new world. Cancel applies neither. */
|
||||
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 boolean experimental;
|
||||
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());
|
||||
experimental = generator.experimentalStructures();
|
||||
}
|
||||
|
||||
@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));
|
||||
if (generator.rootGeneration() == 17) {
|
||||
contents.addChild(CycleButton.builder((Boolean enabled) -> enabled ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF, experimental)
|
||||
.withValues(true, false).create(0, 0, 280, 20, Component.translatable("sanctuary.world_options.experimental"),
|
||||
(button, value) -> experimental = value));
|
||||
contents.addChild(new MultiLineTextWidget(Component.translatable("sanctuary.world_options.experimental_hint"), 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).withExperimentalStructures(experimental)));
|
||||
onClose();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void repositionElements() {
|
||||
layout.arrangeElements();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
minecraft.gui.setScreen(parent);
|
||||
}
|
||||
|
||||
private enum IslandSize {
|
||||
SMALL("small", 512), MEDIUM("medium", 724), LARGE("large", 1024);
|
||||
|
||||
private final String id;
|
||||
private final int diameter;
|
||||
|
||||
IslandSize(String id, int diameter) {
|
||||
this.id = id;
|
||||
this.diameter = diameter;
|
||||
}
|
||||
|
||||
Component label() {
|
||||
return Component.translatable("sanctuary.world_options.size." + id);
|
||||
}
|
||||
|
||||
static IslandSize fromDiameter(int diameter) {
|
||||
for (IslandSize size : values()) {
|
||||
if (size.diameter == diameter) return size;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported Sanctuary island diameter: " + diameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import fr.koka.sanctuary.mixin.ExpansionChunkMapAccessor;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ChunkMap;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
|
||||
/** Conservative admission for fresh regions only; inspecting a position never requests generation. */
|
||||
public final class ExpansionAdmission {
|
||||
private static final int MAX_CHUNKS = 4_000;
|
||||
private static final int READ_BATCH = 64;
|
||||
private static final long DEADLINE_NANOS = TimeUnit.SECONDS.toNanos(30);
|
||||
|
||||
private ExpansionAdmission() { }
|
||||
|
||||
public static void verify(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
verify(level, island, searchDeadline());
|
||||
}
|
||||
|
||||
/** A caller searching several positions must share this deadline across all admission attempts. */
|
||||
public static long searchDeadline() {
|
||||
return System.nanoTime() + DEADLINE_NANOS;
|
||||
}
|
||||
|
||||
public static void verify(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
|
||||
checkDeadline(deadlineNanos);
|
||||
List<ChunkPos> positions = positions(level, island);
|
||||
ChunkMap map = level.getChunkSource().chunkMap;
|
||||
verifyHolders(map, positions, deadlineNanos);
|
||||
for (int start = 0; start < positions.size(); start += READ_BATCH) {
|
||||
checkDeadline(deadlineNanos);
|
||||
int end = Math.min(positions.size(), start + READ_BATCH);
|
||||
List<CompletableFuture<Optional<CompoundTag>>> reads = new ArrayList<>();
|
||||
for (int i = start; i < end; i++) reads.add(map.read(positions.get(i)));
|
||||
ChunkPos occupied = null;
|
||||
try {
|
||||
for (int i = 0; i < reads.size(); i++) {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0) throw new TimeoutException("Admission deadline expired");
|
||||
if (reads.get(i).get(remaining, TimeUnit.NANOSECONDS).isPresent()) {
|
||||
occupied = positions.get(start + i);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Expansion admission interrupted", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new IOException("search_timeout", e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new IOException("Expansion could not prove that every reserved chunk is absent", e);
|
||||
}
|
||||
// Drain every submitted read before skipping this occupied region, so a failed read
|
||||
// is never swallowed as an ordinary collision and no next attempt overlaps the batch.
|
||||
checkDeadline(deadlineNanos);
|
||||
if (occupied != null) throw new OccupiedException("Expansion intersects an existing saved chunk: " + occupied);
|
||||
}
|
||||
verifyHolders(map, positions, deadlineNanos);
|
||||
// Caller must commit and publish on this same server turn, without yielding or processing ticks.
|
||||
}
|
||||
|
||||
/** Recheck active requests immediately before commit, after read-only terrain prospecting. */
|
||||
public static void verifyHolders(ServerLevel level, ExpansionIsland island, long deadlineNanos) throws IOException {
|
||||
checkDeadline(deadlineNanos);
|
||||
verifyHolders(level.getChunkSource().chunkMap, positions(level, island), deadlineNanos);
|
||||
}
|
||||
|
||||
private static List<ChunkPos> positions(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
if (!level.getServer().isSameThread()) throw new IOException("Expansion admission must run on the server thread");
|
||||
if (island.origin()) throw new IOException("The existing Sanctuary region cannot be admitted again");
|
||||
int radius = island.reservedRadius();
|
||||
int minX = Math.floorDiv(island.centerX() - radius, 16), maxX = Math.floorDiv(island.centerX() + radius, 16);
|
||||
int minZ = Math.floorDiv(island.centerZ() - radius, 16), maxZ = Math.floorDiv(island.centerZ() + radius, 16);
|
||||
int limit = ExpansionIsland.spatialGeneration(island.generation()) ? 8000 : MAX_CHUNKS;
|
||||
if ((long) (maxX - minX + 1) * (maxZ - minZ + 1) > limit) throw new IOException("Expansion admission exceeds " + limit + " chunks");
|
||||
ChunkMap map = level.getChunkSource().chunkMap;
|
||||
if (!(map instanceof ExpansionChunkMapAccessor)) throw new IOException("Expansion chunk safety accessor is unavailable");
|
||||
List<ChunkPos> positions = new ArrayList<>();
|
||||
for (int z = minZ; z <= maxZ; z++) for (int x = minX; x <= maxX; x++)
|
||||
if (!ExpansionIsland.spatialGeneration(island.generation()) || island.ownsChunk(x, z)) positions.add(new ChunkPos(x, z));
|
||||
return positions;
|
||||
}
|
||||
|
||||
private static void verifyHolders(ChunkMap map, List<ChunkPos> positions, long deadlineNanos) throws IOException {
|
||||
for (ChunkPos position : positions) {
|
||||
checkDeadline(deadlineNanos);
|
||||
rejectHolder(map, position);
|
||||
}
|
||||
}
|
||||
|
||||
public static void checkDeadline(long deadlineNanos) throws IOException {
|
||||
if (deadlineNanos - System.nanoTime() <= 0) throw new IOException("search_timeout");
|
||||
}
|
||||
|
||||
private static void rejectHolder(ChunkMap map, ChunkPos position) throws IOException {
|
||||
long key = position.pack();
|
||||
if (map.getUpdatingChunkIfPresent(key) != null || ((ExpansionChunkMapAccessor) map).sanctuary$pendingUnloads().containsKey(key)) {
|
||||
throw new OccupiedException("Expansion intersects a loaded or unloading chunk: " + position);
|
||||
}
|
||||
}
|
||||
|
||||
/** Only proven occupancy may move the search farther along the requested direction. */
|
||||
public static final class OccupiedException extends IOException {
|
||||
public OccupiedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** All future holders are known up front; only the immutable region snapshot changes on activation. */
|
||||
public class ExpansionBiomeSource extends BiomeSource {
|
||||
public static final List<String> REQUIRED_BIOMES = Stream.of(
|
||||
"the_void", "forest", "birch_forest", "flower_forest", "plains", "meadow",
|
||||
"taiga", "snowy_taiga", "snowy_plains", "grove", "ice_spikes", "frozen_peaks",
|
||||
"old_growth_spruce_taiga", "dark_forest", "swamp", "pale_garden", "jungle",
|
||||
"sparse_jungle", "bamboo_jungle", "mangrove_swamp", "savanna", "savanna_plateau",
|
||||
"desert", "badlands", "eroded_badlands", "wooded_badlands", "windswept_hills",
|
||||
"windswept_gravelly_hills", "stony_peaks", "dappled_forest", "cherry_grove",
|
||||
"lush_caves", "dripstone_caves", "sulfur_caves"
|
||||
).map(path -> "minecraft:" + path).toList();
|
||||
|
||||
public static final MapCodec<ExpansionBiomeSource> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.biomes)
|
||||
).apply(i, ExpansionBiomeSource::new));
|
||||
|
||||
private final List<Holder<Biome>> biomes;
|
||||
private final Map<String, Holder<Biome>> byName;
|
||||
private volatile List<ExpansionIsland> islands = List.of();
|
||||
|
||||
public ExpansionBiomeSource(List<Holder<Biome>> biomes) {
|
||||
this.biomes = List.copyOf(biomes);
|
||||
var names = new HashMap<String, Holder<Biome>>();
|
||||
for (Holder<Biome> biome : biomes) {
|
||||
String name = biome.unwrapKey().orElseThrow(() -> new IllegalArgumentException("Expansion biomes must be registered holders"))
|
||||
.identifier().toString();
|
||||
if (names.put(name, biome) != null) throw new IllegalArgumentException("Duplicate expansion biome: " + name);
|
||||
}
|
||||
for (String required : REQUIRED_BIOMES) {
|
||||
if (!names.containsKey(required)) throw new IllegalArgumentException("Missing expansion biome: " + required);
|
||||
}
|
||||
this.byName = Map.copyOf(names);
|
||||
}
|
||||
|
||||
public void setIslands(List<ExpansionIsland> islands) { this.islands = List.copyOf(islands); }
|
||||
public List<ExpansionIsland> islands() { return islands; }
|
||||
|
||||
public @Nullable ExpansionIsland islandAt(int x, int z) {
|
||||
return islandAt(islands, x, z);
|
||||
}
|
||||
|
||||
private static @Nullable ExpansionIsland islandAt(List<ExpansionIsland> snapshot, int x, int z) {
|
||||
for (ExpansionIsland island : snapshot) if (island.contains(x, z)) return island;
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override protected MapCodec<? extends ExpansionBiomeSource> codec() { return CODEC; }
|
||||
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() { return biomes.stream(); }
|
||||
|
||||
@Override
|
||||
public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
// General-purpose resolvers may outlive an activation. Resolve against the current snapshot.
|
||||
return (quartX, quartY, quartZ) -> resolve(islands, quartX * 4, quartY * 4, quartZ * 4);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeResolver createResolverForChunk(Climate.Sampler sampler, int minQuartX, int minQuartY,
|
||||
int minQuartZ, int quartSizeX, int quartSizeY, int quartSizeZ) {
|
||||
List<ExpansionIsland> snapshot = islands;
|
||||
return (quartX, quartY, quartZ) -> resolve(snapshot, quartX * 4, quartY * 4, quartZ * 4);
|
||||
}
|
||||
|
||||
protected Holder<Biome> resolve(List<ExpansionIsland> snapshot, int x, int y, int z) {
|
||||
ExpansionIsland island = islandAt(snapshot, x, z);
|
||||
if (island == null || y < 0 || y >= 384) return biome("the_void");
|
||||
double scale = Math.clamp(island.diameter() / 3.0, 24.0, 144.0);
|
||||
double localX = (x - island.centerX()) / scale, localZ = (z - island.centerZ()) / scale;
|
||||
double variation = noise(island.seed(), localX, localZ);
|
||||
double moisture = noise(island.seed() ^ 0xA12C11A7EL, localX * 0.8, localZ * 0.8);
|
||||
double elevation = y - variation * 14 - moisture * 8;
|
||||
|
||||
// Small pockets preserve the surface profile on exposed lower ledges.
|
||||
if (y < 168 && elevation < 162) {
|
||||
if ((island.relief().equals("volcano") && variation > -0.4)
|
||||
|| (variation > 0.22 && variation < 0.46 && moisture < -0.18)) return biome("sulfur_caves");
|
||||
if (moisture > 0.14 && variation > -0.38) return biome("lush_caves");
|
||||
if (moisture < -0.18 && variation < 0.2) return biome("dripstone_caves");
|
||||
}
|
||||
|
||||
String selected = switch (island.climate()) {
|
||||
case "temperate", "lost_city" -> Math.abs(variation) < 0.13 ? "plains"
|
||||
: variation < -0.33 ? "birch_forest" : moisture > 0.44 ? "flower_forest" : "forest";
|
||||
case "boreal" -> elevation > 244 ? "snowy_taiga" : variation < -0.2 ? "grove" : "taiga";
|
||||
case "glacial" -> elevation > 246 ? "frozen_peaks" : variation > 0.35 ? "ice_spikes" : "snowy_plains";
|
||||
case "cold_humid" -> elevation > 250 ? "grove" : variation < -0.16 ? "taiga" : "old_growth_spruce_taiga";
|
||||
case "humid" -> moisture > 0.4 && variation < 0 ? "pale_garden"
|
||||
: elevation < 222 && variation < 0.14 ? "swamp" : "dark_forest";
|
||||
case "tropical" -> elevation < 218 && moisture > 0.15 ? "mangrove_swamp"
|
||||
: variation > 0.3 ? "bamboo_jungle" : variation < -0.34 ? "sparse_jungle" : "jungle";
|
||||
case "warm" -> elevation > 244 ? "savanna_plateau" : moisture > 0.46 ? "sparse_jungle" : "savanna";
|
||||
case "arid" -> variation < -0.2 ? "desert" : variation > 0.4 ? "eroded_badlands"
|
||||
: moisture > 0.36 ? "wooded_badlands" : "badlands";
|
||||
case "dry" -> elevation > 250 ? "stony_peaks" : variation > 0.28 ? "windswept_hills"
|
||||
: moisture < -0.38 ? "windswept_gravelly_hills" : "plains";
|
||||
case "cold_dry" -> elevation > 250 ? "frozen_peaks" : variation > 0.12 ? "snowy_plains" : "windswept_gravelly_hills";
|
||||
case "autumn" -> Math.abs(variation) < 0.12 ? "meadow" : variation < -0.52 ? "birch_forest" : "dappled_forest";
|
||||
case "cherry" -> elevation > 258 ? "grove" : variation < -0.22 ? "meadow" : "cherry_grove";
|
||||
default -> throw new IllegalStateException("Unvalidated expansion climate: " + island.climate());
|
||||
};
|
||||
return biome(selected);
|
||||
}
|
||||
|
||||
private Holder<Biome> biome(String path) { return byName.get("minecraft:" + path); }
|
||||
|
||||
private static double noise(long seed, double x, double z) {
|
||||
int floorX = (int) Math.floor(x), floorZ = (int) Math.floor(z);
|
||||
double tx = fade(x - floorX), tz = fade(z - floorZ);
|
||||
double north = mix(lattice(seed, floorX, floorZ), lattice(seed, floorX + 1, floorZ), tx);
|
||||
double south = mix(lattice(seed, floorX, floorZ + 1), lattice(seed, floorX + 1, floorZ + 1), tx);
|
||||
return mix(north, south, tz);
|
||||
}
|
||||
|
||||
private static double lattice(long seed, int x, int z) {
|
||||
long value = seed ^ (x * 0x9E3779B97F4A7C15L) ^ (z * 0xC2B2AE3D27D4EB4FL);
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
value ^= value >>> 31;
|
||||
return (value >>> 11) * 0x1.0p-52 - 1.0;
|
||||
}
|
||||
|
||||
private static double fade(double value) { return value * value * (3 - 2 * value); }
|
||||
private static double mix(double start, double end, double amount) { return start + (end - start) * amount; }
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.NoiseColumn;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.NoiseRouter;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
|
||||
/** Laboratory only. Each immutable island has its own vanilla noise generator and seeded RandomState.
|
||||
* The host ChunkMap's empty fallback RandomState is deliberately never used for terrain. */
|
||||
public class ExpansionChunkGenerator extends ChunkGenerator {
|
||||
public static final MapCodec<ExpansionChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
ExpansionBiomeSource.CODEC.fieldOf("biome_source").forGetter(ExpansionChunkGenerator::source),
|
||||
Codec.intRange(512, 724).optionalFieldOf("initial_diameter", 512).forGetter(ExpansionChunkGenerator::initialDiameter)
|
||||
).apply(i, ExpansionChunkGenerator::new));
|
||||
private final ExpansionBiomeSource source;
|
||||
private final int initialDiameter;
|
||||
protected volatile RegistryAccess registries;
|
||||
private final Map<ExpansionIsland, Context> contexts = new ConcurrentHashMap<>();
|
||||
protected record Context(NoiseBasedChunkGenerator generator, RandomState random) { public Context {} }
|
||||
|
||||
public ExpansionChunkGenerator(ExpansionBiomeSource source, int initialDiameter) {
|
||||
this(source, initialDiameter, false);
|
||||
}
|
||||
protected ExpansionChunkGenerator(ExpansionBiomeSource source, int initialDiameter, boolean unified) {
|
||||
super(source);
|
||||
if (initialDiameter != 512 && initialDiameter != 724 && !(unified && initialDiameter == 1024))
|
||||
throw new IllegalArgumentException("Unsupported Sanctuary diameter");
|
||||
this.source = source;
|
||||
this.initialDiameter = initialDiameter;
|
||||
}
|
||||
public ExpansionBiomeSource source() { return source; }
|
||||
public int initialDiameter() { return initialDiameter; }
|
||||
@Override protected MapCodec<? extends ExpansionChunkGenerator> codec() { return CODEC; }
|
||||
public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
|
||||
registries = level.registryAccess();
|
||||
source.setIslands(islands);
|
||||
}
|
||||
public void setIslands(List<ExpansionIsland> islands) { source.setIslands(islands); }
|
||||
public void discardUnpublishedContext(ExpansionIsland island) {
|
||||
var key = island.ready();
|
||||
if (source.islands().stream().noneMatch(saved -> saved.ready().equals(key))) contexts.remove(key);
|
||||
}
|
||||
public Holder<Biome> structureBiome(Holder<Biome> biome) { return biome; }
|
||||
public ExpansionIsland islandAt(int x, int z) { return source.islandAt(x, z); }
|
||||
public ExpansionIsland islandForChunk(int x, int z) {
|
||||
for (ExpansionIsland island : source.islands()) if (island.ownsChunk(x, z)) return island;
|
||||
return null;
|
||||
}
|
||||
protected Context context(ExpansionIsland island) {
|
||||
if (registries == null) throw new IllegalStateException("Expansion generator used before world attachment");
|
||||
return contexts.computeIfAbsent(island.ready(), ignored -> {
|
||||
String baseKey = island.origin() && island.diameter() == 724 ? "population_10" : "population_5";
|
||||
var settingsRegistry = registries.lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
var base = settingsRegistry.getValue(Identifier.fromNamespaceAndPath("sanctuary", baseKey));
|
||||
var vanilla = settingsRegistry.getValue(Identifier.withDefaultNamespace("overworld"));
|
||||
if (base == null || vanilla == null) throw new IllegalStateException("Missing laboratory noise settings");
|
||||
var router = base.noiseRouter();
|
||||
var density = new ExpansionDensity(router.finalDensity(), island);
|
||||
var translated = new NoiseRouter(router.temperature(), router.vegetation(), router.continents(), router.erosion(),
|
||||
router.depth(), router.ridges(), router.chunkSurfaceLevel(), density);
|
||||
var settings = new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), Blocks.AIR.defaultBlockState(),
|
||||
translated, vanilla.materialRule(), List.of(), -64, false, Optional.empty(), base.useLegacyRandomSource(), base.debugFunctions());
|
||||
var delegate = new NoiseBasedChunkGenerator(source, Holder.direct(settings));
|
||||
var random = RandomState.create(registries.lookupOrThrow(Registries.NOISE), island.seed(), settings);
|
||||
return new Context(delegate, random);
|
||||
});
|
||||
}
|
||||
protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) { ExpansionTerrain.apply(island, chunk); }
|
||||
public String journalDirectory() { return "sanctuary-expansion-v1"; }
|
||||
public int rootGeneration() { return ExpansionIsland.GENERATION; }
|
||||
public RandomState randomState(ExpansionIsland island) { return context(island).random(); }
|
||||
public NoiseBasedChunkGenerator delegate(ExpansionIsland island) { return context(island).generator(); }
|
||||
@Override public CompletableFuture<ChunkAccess> createBiomes(RandomState ignored, Blender blender, StructureManager structures, ChunkAccess chunk) {
|
||||
var island = islandForChunk(chunk.getPos().x(), chunk.getPos().z());
|
||||
if (island == null) return super.createBiomes(ignored, blender, structures, chunk);
|
||||
var context = context(island);
|
||||
return context.generator().createBiomes(context.random(), blender, structures, chunk);
|
||||
}
|
||||
@Override public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender, RandomState ignored,
|
||||
StructureManager structures, BiomeManager manager, WorldGenRegion region, Set<Holder<Biome>> biomes) {
|
||||
var island = islandForChunk(chunk.getPos().x(), chunk.getPos().z());
|
||||
if (island == null) return CompletableFuture.completedFuture(chunk);
|
||||
var context = context(island);
|
||||
return context.generator().buildTerrain(chunk, blender, context.random(), structures, manager, region, biomes).thenApply(generated -> {
|
||||
afterTerrain(island, generated);
|
||||
return generated;
|
||||
});
|
||||
}
|
||||
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager structures,
|
||||
ChunkAccess chunk, StructureTemplateManager templates, ResourceKey<Level> dimension) {
|
||||
fr.koka.sanctuary.worldgen.city.LostCityStructures.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
ExpansionStructures.generate(this, registries, state, structures, chunk, templates, dimension);
|
||||
}
|
||||
@Override public void spawnOriginalMobs(WorldGenRegion region) {
|
||||
var pos = region.getCenter();
|
||||
var island = islandForChunk(pos.x(), pos.z());
|
||||
if (island != null) context(island).generator().spawnOriginalMobs(region);
|
||||
}
|
||||
@Override public int getGenDepth() { return 384; }
|
||||
@Override public int getMinY() { return 0; }
|
||||
@Override public int getSeaLevel() { return -64; }
|
||||
@Override public int getSpawnHeight(LevelHeightAccessor level) { return 220; }
|
||||
@Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor level, RandomState ignored) {
|
||||
var island = islandAt(x, z);
|
||||
if (island == null) return getMinY();
|
||||
var context = context(island);
|
||||
return context.generator().getBaseHeight(x, z, type, level, context.random());
|
||||
}
|
||||
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor level, RandomState ignored) {
|
||||
var island = islandAt(x, z);
|
||||
if (island != null) {
|
||||
var context = context(island);
|
||||
return context.generator().getBaseColumn(x, z, level, context.random());
|
||||
}
|
||||
BlockState[] air = new BlockState[384];
|
||||
java.util.Arrays.fill(air, Blocks.AIR.defaultBlockState());
|
||||
return new NoiseColumn(0, air);
|
||||
}
|
||||
@Override public void addDebugScreenInfo(List<String> text, RandomState ignored, BlockPos pos, SamplerContext context) {
|
||||
var island = islandAt(pos.getX(), pos.getZ());
|
||||
String prefix = "Sanctuary gen " + rootGeneration() + ": ";
|
||||
text.add(island == null ? prefix + "void" : prefix + island.id() + " / " + island.climate() + " / " + island.relief());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.mojang.brigadier.Command;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.SharedSuggestionProvider;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** OP tools for the experimental world, also usable from the dedicated server console. */
|
||||
public final class ExpansionCommands {
|
||||
private ExpansionCommands() {}
|
||||
private static final String PREFIX = "sanctuary.expansion.";
|
||||
public static void register() {
|
||||
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) -> {
|
||||
var expansion = Commands.literal("expansion").executes(ExpansionCommands::help)
|
||||
.then(Commands.literal("help").executes(ExpansionCommands::help))
|
||||
.then(Commands.literal("list").executes(c -> run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
for (var island : session.islands()) c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "entry", island.id(), island.parent(),
|
||||
label("direction", island.direction()), label("climate", island.climate()), island.diameter(), label("relief", island.relief()), island.centerX(), island.centerZ(),
|
||||
Component.translatable(PREFIX + "state." + island.status())), false);
|
||||
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "progress", session.progress()), false);
|
||||
return session.islands().size();
|
||||
})))
|
||||
.then(Commands.literal("visit").then(islandArgument().executes(c -> run(c, () -> {
|
||||
var player = c.getSource().getPlayerOrException();
|
||||
if (player.isPassenger()) throw new IllegalArgumentException("dismount_first");
|
||||
var landing = ExpansionRuntime.session(c.getSource().getLevel()).landing(word(c, "island"));
|
||||
boolean moved = player.teleportTo(c.getSource().getLevel(), landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
Set.of(), player.getYRot(), player.getXRot(), true);
|
||||
if (!moved) throw new IllegalArgumentException("teleport_failed");
|
||||
player.fallDistance = 0;
|
||||
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "visited", word(c, "island")), false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))))
|
||||
.then(Commands.literal("resume").then(islandArgument().executes(c -> run(c, () -> {
|
||||
ExpansionRuntime.session(c.getSource().getLevel()).resume(word(c, "island"));
|
||||
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "resumed", word(c, "island")), false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))))
|
||||
.then(createTree("create", false)).then(createTree("preview", true))
|
||||
.then(Commands.literal("quick").then(Commands.argument("direction", StringArgumentType.word())
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
|
||||
.executes(c -> quick(c, null, 0, "natural"))
|
||||
.then(Commands.argument("climate", StringArgumentType.word())
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
|
||||
.executes(c -> quick(c, word(c, "climate"), 0, "natural"))
|
||||
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 1024))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512", "1024"), b))
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), "natural"))
|
||||
.then(Commands.argument("relief", StringArgumentType.word())
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
|
||||
.executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief")))
|
||||
.then(distanceArgument().executes(c -> quick(c, word(c, "climate"), IntegerArgumentType.getInteger(c, "diameter"),
|
||||
word(c, "relief"), IntegerArgumentType.getInteger(c, "distance")))))))));
|
||||
dispatcher.register(Commands.literal("sanctuary").requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)).then(expansion));
|
||||
});
|
||||
}
|
||||
private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, String> islandArgument() {
|
||||
return Commands.argument("island", StringArgumentType.word()).suggests((c, b) -> {
|
||||
try { return SharedSuggestionProvider.suggest(ExpansionRuntime.session(c.getSource().getLevel()).islands().stream().map(ExpansionIsland::id), b); }
|
||||
catch (IllegalArgumentException ignored) { return b.buildFuture(); }
|
||||
});
|
||||
}
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> createTree(String name, boolean preview) {
|
||||
return Commands.literal(name).then(Commands.argument("id", StringArgumentType.word())
|
||||
.then(Commands.argument("parent", StringArgumentType.word())
|
||||
.suggests((c, b) -> {
|
||||
try { return SharedSuggestionProvider.suggest(ExpansionRuntime.session(c.getSource().getLevel()).islands().stream().map(ExpansionIsland::id), b); }
|
||||
catch (IllegalArgumentException ignored) { return b.buildFuture(); }
|
||||
})
|
||||
.then(Commands.argument("direction", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.DIRECTIONS, b))
|
||||
.then(Commands.argument("climate", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.CLIMATES, b))
|
||||
.then(Commands.argument("diameter", IntegerArgumentType.integer(64, 1024))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("64", "128", "256", "512", "1024"), b))
|
||||
.then(Commands.argument("relief", StringArgumentType.word()).suggests((c, b) -> SharedSuggestionProvider.suggest(ExpansionIsland.RELIEFS, b))
|
||||
.executes(c -> create(c, preview, 0))
|
||||
.then(distanceArgument().executes(c -> create(c, preview, IntegerArgumentType.getInteger(c, "distance"))))))))));
|
||||
}
|
||||
private static com.mojang.brigadier.builder.RequiredArgumentBuilder<CommandSourceStack, Integer> distanceArgument() {
|
||||
return Commands.argument("distance", IntegerArgumentType.integer(0, 100_000))
|
||||
.suggests((c, b) -> SharedSuggestionProvider.suggest(List.of("1024", "2048", "4096"), b));
|
||||
}
|
||||
private static int create(CommandContext<CommandSourceStack> c, boolean preview, int minimumDistance) {
|
||||
return run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
var island = preview ? session.candidate(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance)
|
||||
: session.create(word(c, "id"), word(c, "parent"), word(c, "direction"), word(c, "climate"),
|
||||
IntegerArgumentType.getInteger(c, "diameter"), word(c, "relief"), minimumDistance);
|
||||
describe(c, island, preview);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
}
|
||||
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief) {
|
||||
return quick(c, climate, diameter, relief, 0);
|
||||
}
|
||||
private static int quick(CommandContext<CommandSourceStack> c, String climate, int diameter, String relief, int minimumDistance) {
|
||||
return run(c, () -> {
|
||||
var session = ExpansionRuntime.session(c.getSource().getLevel());
|
||||
int next = session.islands().size();
|
||||
while (true) {
|
||||
String possible = "island_" + next;
|
||||
if (session.islands().stream().noneMatch(i -> i.id().equals(possible))) break;
|
||||
next++;
|
||||
}
|
||||
String direction = word(c, "direction");
|
||||
if (!ExpansionIsland.DIRECTIONS.contains(direction)) throw new IllegalArgumentException("invalid_direction");
|
||||
String id = "island_" + next;
|
||||
int chosenDiameter = diameter != 0 ? diameter : ExpansionIsland.spatialGeneration(session.islands().getFirst().generation())
|
||||
? ExpansionRandomSize.choose(c.getSource().getLevel().getSeed(), id) : 128;
|
||||
var island = session.create(id, "sanctuary", direction,
|
||||
climate == null ? ExpansionIsland.directionalClimate(direction) : climate, chosenDiameter, relief, minimumDistance);
|
||||
describe(c, island, false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
});
|
||||
}
|
||||
private static void describe(CommandContext<CommandSourceStack> c, ExpansionIsland island, boolean preview) {
|
||||
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + (preview ? "preview" : "created"),
|
||||
island.id(), island.centerX(), island.centerZ(), label("climate", island.climate()), island.diameter(), label("relief", island.relief())), false);
|
||||
if (!preview && ExpansionVoidReuse.requiresReload(c.getSource().getLevel(), island.id()))
|
||||
c.getSource().sendSuccess(() -> Component.translatable(PREFIX + "reuse_reload", island.id()), false);
|
||||
}
|
||||
private static int help(CommandContext<CommandSourceStack> context) {
|
||||
context.getSource().sendSuccess(() -> Component.translatable(PREFIX + "help"), false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}
|
||||
private static String word(CommandContext<CommandSourceStack> context, String key) { return StringArgumentType.getString(context, key); }
|
||||
private static Component label(String category, String id) { return Component.translatable(PREFIX + category + "." + id); }
|
||||
@FunctionalInterface private interface Action { int run() throws Exception; }
|
||||
private static int run(CommandContext<CommandSourceStack> context, Action action) {
|
||||
try { return action.run(); }
|
||||
catch (Exception exception) {
|
||||
String reason = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage();
|
||||
Component detail = reason.matches("[a-z_]+") ? Component.translatable(PREFIX + "error." + reason) : Component.literal(reason);
|
||||
context.getSource().sendFailure(Component.translatable(PREFIX + "error", detail));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.Arrays;
|
||||
import net.minecraft.util.Interval;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityBuffer;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
|
||||
/** Versioned alpha.12 wrapper around a complete historical density, sampled in its own island frame. */
|
||||
public record ExpansionDensity(DensityFunction source, ExpansionIsland island) implements DensityFunction {
|
||||
public static final MapCodec<ExpansionDensity> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
|
||||
DensityFunction.CODEC.fieldOf("source").forGetter(ExpansionDensity::source),
|
||||
ExpansionIsland.CODEC.fieldOf("island").forGetter(ExpansionDensity::island)
|
||||
).apply(i, ExpansionDensity::new));
|
||||
|
||||
// One bounded numeric/descriptor cache per worker. It holds no sampler, context or world.
|
||||
private static final ThreadLocal<ColumnCache> COLUMNS = ThreadLocal.withInitial(ColumnCache::new);
|
||||
|
||||
@Override public DensitySampler compileSampler(CompileContext context) {
|
||||
DensitySampler original = source.compileSampler(context);
|
||||
return new DensitySampler() {
|
||||
@Override public float sampleValue(SamplerContext sample, int x, int y, int z) {
|
||||
if (y <= 0 || y >= 384 || !island.contains(x, z)) return -1.0F;
|
||||
ExpansionRelief.Column column = COLUMNS.get().get(island, x, z);
|
||||
double sy = ExpansionRelief.sourceY(island, column, y);
|
||||
if (sy <= 0 || sy >= 384) return -1.0F;
|
||||
int sx = ExpansionRelief.sourceX(island, x), sz = ExpansionRelief.sourceZ(island, z);
|
||||
int lowerY = (int) Math.floor(sy);
|
||||
float low = original.sampleValue(sample, sx, lowerY, sz);
|
||||
double fraction = sy - lowerY;
|
||||
// Below the root's upper transition this is precisely the old single sample.
|
||||
// Fractional Y elsewhere is interpolated, avoiding one-block terraces from rounding.
|
||||
float density = fraction == 0.0 ? low : (float) (low
|
||||
+ (original.sampleValue(sample, sx, lowerY + 1, sz) - low) * fraction);
|
||||
return ExpansionRelief.sculpt(island, column, y, Math.clamp(density, -1.0F, 1.0F));
|
||||
}
|
||||
|
||||
@Override public void sampleVolume(SamplerContext sample, DensityBuffer buffer, DensityVolume volume) {
|
||||
DensitySampler.sampleVolumeNaive(sample, buffer, volume, this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override public DensityFunction rewriteChildren(DfRewriteRule rule) {
|
||||
return new ExpansionDensity(rule.rewrite(source), island);
|
||||
}
|
||||
@Override public Interval range() { return Interval.of(-1.0F, 1.0F); }
|
||||
@Override public int domainAxes() { return ALL_AXES; }
|
||||
@Override public MapCodec<ExpansionDensity> codec() { return CODEC; }
|
||||
|
||||
private static final class ColumnCache {
|
||||
private static final int SIZE = 256;
|
||||
private ExpansionIsland selected;
|
||||
private final int[] xs = new int[SIZE], zs = new int[SIZE];
|
||||
private final ExpansionRelief.Column[] columns = new ExpansionRelief.Column[SIZE];
|
||||
|
||||
ExpansionRelief.Column get(ExpansionIsland island, int x, int z) {
|
||||
if (selected != island) {
|
||||
selected = island;
|
||||
Arrays.fill(columns, null);
|
||||
}
|
||||
int hash = x * 0x9e3779b9 ^ Integer.rotateLeft(z * 0x85ebca6b, 13);
|
||||
int slot = (hash ^ (hash >>> 16)) & (SIZE - 1);
|
||||
if (columns[slot] == null || xs[slot] != x || zs[slot] != z) {
|
||||
columns[slot] = ExpansionRelief.column(island, x, z);
|
||||
xs[slot] = x; zs[slot] = z;
|
||||
}
|
||||
return columns[slot];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable alpha.12 region contract. Directions place islands; climate remains an explicit choice. */
|
||||
public record ExpansionIsland(String id, String parent, String direction, String climate,
|
||||
int diameter, int centerX, int centerZ, long seed, String relief, String status, int generation, String reuseToken) {
|
||||
public static final int GENERATION = 12;
|
||||
public static final List<String> DIRECTIONS = List.of("north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest");
|
||||
public static final List<String> CLIMATES = List.of("temperate", "boreal", "glacial", "cold_humid", "humid", "tropical", "warm", "arid", "dry", "cold_dry", "autumn", "cherry", "lost_city");
|
||||
public static final List<String> RELIEFS = List.of("natural", "peaks", "canyon", "volcano");
|
||||
public static final List<Integer> LEGACY_DIAMETERS = List.of(64, 128, 256, 512);
|
||||
public static final List<Integer> DIAMETERS = List.of(64, 128, 256, 512, 1024);
|
||||
public static final int DECORATION_MARGIN = 32;
|
||||
public static final int GENERATION_HALO = 176;
|
||||
public static final Codec<ExpansionIsland> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
Codec.STRING.fieldOf("id").forGetter(ExpansionIsland::id),
|
||||
Codec.STRING.fieldOf("parent").forGetter(ExpansionIsland::parent),
|
||||
Codec.STRING.fieldOf("direction").forGetter(ExpansionIsland::direction),
|
||||
Codec.STRING.fieldOf("climate").forGetter(ExpansionIsland::climate),
|
||||
Codec.INT.fieldOf("diameter").forGetter(ExpansionIsland::diameter),
|
||||
Codec.INT.fieldOf("center_x").forGetter(ExpansionIsland::centerX),
|
||||
Codec.INT.fieldOf("center_z").forGetter(ExpansionIsland::centerZ),
|
||||
Codec.LONG.fieldOf("seed").forGetter(ExpansionIsland::seed),
|
||||
Codec.STRING.fieldOf("relief").forGetter(ExpansionIsland::relief),
|
||||
Codec.STRING.fieldOf("status").forGetter(ExpansionIsland::status),
|
||||
Codec.INT.fieldOf("generation").forGetter(ExpansionIsland::generation),
|
||||
Codec.STRING.optionalFieldOf("reuse_token", "").forGetter(ExpansionIsland::reuseToken)
|
||||
).apply(i, ExpansionIsland::new));
|
||||
|
||||
public ExpansionIsland(String id, String parent, String direction, String climate, int diameter,
|
||||
int centerX, int centerZ, long seed, String relief, String status, int generation) {
|
||||
this(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, "");
|
||||
}
|
||||
|
||||
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 (!((spatialGeneration(generation) ? DIAMETERS : LEGACY_DIAMETERS).contains(diameter) || (id.equals("sanctuary") && (diameter == 724 || (generation == 13 || spatialGeneration(generation)) && 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 && !spatialGeneration(generation) && !(generation == 13 && id.equals("sanctuary")))) throw new IllegalArgumentException("Unsupported island state or generation");
|
||||
if (climate.equals("lost_city") && (!spatialGeneration(generation) || id.equals("sanctuary"))) throw new IllegalArgumentException("Lost City requires a generation 14, 15, 16 or 17 expansion");
|
||||
if (reuseToken == null || !reuseToken.isEmpty() && (!spatialGeneration(generation) || id.equals("sanctuary") || !reuseToken.matches("[0-9a-f]{64}")))
|
||||
throw new IllegalArgumentException("Invalid void reuse certificate token");
|
||||
}
|
||||
|
||||
public static ExpansionIsland origin(int diameter, long seed) {
|
||||
return origin(diameter, seed, GENERATION);
|
||||
}
|
||||
public static ExpansionIsland origin(int diameter, long seed, int generation) {
|
||||
return new ExpansionIsland("sanctuary", "sanctuary", "north", "temperate", diameter, 0, 0, seed, "natural", "ready", generation);
|
||||
}
|
||||
|
||||
public static boolean spatialGeneration(int generation) { return generation == 14 || generation == 15 || generation == 16 || generation == 17; }
|
||||
public boolean origin() { return id.equals("sanctuary"); }
|
||||
public double referenceDiameter() { return origin() && diameter == 724 ? Math.sqrt(2) * 512 : 512; }
|
||||
public double horizontalScale() { return origin() ? 1.0 : diameter / referenceDiameter(); }
|
||||
public double radius() { return diameter / 2.0; }
|
||||
public int terrainRadius() { if ((generation == 13 || spatialGeneration(generation)) && 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 + (spatialGeneration(generation) ? 0 : GENERATION_HALO); }
|
||||
public boolean contains(int x, int z) { return Math.hypot((double) x - centerX, (double) z - centerZ) < terrainRadius(); }
|
||||
public boolean ownsChunk(int chunkX, int chunkZ) {
|
||||
int x = Math.clamp(centerX, chunkX * 16, chunkX * 16 + 15);
|
||||
int z = Math.clamp(centerZ, chunkZ * 16, chunkZ * 16 + 15);
|
||||
return Math.hypot((double) x - centerX, (double) z - centerZ) <= terrainRadius() + DECORATION_MARGIN;
|
||||
}
|
||||
public boolean conflicts(ExpansionIsland other) {
|
||||
if (spatialGeneration(generation) && generation == other.generation()) return ExpansionSpatialPlacement14.conflicts(this, other);
|
||||
return Math.abs((long) centerX - other.centerX) <= reservedRadius() + other.reservedRadius()
|
||||
&& Math.abs((long) centerZ - other.centerZ) <= reservedRadius() + other.reservedRadius();
|
||||
}
|
||||
public ExpansionIsland ready() { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, "ready", generation, reuseToken); }
|
||||
public ExpansionIsland withReuseToken(String token) { return new ExpansionIsland(id, parent, direction, climate, diameter, centerX, centerZ, seed, relief, status, generation, token); }
|
||||
public static String directionalClimate(String direction) {
|
||||
return switch (direction) {
|
||||
case "north" -> "boreal"; case "northeast" -> "cold_humid"; case "east" -> "humid";
|
||||
case "southeast" -> "tropical"; case "south" -> "warm"; case "southwest" -> "arid";
|
||||
case "west" -> "dry"; case "northwest" -> "cold_dry";
|
||||
default -> throw new IllegalArgumentException("Unknown direction");
|
||||
};
|
||||
}
|
||||
public static long deriveSeed(long seed, String id) {
|
||||
long h = seed ^ 0xA12E7A510L;
|
||||
for (int c : id.toCharArray()) h = (h ^ c) * 0x100000001b3L;
|
||||
h = (h ^ (h >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
h = (h ^ (h >>> 27)) * 0x94d049bb133111ebL;
|
||||
return h ^ (h >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.Strictness;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** A failed or missing existing journal is never replaced with a fresh expansion history. */
|
||||
public final class ExpansionJournal {
|
||||
public static final String FILE_NAME = "expansions.json";
|
||||
public static final int MAX_ISLANDS = 64;
|
||||
private static final int MAX_BYTES = 131_072;
|
||||
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
private static final Set<String> JOURNAL_FIELDS = Set.of("schema", "world_seed", "root_diameter", "islands");
|
||||
private static final Set<String> ISLAND_FIELDS = Set.of("id", "parent", "direction", "climate", "diameter", "center_x", "center_z", "seed", "relief", "status", "generation");
|
||||
private final Path directory;
|
||||
private final Path file;
|
||||
private final long seed;
|
||||
private final int rootDiameter;
|
||||
private final int rootGeneration;
|
||||
private final DirectorySync directorySync;
|
||||
private List<ExpansionIsland> islands;
|
||||
private boolean failed;
|
||||
|
||||
public ExpansionJournal(Path directory, long seed, int rootDiameter) throws IOException {
|
||||
this(directory, seed, rootDiameter, ExpansionIsland.GENERATION);
|
||||
}
|
||||
public ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration) throws IOException {
|
||||
this(directory, seed, rootDiameter, rootGeneration,
|
||||
directorySyncFor(directory.getFileSystem().getSeparator(), ExpansionJournal::forceDirectory));
|
||||
}
|
||||
|
||||
ExpansionJournal(Path directory, long seed, int rootDiameter, int rootGeneration, DirectorySync directorySync) throws IOException {
|
||||
if (rootGeneration != 12 && rootGeneration != 13 && !ExpansionIsland.spatialGeneration(rootGeneration)) throw new IOException("Unsupported root generation");
|
||||
if (rootDiameter != 512 && rootDiameter != 724 && !((rootGeneration == 13 || ExpansionIsland.spatialGeneration(rootGeneration)) && rootDiameter == 1024))
|
||||
throw new IOException("Unsupported expansion root diameter");
|
||||
this.rootGeneration = rootGeneration;
|
||||
this.directorySync = java.util.Objects.requireNonNull(directorySync);
|
||||
this.directory = directory.toAbsolutePath().normalize();
|
||||
this.file = this.directory.resolve(FILE_NAME);
|
||||
this.seed = seed;
|
||||
this.rootDiameter = rootDiameter;
|
||||
if (Files.exists(this.directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(this.directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Expansion journal path is not a directory");
|
||||
this.islands = read();
|
||||
} else {
|
||||
// Creating the directory is the bootstrap marker. An interrupted bootstrap requires repair.
|
||||
Files.createDirectories(this.directory.getParent());
|
||||
Files.createDirectory(this.directory);
|
||||
this.islands = List.of(ExpansionIsland.origin(rootDiameter, seed, rootGeneration));
|
||||
persist(this.islands);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized List<ExpansionIsland> islands() { return islands; }
|
||||
|
||||
/** False after uncertain persistence or external damage; only a successful reload can recover. */
|
||||
public synchronized boolean healthy() { return !failed; }
|
||||
|
||||
public synchronized void commit(List<ExpansionIsland> proposed) throws IOException {
|
||||
if (failed) throw new IOException("Expansion journal failed earlier; reload and inspect it before another expansion");
|
||||
List<ExpansionIsland> next = validate(proposed);
|
||||
if (next.size() < islands.size()) throw new IOException("Existing expansion regions cannot be removed");
|
||||
for (int i = 0; i < islands.size(); i++) {
|
||||
ExpansionIsland previous = islands.get(i), replacement = next.get(i);
|
||||
if (!previous.equals(replacement) && !(previous.status().equals("reserved") && previous.ready().equals(replacement))) {
|
||||
throw new IOException("Existing expansion region cannot be changed: " + previous.id());
|
||||
}
|
||||
}
|
||||
for (int i = islands.size(); i < next.size(); i++) {
|
||||
if (!next.get(i).status().equals("reserved")) throw new IOException("New expansion region must first be reserved");
|
||||
}
|
||||
// Detect another writer, a removed file or external damage before overwriting any history.
|
||||
try {
|
||||
if (!read().equals(islands)) throw new IOException("Expansion journal changed outside this instance");
|
||||
} catch (IOException e) {
|
||||
failed = true;
|
||||
throw e;
|
||||
}
|
||||
if (next.equals(islands)) return;
|
||||
persist(next);
|
||||
islands = next;
|
||||
}
|
||||
|
||||
private void persist(List<ExpansionIsland> next) throws IOException {
|
||||
Path temporary = null;
|
||||
try {
|
||||
byte[] bytes = (JSON.toJson(encode(next)) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
temporary = Files.createTempFile(directory, ".expansions-", ".tmp");
|
||||
try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
// No fallback to a non-atomic replacement: failure must remain visible to the caller.
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
temporary = null;
|
||||
directorySync.force(directory);
|
||||
if (!read().equals(next)) throw new IOException("Expansion journal verification disagrees with the committed plan");
|
||||
} catch (IOException | RuntimeException e) {
|
||||
failed = true;
|
||||
if (e instanceof IOException io) throw io;
|
||||
throw new IOException("Could not commit expansion journal", e);
|
||||
} finally {
|
||||
if (temporary != null) {
|
||||
try { Files.deleteIfExists(temporary); } catch (IOException ignored) { /* The original failure remains authoritative. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface DirectorySync { void force(Path directory) throws IOException; }
|
||||
|
||||
static DirectorySync directorySyncFor(String separator, DirectorySync supportedSync) {
|
||||
// Windows FileChannel cannot open directory handles. Only this additional
|
||||
// directory flush is omitted: file force, atomic replacement and read-back remain mandatory.
|
||||
return "\\".equals(separator) ? directory -> {} : supportedSync;
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
try (FileChannel parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
|
||||
}
|
||||
|
||||
private List<ExpansionIsland> read() throws IOException {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Existing expansion directory has no regular journal file");
|
||||
if (Files.size(file) > MAX_BYTES) throw new IOException("Expansion journal exceeds its size limit");
|
||||
try (JsonReader reader = new JsonReader(new StringReader(Files.readString(file, StandardCharsets.UTF_8)))) {
|
||||
reader.setStrictness(Strictness.STRICT);
|
||||
JsonObject root = readJson(reader, 0).getAsJsonObject();
|
||||
if (reader.peek() != JsonToken.END_DOCUMENT) throw new IOException("Trailing data in expansion journal");
|
||||
requireFields(root, JOURNAL_FIELDS);
|
||||
if (integer(root, "schema") != 1 || number(root, "world_seed").longValueExact() != seed || integer(root, "root_diameter") != rootDiameter) {
|
||||
throw new IOException("Expansion journal schema, world seed or root diameter does not match");
|
||||
}
|
||||
JsonArray entries = root.getAsJsonArray("islands");
|
||||
List<ExpansionIsland> decoded = new ArrayList<>();
|
||||
for (JsonElement element : entries) {
|
||||
JsonObject entry = element.getAsJsonObject();
|
||||
var fields = new java.util.HashSet<>(ISLAND_FIELDS);
|
||||
if (ExpansionIsland.spatialGeneration(rootGeneration) && entry.has("reuse_token")) fields.add("reuse_token");
|
||||
requireFields(entry, fields);
|
||||
decoded.add(new ExpansionIsland(string(entry, "id"), string(entry, "parent"), string(entry, "direction"), string(entry, "climate"),
|
||||
integer(entry, "diameter"), integer(entry, "center_x"), integer(entry, "center_z"), number(entry, "seed").longValueExact(),
|
||||
string(entry, "relief"), string(entry, "status"), integer(entry, "generation"),
|
||||
entry.has("reuse_token") ? string(entry, "reuse_token") : ""));
|
||||
}
|
||||
return validate(decoded);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid expansion journal", e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ExpansionIsland> validate(List<ExpansionIsland> proposed) throws IOException {
|
||||
if (proposed == null || proposed.isEmpty() || proposed.size() > MAX_ISLANDS) throw new IOException("Expansion journal must contain 1 to 64 regions");
|
||||
List<ExpansionIsland> checked;
|
||||
try { checked = List.copyOf(proposed); } catch (RuntimeException e) { throw new IOException("Invalid expansion entries", e); }
|
||||
if (!checked.getFirst().equals(ExpansionIsland.origin(rootDiameter, seed, rootGeneration))) throw new IOException("Sanctuary root region is immutable");
|
||||
Map<String, ExpansionIsland> previous = new HashMap<>();
|
||||
previous.put("sanctuary", checked.getFirst());
|
||||
for (int i = 1; i < checked.size(); i++) {
|
||||
ExpansionIsland island = checked.get(i);
|
||||
if (island.generation() != (ExpansionIsland.spatialGeneration(rootGeneration) ? rootGeneration : 12)) throw new IOException("Expansion generation does not match its world contract");
|
||||
if (previous.containsKey(island.id())) throw new IOException("Duplicate expansion identifier: " + island.id());
|
||||
ExpansionIsland parent = previous.get(island.parent());
|
||||
if (parent == null || !parent.status().equals("ready")) throw new IOException("Expansion parent must be an earlier ready region: " + island.parent());
|
||||
if (island.seed() != ExpansionIsland.deriveSeed(seed, island.id())) throw new IOException("Expansion seed differs from its saved identity: " + island.id());
|
||||
long dx = (long) island.centerX() - parent.centerX(), dz = (long) island.centerZ() - parent.centerZ();
|
||||
if (!(ExpansionIsland.spatialGeneration(island.generation()) ? ExpansionSpatialPlacement14.followsDirection(island.direction(), dx, dz) : followsDirection(island.direction(), dx, dz))) throw new IOException("Expansion position does not follow its direction: " + island.id());
|
||||
for (ExpansionIsland other : previous.values()) if (island.conflicts(other)) throw new IOException("Expansion reservations overlap: " + island.id() + " / " + other.id());
|
||||
previous.put(island.id(), island);
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
|
||||
private static boolean followsDirection(String direction, long dx, long dz) {
|
||||
return switch (direction) {
|
||||
case "north" -> dx == 0 && dz < 0;
|
||||
case "northeast" -> dx > 0 && dz == -dx;
|
||||
case "east" -> dx > 0 && dz == 0;
|
||||
case "southeast" -> dx > 0 && dz == dx;
|
||||
case "south" -> dx == 0 && dz > 0;
|
||||
case "southwest" -> dx < 0 && dz == -dx;
|
||||
case "west" -> dx < 0 && dz == 0;
|
||||
case "northwest" -> dx < 0 && dz == dx;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private JsonObject encode(List<ExpansionIsland> entries) {
|
||||
JsonObject root = new JsonObject();
|
||||
root.addProperty("schema", 1); root.addProperty("world_seed", seed); root.addProperty("root_diameter", rootDiameter);
|
||||
JsonArray array = new JsonArray();
|
||||
for (ExpansionIsland island : entries) {
|
||||
JsonObject entry = new JsonObject();
|
||||
entry.addProperty("id", island.id()); entry.addProperty("parent", island.parent());
|
||||
entry.addProperty("direction", island.direction()); entry.addProperty("climate", island.climate());
|
||||
entry.addProperty("diameter", island.diameter()); entry.addProperty("center_x", island.centerX()); entry.addProperty("center_z", island.centerZ());
|
||||
entry.addProperty("seed", island.seed()); entry.addProperty("relief", island.relief()); entry.addProperty("status", island.status());
|
||||
entry.addProperty("generation", island.generation());
|
||||
if (!island.reuseToken().isEmpty()) entry.addProperty("reuse_token", island.reuseToken());
|
||||
array.add(entry);
|
||||
}
|
||||
root.add("islands", array);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JsonElement readJson(JsonReader reader, int depth) throws IOException {
|
||||
if (depth > 4) throw new IOException("Expansion journal nesting exceeds its schema");
|
||||
return switch (reader.peek()) {
|
||||
case BEGIN_OBJECT -> {
|
||||
reader.beginObject(); JsonObject object = new JsonObject();
|
||||
while (reader.hasNext()) {
|
||||
String name = reader.nextName();
|
||||
if (object.has(name)) throw new IOException("Duplicate JSON field: " + name);
|
||||
if (object.size() >= 16) throw new IOException("Too many journal fields");
|
||||
object.add(name, readJson(reader, depth + 1));
|
||||
}
|
||||
reader.endObject(); yield object;
|
||||
}
|
||||
case BEGIN_ARRAY -> {
|
||||
reader.beginArray(); JsonArray array = new JsonArray();
|
||||
while (reader.hasNext()) {
|
||||
if (array.size() >= MAX_ISLANDS) throw new IOException("Too many expansion regions");
|
||||
array.add(readJson(reader, depth + 1));
|
||||
}
|
||||
reader.endArray(); yield array;
|
||||
}
|
||||
case STRING -> new JsonPrimitive(reader.nextString());
|
||||
case NUMBER -> new JsonPrimitive(new BigDecimal(reader.nextString()));
|
||||
case BOOLEAN -> new JsonPrimitive(reader.nextBoolean());
|
||||
case NULL -> { reader.nextNull(); yield JsonNull.INSTANCE; }
|
||||
default -> throw new IOException("Unexpected expansion journal token");
|
||||
};
|
||||
}
|
||||
|
||||
private static void requireFields(JsonObject value, Set<String> fields) throws IOException {
|
||||
if (!value.keySet().equals(fields)) throw new IOException("Missing or unknown expansion journal fields");
|
||||
}
|
||||
private static String string(JsonObject value, String name) throws IOException {
|
||||
JsonPrimitive primitive = value.getAsJsonPrimitive(name);
|
||||
if (!primitive.isString()) throw new IOException("Expected string field: " + name);
|
||||
return primitive.getAsString();
|
||||
}
|
||||
private static BigDecimal number(JsonObject value, String name) throws IOException {
|
||||
JsonPrimitive primitive = value.getAsJsonPrimitive(name);
|
||||
if (!primitive.isNumber()) throw new IOException("Expected numeric field: " + name);
|
||||
return primitive.getAsBigDecimal();
|
||||
}
|
||||
private static int integer(JsonObject value, String name) throws IOException { return number(value, name).intValueExact(); }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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);
|
||||
int rootGeneration = islands.getFirst().generation();
|
||||
int generation = ExpansionIsland.spatialGeneration(rootGeneration) ? rootGeneration : ExpansionIsland.GENERATION;
|
||||
if ((climate.equals("lost_city") || diameter == 1024) && !ExpansionIsland.spatialGeneration(generation)) throw new IllegalArgumentException("city_generation_required");
|
||||
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", generation);
|
||||
if (ExpansionIsland.spatialGeneration(generation)) return ExpansionSpatialPlacement14.candidates(islands, parent, sample, minimumDistance);
|
||||
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", generation);
|
||||
if (islands.stream().noneMatch(candidate::conflicts)) candidates.add(candidate);
|
||||
}
|
||||
return List.copyOf(candidates);
|
||||
}
|
||||
|
||||
public static void validateDistance(int minimumDistance) {
|
||||
if (minimumDistance < 0 || minimumDistance > MAX_DISTANCE) throw new IllegalArgumentException("invalid_distance");
|
||||
}
|
||||
|
||||
public static double distanceFromParent(ExpansionIsland island, ExpansionIsland parent) {
|
||||
return Math.hypot((double) island.centerX() - parent.centerX(), (double) island.centerZ() - parent.centerZ());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
/** A quick request keeps its draw across placement retries; success records the size in the journal. */
|
||||
public final class ExpansionRandomSize {
|
||||
private ExpansionRandomSize() {}
|
||||
|
||||
public static int choose(long worldSeed, String islandId) {
|
||||
int draw = (int) Long.remainderUnsigned(ExpansionIsland.deriveSeed(worldSeed ^ 0xA14D1A6E7L, islandId), 100);
|
||||
if (draw < 40) return 64;
|
||||
if (draw < 70) return 128;
|
||||
if (draw < 88) return 256;
|
||||
if (draw < 97) return 512;
|
||||
return 1024;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
/** Pure alpha.12 coordinate deformation. Existing generators and the lower root island are unchanged. */
|
||||
public final class ExpansionRelief {
|
||||
public static final double VERTICAL_ANCHOR = 170.0;
|
||||
public static final double UPPER_START = 160.0;
|
||||
public static final double UPPER_TRANSITION = 144.0;
|
||||
public static final double CRATER_FLOOR = 188.0;
|
||||
|
||||
/** Weights describe geography only; no block, liquid or structure is placed here. */
|
||||
public record Column(double uplift, double canyonWeight, double craterWeight) {}
|
||||
|
||||
private ExpansionRelief() {}
|
||||
|
||||
public static double horizontalScale(ExpansionIsland island) {
|
||||
// The public 724 is rounded; the original population_10 frame is already sqrt(2)*512.
|
||||
return island.origin() ? 1.0 : island.horizontalScale();
|
||||
}
|
||||
|
||||
public static double verticalScale(ExpansionIsland island) {
|
||||
return island.origin() ? 1.0 : Math.max(0.4, Math.sqrt(island.horizontalScale()));
|
||||
}
|
||||
|
||||
public static int sourceX(ExpansionIsland island, int x) {
|
||||
return (int) Math.round(((double) x - island.centerX()) / horizontalScale(island));
|
||||
}
|
||||
|
||||
public static int sourceZ(ExpansionIsland island, int z) {
|
||||
return (int) Math.round(((double) z - island.centerZ()) / horizontalScale(island));
|
||||
}
|
||||
|
||||
/** Reference Y before upper deformation; the inverse projects a source altitude into the island. */
|
||||
public static double referenceY(ExpansionIsland island, double y) {
|
||||
return VERTICAL_ANCHOR + (y - VERTICAL_ANCHOR) / verticalScale(island);
|
||||
}
|
||||
|
||||
public static double worldY(ExpansionIsland island, double referenceY) {
|
||||
return VERTICAL_ANCHOR + (referenceY - VERTICAL_ANCHOR) * verticalScale(island);
|
||||
}
|
||||
|
||||
public static double sourceY(ExpansionIsland island, Column column, double y) {
|
||||
double reference = referenceY(island, y);
|
||||
// |uplift| <= 48 and max smoothstep slope = 1.5/144, so this map is strictly
|
||||
// increasing. The whole source field, including its upper envelope, moves together.
|
||||
return reference - column.uplift() * smoothstep((reference - UPPER_START) / UPPER_TRANSITION);
|
||||
}
|
||||
|
||||
/** Additional cuts leave the source void empty, and never produce an unbounded positive density. */
|
||||
public static float sculpt(ExpansionIsland island, Column column, double y, float source) {
|
||||
if (source <= 0) return source;
|
||||
double reference = referenceY(island, y);
|
||||
double canyon = 2.1 * column.canyonWeight() * smoothstep((reference - 120.0) / 34.0);
|
||||
double crater = 2.1 * column.craterWeight() * smoothstep((reference - CRATER_FLOOR) / 14.0);
|
||||
return (float) Math.clamp(source - Math.max(canyon, crater), -1.0, 1.0);
|
||||
}
|
||||
|
||||
/** True only in the inner volcanic bowl; the material pass may use this without guessing from blocks. */
|
||||
public static boolean inCrater(ExpansionIsland island, int x, int z) {
|
||||
return island.relief().equals("volcano") && column(island, x, z).craterWeight() >= 0.95;
|
||||
}
|
||||
|
||||
public static int craterFloorY(ExpansionIsland island) {
|
||||
return (int) Math.floor(worldY(island, CRATER_FLOOR));
|
||||
}
|
||||
|
||||
public static Column column(ExpansionIsland island, int x, int z) {
|
||||
double dx = (double) x - island.centerX(), dz = (double) z - island.centerZ();
|
||||
double radius = island.radius();
|
||||
double radial = Math.hypot(dx, dz) / radius;
|
||||
double edge = 1.0 - smoothstep((radial - 0.70) / 0.30);
|
||||
if (edge <= 0) return new Column(0, 0, 0);
|
||||
|
||||
long seed = island.seed();
|
||||
double wavelength = Math.max(16.0, radius * 0.40);
|
||||
double broad = noise(seed, dx / wavelength, dz / wavelength);
|
||||
double detail = noise(seed ^ 0xA12DE7A1L, dx / (wavelength * 0.43), dz / (wavelength * 0.43));
|
||||
double uplift;
|
||||
double canyon = 0.0, crater = 0.0;
|
||||
switch (island.relief()) {
|
||||
case "natural" -> {
|
||||
double ridge = Math.pow(1.0 - Math.abs(broad), 4.0);
|
||||
uplift = Math.clamp(12.0 * broad + 5.0 * detail + 10.0 * Math.max(0.0, ridge - 0.5), -20.0, 20.0);
|
||||
}
|
||||
case "peaks" -> {
|
||||
double ridge = Math.pow(1.0 - Math.abs(broad), 3.0);
|
||||
uplift = Math.clamp(10.0 * detail + 44.0 * ridge, -16.0, 48.0);
|
||||
}
|
||||
case "canyon" -> {
|
||||
uplift = 8.0 * detail + 16.0 * Math.max(0.0, broad);
|
||||
double angle = unit(seed ^ 0xCA1201L) * Math.PI * 2.0;
|
||||
double along = dx * Math.cos(angle) + dz * Math.sin(angle);
|
||||
double sideways = -dx * Math.sin(angle) + dz * Math.cos(angle);
|
||||
double phase = unit(seed ^ 0xCA1202L) * Math.PI * 2.0;
|
||||
double curve = radius * 0.085 * (Math.sin(along / (radius * 0.36) + phase) - Math.sin(phase));
|
||||
double halfWidth = Math.clamp(radius * 0.035, 2.25, 12.0);
|
||||
canyon = (1.0 - smoothstep((Math.abs(sideways - curve) - halfWidth) / (halfWidth * 0.75)))
|
||||
* (1.0 - smoothstep((Math.abs(along) / radius - 0.62) / 0.20));
|
||||
}
|
||||
case "volcano" -> {
|
||||
// A raised annulus surrounds a depressed centre; this profile changes the
|
||||
// silhouette and cuts an actual crater, independently of its later palette.
|
||||
double coneRadius = radial + 0.018 * detail;
|
||||
double cone = Math.pow(Math.max(0.0, 1.0 - coneRadius / 0.68), 1.35);
|
||||
double hollow = 1.0 - smoothstep((radial - 0.08) / 0.15);
|
||||
uplift = Math.clamp(48.0 * cone * (1.0 - 0.90 * hollow) + 3.0 * detail, -6.0, 48.0);
|
||||
crater = 1.0 - smoothstep((radial - 0.10) / 0.09);
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unknown relief " + island.relief());
|
||||
}
|
||||
return new Column(uplift * edge, canyon * edge, crater * edge);
|
||||
}
|
||||
|
||||
/** Smooth coherent value noise: no mutable RNG, global map, chunk boundaries or order dependence. */
|
||||
private static double noise(long seed, double x, double z) {
|
||||
long ix = (long) Math.floor(x), iz = (long) Math.floor(z);
|
||||
double tx = smoothstep(x - ix), tz = smoothstep(z - iz);
|
||||
double a = lattice(seed, ix, iz), b = lattice(seed, ix + 1, iz);
|
||||
double c = lattice(seed, ix, iz + 1), d = lattice(seed, ix + 1, iz + 1);
|
||||
return (a + (b - a) * tx) + ((c + (d - c) * tx) - (a + (b - a) * tx)) * tz;
|
||||
}
|
||||
|
||||
private static double lattice(long seed, long x, long z) {
|
||||
return unit(seed ^ x * 0x9e3779b97f4a7c15L ^ z * 0x632be59bd9b4e019L) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
private static double unit(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
|
||||
private static double smoothstep(double value) {
|
||||
double t = Math.clamp(value, 0.0, 1.0);
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
/** Set only after an authorized certificate was consumed, or its native saved marker was verified. */
|
||||
public interface ExpansionReuseMarker {
|
||||
String sanctuary$reuseMarker();
|
||||
void sanctuary$reuseMarker(String token);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
|
||||
/** Transient serialization state; never inferred merely from a chunk's location. */
|
||||
public interface ExpansionReuseSerializedData {
|
||||
void sanctuary$originalReuseNbt(CompoundTag tag);
|
||||
void sanctuary$serializedReuseMarker(String token);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.TicketType;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Server-owned append-only admission and bounded preparation; certified void reuse starts on reload. */
|
||||
public final class ExpansionRuntime {
|
||||
private static final Map<ServerLevel, Session> SESSIONS = new HashMap<>();
|
||||
private static final TicketType PREPARATION = new TicketType(0L, 2 | 8);
|
||||
private ExpansionRuntime() {}
|
||||
public static void register() {
|
||||
Registry.register(BuiltInRegistries.TICKET_TYPE, SanctuaryMod.id("expansion_preparation"), PREPARATION);
|
||||
ServerLevelEvents.LOAD.register((server, level) -> {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)) return;
|
||||
if (level.dimension() != Level.OVERWORLD) throw new IllegalStateException("The expansion laboratory is Overworld only");
|
||||
try {
|
||||
var directory = server.getWorldPath(LevelResource.ROOT).resolve("data/" + generator.journalDirectory());
|
||||
if (server.getWorldData().overworldData().isInitialized()
|
||||
&& !java.nio.file.Files.isDirectory(directory, java.nio.file.LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Initialized laboratory world is missing its expansion journal directory");
|
||||
var journal = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
|
||||
ExpansionVoidReuse.open(level, generator, journal.islands());
|
||||
generator.initialize(level, journal.islands());
|
||||
var session = new Session(level, generator, journal);
|
||||
SESSIONS.put(level, session);
|
||||
for (var island : journal.islands()) if (island.status().equals("reserved")) session.enqueue(island);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot safely open the Sanctuary expansion journal", e);
|
||||
}
|
||||
});
|
||||
ServerTickEvents.END_LEVEL_TICK.register(level -> {
|
||||
Session session = SESSIONS.get(level);
|
||||
if (session != null) session.tick();
|
||||
});
|
||||
ServerLevelEvents.UNLOAD.register((server, level) -> {
|
||||
Session session = SESSIONS.remove(level);
|
||||
if (session != null) session.releaseTicket();
|
||||
ExpansionVoidReuse.close(level);
|
||||
});
|
||||
ServerLifecycleEvents.SERVER_STOPPED.register(server -> SESSIONS.clear());
|
||||
}
|
||||
public static Session session(ServerLevel level) {
|
||||
Session session = SESSIONS.get(level);
|
||||
if (session == null) throw new IllegalArgumentException("laboratory_required");
|
||||
return session;
|
||||
}
|
||||
public static final class Session {
|
||||
private final ServerLevel level;
|
||||
private final ExpansionChunkGenerator generator;
|
||||
private final ExpansionJournal journal;
|
||||
private final ArrayDeque<Job> jobs = new ArrayDeque<>();
|
||||
private String failure = "";
|
||||
private boolean journalUnavailableNotified;
|
||||
private Session(ServerLevel level, ExpansionChunkGenerator generator, ExpansionJournal journal) {
|
||||
this.level = level; this.generator = generator; this.journal = journal;
|
||||
}
|
||||
public List<ExpansionIsland> islands() { return journal.islands(); }
|
||||
public ExpansionIsland island(String id) {
|
||||
return islands().stream().filter(i -> i.id().equals(id)).findFirst().orElseThrow(() -> new IllegalArgumentException("unknown_island"));
|
||||
}
|
||||
public String progress() {
|
||||
Job job = jobs.peek();
|
||||
return job == null ? (failure.isEmpty() ? "idle" : failure) : job.island.id() + ": " + job.index + "/" + job.chunks.size();
|
||||
}
|
||||
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief) {
|
||||
return candidate(id, parentId, direction, climate, diameter, relief, 0);
|
||||
}
|
||||
public ExpansionIsland candidate(String id, String parentId, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) {
|
||||
return ExpansionPlacement.candidates(level.getSeed(), islands(), id, parentId, direction, climate, diameter, relief, minimumDistance)
|
||||
.stream().findFirst().orElseThrow(() -> new IllegalArgumentException("no_free_space"));
|
||||
}
|
||||
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief) throws IOException {
|
||||
return create(id, parent, direction, climate, diameter, relief, 0);
|
||||
}
|
||||
public ExpansionIsland create(String id, String parent, String direction, String climate, int diameter, String relief,
|
||||
int minimumDistance) throws IOException {
|
||||
long deadline = ExpansionAdmission.searchDeadline();
|
||||
requireHealthyJournal();
|
||||
ExpansionPlacement.validateDistance(minimumDistance);
|
||||
var existing = islands().stream().filter(i -> i.id().equals(id)).findFirst();
|
||||
if (existing.isPresent()) {
|
||||
var value = existing.get();
|
||||
if (!value.parent().equals(parent) || !value.direction().equals(direction) || !value.climate().equals(climate)
|
||||
|| value.diameter() != diameter || !value.relief().equals(relief)
|
||||
|| (minimumDistance > 0 && ExpansionPlacement.distanceFromParent(value, island(parent)) < minimumDistance))
|
||||
throw new IllegalArgumentException("id_conflict");
|
||||
return value;
|
||||
}
|
||||
if (islands().size() >= 64) throw new IllegalArgumentException("island_limit");
|
||||
var candidates = ExpansionPlacement.candidates(level.getSeed(), islands(), id, parent, direction, climate, diameter, relief, minimumDistance);
|
||||
for (var value : candidates) {
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
try {
|
||||
var inspection = ExpansionIsland.spatialGeneration(value.generation()) ? ExpansionVoidReuse.inspect(level, value, deadline) : null;
|
||||
if (inspection == null) ExpansionAdmission.verify(level, value, deadline);
|
||||
if (probe(value) == null) throw new IllegalArgumentException("no_ground");
|
||||
var admitted = inspection == null ? value : value.withReuseToken(ExpansionVoidReuse.persist(inspection));
|
||||
var next = new ArrayList<>(islands());
|
||||
next.add(admitted);
|
||||
if (inspection == null) ExpansionAdmission.verifyHolders(level, value, deadline);
|
||||
commitJournal(next); // Durability and re-read come before worldgen sees the island.
|
||||
if (!admitted.reuseToken().isEmpty()) ExpansionVoidReuse.deferred(level, admitted);
|
||||
publishActiveIslands();
|
||||
failure = "";
|
||||
if (ExpansionVoidReuse.requiresReload(level, admitted.id())) failure = admitted.id() + ": reload_required";
|
||||
else enqueue(admitted);
|
||||
return admitted;
|
||||
} catch (ExpansionAdmission.OccupiedException occupied) {
|
||||
// IO failures, timeouts and unsuitable terrain stop this request without a new draw.
|
||||
} finally {
|
||||
generator.discardUnpublishedContext(value);
|
||||
}
|
||||
}
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
throw new IllegalArgumentException("no_free_space");
|
||||
}
|
||||
public void resume(String id) {
|
||||
requireHealthyJournal();
|
||||
var island = island(id);
|
||||
if (!island.status().equals("reserved")) return;
|
||||
if (ExpansionVoidReuse.requiresReload(level, id)) throw new IllegalArgumentException("reload_required");
|
||||
failure = "";
|
||||
enqueue(island);
|
||||
}
|
||||
private void enqueue(ExpansionIsland island) {
|
||||
String reuseFailure = ExpansionVoidReuse.failure(level, island.id());
|
||||
if (reuseFailure != null) { failure = island.id() + ": " + reuseFailure; return; }
|
||||
if (ExpansionVoidReuse.requiresReload(level, island.id())) return;
|
||||
if (jobs.stream().noneMatch(j -> j.island.id().equals(island.id()))) jobs.add(new Job(island));
|
||||
}
|
||||
private void publishActiveIslands() {
|
||||
generator.setIslands(journal.islands().stream().filter(i -> !ExpansionVoidReuse.requiresReload(level, i.id())).toList());
|
||||
}
|
||||
private void requireHealthyJournal() {
|
||||
if (!journal.healthy()) {
|
||||
stopForJournalFailure(null);
|
||||
throw new IllegalStateException("journal_unavailable");
|
||||
}
|
||||
}
|
||||
private void commitJournal(List<ExpansionIsland> next) throws IOException {
|
||||
try {
|
||||
journal.commit(next);
|
||||
} catch (IOException exception) {
|
||||
if (!journal.healthy()) {
|
||||
stopForJournalFailure(exception);
|
||||
throw new IllegalStateException("journal_unavailable", exception);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
private void stopForJournalFailure(Throwable cause) {
|
||||
releaseTicket();
|
||||
jobs.clear(); // Saved reservations remain intact and are enqueued only after a verified reload.
|
||||
failure = "journal_unavailable: reload required";
|
||||
if (journalUnavailableNotified) return;
|
||||
journalUnavailableNotified = true;
|
||||
SanctuaryMod.LOGGER.error("Expansion journal unavailable; all preparation stopped and reload is required", cause);
|
||||
for (var player : level.players()) if (player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER))
|
||||
player.sendSystemMessage(Component.translatable("sanctuary.expansion.journal_unavailable"));
|
||||
}
|
||||
/** Base-terrain prospecting is read-only: no chunk requests or writes. */
|
||||
public BlockPos probe(ExpansionIsland island) {
|
||||
var delegate = generator.delegate(island);
|
||||
var random = generator.randomState(island);
|
||||
int stride = Math.max(4, island.diameter() / 32);
|
||||
int limit = Math.max(stride, (int) (island.radius() * 0.75));
|
||||
for (int r = 0; r <= limit; r += stride) for (int x = -r; x <= r; x += stride) for (int z = -r; z <= r; z += stride) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) != r) continue;
|
||||
int wx = island.centerX() + x, wz = island.centerZ() + z;
|
||||
int y = delegate.getBaseHeight(wx, wz, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, level, random);
|
||||
if (y > 24 && y < 365) return new BlockPos(wx, y, wz);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public BlockPos landing(String id) {
|
||||
var island = island(id);
|
||||
if (!island.status().equals("ready")) throw new IllegalArgumentException("not_ready");
|
||||
int stride = Math.max(4, island.diameter() / 32);
|
||||
int limit = (int) (island.radius() * 0.85);
|
||||
for (int r = 0; r <= limit; r += stride) for (int x = -r; x <= r; x += stride) for (int z = -r; z <= r; z += stride) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) != r) continue;
|
||||
int wx = island.centerX() + x, wz = island.centerZ() + z;
|
||||
// Only this already-open island is loaded; no block is changed for teleportation.
|
||||
level.getChunk(wx >> 4, wz >> 4);
|
||||
int top = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, wx, wz);
|
||||
for (int y = top; y > 24; y--) {
|
||||
var pos = new BlockPos(wx, y, wz);
|
||||
if (safeLanding(level, pos)) return pos;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("no_landing");
|
||||
}
|
||||
private void releaseTicket() {
|
||||
Job job = jobs.peek();
|
||||
if (job != null && job.ticket != null) {
|
||||
level.getChunkSource().removeTicketWithRadius(PREPARATION, job.ticket, 0);
|
||||
job.ticket = null;
|
||||
}
|
||||
}
|
||||
private void tick() {
|
||||
if (!journal.healthy()) {
|
||||
stopForJournalFailure(null);
|
||||
return;
|
||||
}
|
||||
Job job = jobs.peek();
|
||||
if (job == null) return;
|
||||
try {
|
||||
String reuseFailure = ExpansionVoidReuse.failure(level, job.island.id());
|
||||
if (reuseFailure != null) throw new IllegalStateException(reuseFailure);
|
||||
if (job.pending != null) {
|
||||
if (!job.pending.isDone()) return;
|
||||
job.pending.join();
|
||||
if (level.getChunkSource().getChunkNow(job.ticket.x(), job.ticket.z()) == null) throw new IllegalStateException("Chunk did not reach FULL");
|
||||
releaseTicket();
|
||||
job.pending = null;
|
||||
job.index++;
|
||||
}
|
||||
if (job.index == job.chunks.size()) {
|
||||
if (!ExpansionVoidReuse.complete(level, job.island)) throw new IllegalStateException("void_reuse_incomplete");
|
||||
var next = islands().stream().map(i -> i.id().equals(job.island.id()) ? i.ready() : i).toList();
|
||||
commitJournal(next);
|
||||
publishActiveIslands();
|
||||
jobs.remove();
|
||||
SanctuaryMod.LOGGER.info("Expansion {} ready: {} FULL chunks", job.island.id(), job.index);
|
||||
for (var player : level.players()) if (player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER))
|
||||
player.sendSystemMessage(Component.translatable("sanctuary.expansion.ready", job.island.id(), job.index, job.island.id()));
|
||||
return;
|
||||
}
|
||||
job.ticket = job.chunks.get(job.index);
|
||||
job.pending = level.getChunkSource().addTicketAndLoadWithRadius(PREPARATION, job.ticket, 0);
|
||||
} catch (Exception exception) {
|
||||
if (!journal.healthy()) {
|
||||
stopForJournalFailure(exception);
|
||||
return;
|
||||
}
|
||||
releaseTicket();
|
||||
failure = job.island.id() + ": " + exception.getMessage();
|
||||
jobs.remove();
|
||||
SanctuaryMod.LOGGER.error("Expansion preparation stopped; reservation retained for resume: {}", job.island.id(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static final class Job {
|
||||
final ExpansionIsland island;
|
||||
final List<ChunkPos> chunks = new ArrayList<>();
|
||||
int index;
|
||||
ChunkPos ticket;
|
||||
CompletableFuture<?> pending;
|
||||
Job(ExpansionIsland island) {
|
||||
this.island = island;
|
||||
int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
for (int x = (island.centerX() - radius) >> 4; x <= (island.centerX() + radius) >> 4; x++)
|
||||
for (int z = (island.centerZ() - radius) >> 4; z <= (island.centerZ() + radius) >> 4; z++)
|
||||
if (!ExpansionIsland.spatialGeneration(island.generation()) || island.ownsChunk(x, z)) chunks.add(new ChunkPos(x, z));
|
||||
}
|
||||
}
|
||||
public static boolean safeLanding(ServerLevel level, BlockPos feet) {
|
||||
var floor = level.getBlockState(feet.below());
|
||||
return floor.isCollisionShapeFullBlock(level, feet.below())
|
||||
&& floor.getFluidState().isEmpty()
|
||||
&& !floor.is(net.minecraft.tags.BlockTags.LEAVES)
|
||||
&& !floor.is(net.minecraft.world.level.block.Blocks.MAGMA_BLOCK)
|
||||
&& !floor.is(net.minecraft.world.level.block.Blocks.CACTUS)
|
||||
&& level.getBlockState(feet).isAir() && level.getBlockState(feet.above()).isAir();
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Generation 14 incremental Poisson-disc rejection with variable island radii.
|
||||
* Accepted regions keep a minimum separation; candidate draws are reproducible and bounded.
|
||||
* The requested direction selects a 45-degree sector, independently of biome and climate.
|
||||
* Generation dependencies are read neighborhoods, not ownership reservations. */
|
||||
public final class ExpansionSpatialPlacement14 {
|
||||
public static final int MAX_ATTEMPTS = 32;
|
||||
// Distinct circular footprints must also avoid owning the same 16x16 corner chunk.
|
||||
private static final int CHUNK_CORNER_GUARD = 24;
|
||||
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 static final double HALF_SECTOR_TANGENT = Math.tan(Math.PI / 8);
|
||||
|
||||
private ExpansionSpatialPlacement14() {}
|
||||
|
||||
/** The caller validates IDs/profile choices before constructing the prototype.
|
||||
* Alternatives may overlap each other: only one is admitted, then added to islands.
|
||||
* Subsequent calls reject its complete write footprint, including pending reservations. */
|
||||
public static List<ExpansionIsland> candidates(List<ExpansionIsland> islands,
|
||||
ExpansionIsland parent, ExpansionIsland prototype, int minimumDistance) {
|
||||
if (!ExpansionIsland.spatialGeneration(parent.generation()) || prototype.generation() != parent.generation() || prototype.origin())
|
||||
throw new IllegalArgumentException("Spatial placement requires matching generation 14 or 15 expansions");
|
||||
if (!parent.status().equals("ready")) throw new IllegalArgumentException("parent_not_ready");
|
||||
if (!prototype.parent().equals(parent.id())) throw new IllegalArgumentException("unknown_island");
|
||||
ExpansionPlacement.validateDistance(minimumDistance);
|
||||
int direction = ExpansionIsland.DIRECTIONS.indexOf(prototype.direction());
|
||||
if (direction < 0) throw new IllegalArgumentException("invalid_direction");
|
||||
double angle = Math.atan2(DZ[direction], DX[direction]);
|
||||
double start = Math.max(minimumSeparation(parent, prototype), minimumDistance);
|
||||
double annulus = Math.max(64, writeRadius(prototype) * .70);
|
||||
long drawSeed = mix(prototype.seed() ^ parent.seed() ^ 0xA14D15C5EEDL);
|
||||
List<ExpansionIsland> result = new ArrayList<>();
|
||||
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
long draw = mix(drawSeed + attempt * 0x9E3779B97F4A7C15L);
|
||||
// Eight nearby candidates are exhausted before moving to each farther annulus.
|
||||
// Eighteen degrees leave snapping room inside the exact 22.5-degree sector.
|
||||
double candidateAngle = angle + (unit(draw) * 2 - 1) * Math.PI / 10;
|
||||
double distance = start + 24 + (attempt / 8 + unit(mix(draw ^ 0xD15C14L))) * annulus;
|
||||
long x = Math.round((parent.centerX() + Math.cos(candidateAngle) * distance) / 16) * 16;
|
||||
long z = Math.round((parent.centerZ() + Math.sin(candidateAngle) * distance) / 16) * 16;
|
||||
if (Math.abs(x) > ExpansionPlacement.MAX_DISTANCE || Math.abs(z) > ExpansionPlacement.MAX_DISTANCE) continue;
|
||||
if (!followsDirection(prototype.direction(), x - parent.centerX(), z - parent.centerZ())) continue;
|
||||
if (Math.hypot(x - parent.centerX(), z - parent.centerZ()) < minimumDistance) continue;
|
||||
ExpansionIsland candidate = new ExpansionIsland(prototype.id(), prototype.parent(), prototype.direction(),
|
||||
prototype.climate(), prototype.diameter(), (int) x, (int) z, prototype.seed(), prototype.relief(), "reserved", prototype.generation());
|
||||
if (islands.stream().noneMatch(saved -> conflicts(candidate, saved)) && !result.contains(candidate)) result.add(candidate);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** All coordinates writable by this island, including decoration spilling beyond its terrain. */
|
||||
public static int writeRadius(ExpansionIsland island) {
|
||||
return island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
}
|
||||
|
||||
/** Vanilla neighboring stages may read this far, without claiming or changing those chunks. */
|
||||
public static int readRadius(ExpansionIsland island) {
|
||||
return writeRadius(island) + ExpansionIsland.GENERATION_HALO;
|
||||
}
|
||||
|
||||
public static int minimumSeparation(ExpansionIsland a, ExpansionIsland b) {
|
||||
int relevantSize = a.origin() ? b.diameter() : b.origin() ? a.diameter() : Math.max(a.diameter(), b.diameter());
|
||||
return writeRadius(a) + writeRadius(b) + CHUNK_CORNER_GUARD + extraGap(relevantSize);
|
||||
}
|
||||
|
||||
public static boolean conflicts(ExpansionIsland a, ExpansionIsland b) {
|
||||
double dx = (double) a.centerX() - b.centerX(), dz = (double) a.centerZ() - b.centerZ();
|
||||
return dx * dx + dz * dz < Math.pow(minimumSeparation(a, b), 2);
|
||||
}
|
||||
|
||||
/** Direction validation for persisted v14 records; v12/v13 retain their exact-ray rule. */
|
||||
public static boolean followsDirection(String direction, long dx, long dz) {
|
||||
int ordinal = ExpansionIsland.DIRECTIONS.indexOf(direction);
|
||||
if (ordinal < 0) return false;
|
||||
double projection = (double) dx * DX[ordinal] + (double) dz * DZ[ordinal];
|
||||
double cross = (double) dx * DZ[ordinal] - (double) dz * DX[ordinal];
|
||||
return projection > 0 && Math.abs(cross) <= projection * HALF_SECTOR_TANGENT + 1.0e-9;
|
||||
}
|
||||
|
||||
private static int extraGap(int diameter) {
|
||||
if (diameter <= 64) return 0;
|
||||
if (diameter <= 128) return 16;
|
||||
if (diameter <= 256) return 48;
|
||||
if (diameter <= 512) return 96;
|
||||
return 160;
|
||||
}
|
||||
private static double unit(long value) { return (value >>> 11) * 0x1.0p-53; }
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.NoiseColumn;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
|
||||
/** Plan native starts before terrain and references; decoration and structure persistence remain vanilla. */
|
||||
public final class ExpansionStructures {
|
||||
private static final boolean DIAGNOSTICS = Boolean.getBoolean("sanctuary.test.expansion")
|
||||
|| Boolean.getBoolean("sanctuary.expansion.structureDiagnostics");
|
||||
private static final int CELL_CHUNKS = 8;
|
||||
private static final List<String> VILLAGES = List.of("village_plains", "village_desert", "village_savanna", "village_snowy", "village_taiga");
|
||||
private static final List<String> LANDMARKS = List.of("desert_pyramid", "jungle_pyramid", "swamp_hut");
|
||||
private static final List<String> CAMPS = List.of(
|
||||
"forest", "birch_forest", "old_growth_birch_forest", "old_growth_pine_taiga", "old_growth_spruce_taiga",
|
||||
"pale_garden", "savanna", "dappled_forest", "snowy_taiga", "cherry_grove", "swamp", "sparse_jungle",
|
||||
"meadow", "wooded_badlands", "flower_forest", "windswept_forest", "taiga", "bamboo_jungle"
|
||||
);
|
||||
|
||||
private ExpansionStructures() {}
|
||||
|
||||
public static void generate(ExpansionChunkGenerator generator, RegistryAccess registries,
|
||||
ChunkGeneratorStructureState state, StructureManager manager, ChunkAccess chunk,
|
||||
StructureTemplateManager templates, ResourceKey<Level> level) {
|
||||
if (!manager.shouldGenerateStructures()) return;
|
||||
int blockX = chunk.getPos().getMiddleBlockX(), blockZ = chunk.getPos().getMiddleBlockZ();
|
||||
ExpansionIsland island = generator.islandAt(blockX, blockZ);
|
||||
if (island == null) return;
|
||||
long roll = candidateRoll(island, chunk.getPos().x(), chunk.getPos().z());
|
||||
if (roll == 0 || Long.remainderUnsigned(roll, 100) >= 72) return;
|
||||
|
||||
RandomState randomState = generator.randomState(island);
|
||||
int surfaceY = generator.getBaseHeight(blockX, blockZ, Heightmap.Types.WORLD_SURFACE_WG, chunk, randomState) - 1;
|
||||
if (surfaceY < 48) {
|
||||
diagnostic(island, chunk, "no-surface", "height=" + surfaceY);
|
||||
return;
|
||||
}
|
||||
var sampler = randomState.createClimateSampler(SamplerContext.builder().enableCaches().build());
|
||||
Holder<Biome> biome = generator.structureBiome(generator.getBiomeSource().createResolver(sampler)
|
||||
.getNoiseBiome(blockX >> 2, surfaceY >> 2, blockZ >> 2));
|
||||
var registry = registries.lookupOrThrow(Registries.STRUCTURE);
|
||||
List<Holder<Structure>> villages = new ArrayList<>();
|
||||
// Villages remain optional and are never squeezed into the small formats.
|
||||
if (island.diameter() >= 256 && Long.remainderUnsigned(roll >>> 9, 100) < 36) {
|
||||
for (String name : VILLAGES) addMatching(villages, registry.get(Identifier.fromNamespaceAndPath("minecraft", name)).orElse(null), biome);
|
||||
}
|
||||
List<Holder<Structure>> landmarks = new ArrayList<>();
|
||||
for (String name : LANDMARKS) addMatching(landmarks, registry.get(Identifier.fromNamespaceAndPath("minecraft", name)).orElse(null), biome);
|
||||
for (String suffix : CAMPS) addMatching(landmarks,
|
||||
registry.get(Identifier.fromNamespaceAndPath("minecraft", "abandoned_camp_" + suffix)).orElse(null), biome);
|
||||
if (villages.isEmpty() && landmarks.isEmpty()) {
|
||||
diagnostic(island, chunk, "no-compatible-structure", "biome=" + biome.unwrapKey().orElseThrow().identifier() + " height=" + surfaceY);
|
||||
return;
|
||||
}
|
||||
// An eligible but unsupported village must not suppress a smaller compatible camp.
|
||||
// Both groups keep their original biome and foundation checks; one accepted start ends the site.
|
||||
for (List<Holder<Structure>> candidates : List.of(villages, landmarks)) {
|
||||
if (candidates.isEmpty()) continue;
|
||||
int selectedIndex = (int) Long.remainderUnsigned(roll >>> 19, candidates.size());
|
||||
for (int attempt = 0; attempt < candidates.size(); attempt++) {
|
||||
Holder<Structure> selected = candidates.get((selectedIndex + attempt) % candidates.size());
|
||||
Structure structure = selected.value();
|
||||
StructureStart previous = manager.getStartForStructure(structure, chunk);
|
||||
if (previous != null && previous.isValid()) return;
|
||||
String name = selected.unwrapKey().orElseThrow().identifier().toString();
|
||||
StructureStart start = structure.generate(selected, level, registries, generator, generator.getBiomeSource(),
|
||||
sampler, randomState, templates, island.seed(), chunk.getPos(), previous == null ? 0 : previous.getReferences(),
|
||||
chunk, candidateBiome -> structure.biomes().contains(generator.structureBiome(candidateBiome)));
|
||||
if (!start.isValid()) {
|
||||
diagnostic(island, chunk, "native-rejected", name + " biome=" + biome.unwrapKey().orElseThrow().identifier() + " height=" + surfaceY);
|
||||
continue;
|
||||
}
|
||||
boolean temple = name.equals("minecraft:desert_pyramid") || name.equals("minecraft:jungle_pyramid");
|
||||
String rejection = supportRejection(generator, island, chunk, randomState, start, temple);
|
||||
if (rejection != null) {
|
||||
diagnostic(island, chunk, "support-rejected", name + " " + rejection + " bounds=" + start.getBoundingBox());
|
||||
continue;
|
||||
}
|
||||
manager.setStartForStructure(structure, start, chunk);
|
||||
diagnostic(island, chunk, "accepted", name + " pieces=" + start.getPieces().size() + " bounds=" + start.getBoundingBox());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addMatching(List<Holder<Structure>> candidates, Holder<Structure> structure, Holder<Biome> biome) {
|
||||
if (structure != null && structure.value().biomes().contains(biome)) candidates.add(structure);
|
||||
}
|
||||
|
||||
/** One possible start in each 128-block square, salted by the persisted island seed. */
|
||||
private static long candidateRoll(ExpansionIsland island, int chunkX, int chunkZ) {
|
||||
int localX = chunkX - Math.floorDiv(island.centerX(), 16), localZ = chunkZ - Math.floorDiv(island.centerZ(), 16);
|
||||
int cellX = Math.floorDiv(localX, CELL_CHUNKS), cellZ = Math.floorDiv(localZ, CELL_CHUNKS);
|
||||
long roll = mix(island.seed() ^ 0xA1257A0C7L ^ (cellX * 0x9E3779B97F4A7C15L) ^ (cellZ * 0xC2B2AE3D27D4EB4FL));
|
||||
if (Math.floorMod(localX, CELL_CHUNKS) != (int) Long.remainderUnsigned(roll >>> 3, CELL_CHUNKS)
|
||||
|| Math.floorMod(localZ, CELL_CHUNKS) != (int) Long.remainderUnsigned(roll >>> 12, CELL_CHUNKS)) return 0;
|
||||
return roll;
|
||||
}
|
||||
|
||||
private static String supportRejection(ExpansionChunkGenerator generator, ExpansionIsland island, ChunkAccess chunk,
|
||||
RandomState randomState, StructureStart start, boolean temple) {
|
||||
// Native beard adaptation already expands this box by 12 blocks when applicable.
|
||||
BoundingBox total = start.getBoundingBox();
|
||||
if (generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator city
|
||||
&& city.lostCityPlan(island).intersects(total.minX(), total.minZ(), total.maxX(), total.maxZ()))
|
||||
return "occupied-city-district";
|
||||
if (generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator city
|
||||
&& (city.intersectsCityConstruction15(total.minX() - 1, total.minY() - (temple ? 24 : 12), total.minZ() - 1,
|
||||
total.maxX() + 1, total.maxY(), total.maxZ() + 1)
|
||||
|| city.intersectsCityConstruction16(total.minX() - 1, total.minY() - (temple ? 24 : 12), total.minZ() - 1,
|
||||
total.maxX() + 1, total.maxY(), total.maxZ() + 1)))
|
||||
return "occupied-city-underground-or-support";
|
||||
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 (generator instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator city
|
||||
&& city.intersectsExperimentalStructures(total.minX() - 1, total.minY() - (temple ? 24 : 12), total.minZ() - 1,
|
||||
total.maxX() + 1, total.maxY() + 4, total.maxZ() + 1)) return "experimental-structure";
|
||||
if (!inside(island, total.minX(), total.minZ(), 4) || !inside(island, total.maxX(), total.minZ(), 4)
|
||||
|| !inside(island, total.minX(), total.maxZ(), 4) || !inside(island, total.maxX(), total.maxZ(), 4)) return "outside-island";
|
||||
if (total.minY() < chunk.getMinY() || total.maxY() > chunk.getMaxY() - 4) return "outside-height";
|
||||
Map<Long, Ground> columns = new HashMap<>();
|
||||
int depth = temple ? 24 : 12;
|
||||
int allMin = Integer.MAX_VALUE, allMax = Integer.MIN_VALUE;
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
BoundingBox box = piece.getBoundingBox();
|
||||
int low = Integer.MAX_VALUE, high = Integer.MIN_VALUE;
|
||||
for (int z = box.minZ() - 1; z <= box.maxZ() + 1; z++) {
|
||||
for (int x = box.minX() - 1; x <= box.maxX() + 1; x++) {
|
||||
if (!inside(island, x, z, 4)) return "footprint-outside-island at " + x + "," + z;
|
||||
long key = ((long) x << 32) ^ (z & 0xffffffffL);
|
||||
Ground ground = columns.get(key);
|
||||
if (ground == null) {
|
||||
ground = ground(generator, chunk, randomState, x, z);
|
||||
columns.put(key, ground);
|
||||
}
|
||||
if (ground.top() < 48 || ground.thickness() < depth)
|
||||
return "foundation at " + x + "," + z + " top=" + ground.top() + " thickness=" + ground.thickness() + " required=" + depth;
|
||||
low = Math.min(low, ground.top());
|
||||
high = Math.max(high, ground.top());
|
||||
if (high - low > (temple ? 4 : 8)) return "piece-slope=" + (high - low) + " low=" + low + " high=" + high;
|
||||
}
|
||||
}
|
||||
allMin = Math.min(allMin, low);
|
||||
allMax = Math.max(allMax, high);
|
||||
if (allMax - allMin > 20) return "site-slope=" + (allMax - allMin);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void diagnostic(ExpansionIsland island, ChunkAccess chunk, String reason, String details) {
|
||||
if (DIAGNOSTICS) SanctuaryMod.LOGGER.info("Expansion structure {} seed {} chunk {},{} {}: {}",
|
||||
island.id(), island.seed(), chunk.getPos().x(), chunk.getPos().z(), reason, details);
|
||||
}
|
||||
|
||||
private static Ground ground(ExpansionChunkGenerator generator, ChunkAccess chunk, RandomState randomState, int x, int z) {
|
||||
NoiseColumn column = generator.getBaseColumn(x, z, chunk, randomState);
|
||||
int y = chunk.getMaxY();
|
||||
while (y >= chunk.getMinY() && column.getBlock(y).isAir()) y--;
|
||||
int top = y;
|
||||
// A wet, unsupported or hollow foundation is rejected before any start is saved.
|
||||
while (y >= chunk.getMinY() && top - y < 24 && column.getBlock(y).isSolid() && column.getBlock(y).getFluidState().isEmpty()) y--;
|
||||
return new Ground(top, top - y);
|
||||
}
|
||||
|
||||
private static boolean inside(ExpansionIsland island, int x, int z, int margin) {
|
||||
return Math.hypot((double) x - island.centerX(), (double) z - island.centerZ()) < island.terrainRadius() - margin;
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
|
||||
private record Ground(int top, int thickness) {}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/** Alpha.12 material pass on fresh terrain only. It never builds a floor, dam or extra island mass. */
|
||||
public final class ExpansionTerrain {
|
||||
private ExpansionTerrain() {}
|
||||
|
||||
/** Called once after the delegate finishes terrain and before decoration. No adjacent chunk is read. */
|
||||
public static void apply(ExpansionIsland island, ChunkAccess chunk) {
|
||||
boolean volcanic = island.relief().equals("volcano");
|
||||
boolean wet = !volcanic && (island.origin() || island.climate().equals("humid")
|
||||
|| island.climate().equals("tropical") || island.climate().equals("cold_humid"));
|
||||
if (!volcanic && !wet) return;
|
||||
|
||||
// Find against the original stone/deepslate before the volcanic palette replaces it.
|
||||
// This one seed-owned column is the only fluid candidate for the entire island.
|
||||
BlockPos pocket = retainedPocket(island, chunk, volcanic);
|
||||
if (volcanic) volcanicPalette(island, chunk);
|
||||
if (pocket == null) return;
|
||||
|
||||
chunk.setBlockState(pocket, (volcanic ? Blocks.LAVA : Blocks.WATER).defaultBlockState(), 0);
|
||||
chunk.markPosForPostProcessing(pocket);
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
|
||||
/** A diagnostic can inspect this column; it is a candidate, never a promise of fluid. Y is unspecified. */
|
||||
public static BlockPos pocketCandidate(ExpansionIsland island) {
|
||||
long seed = island.seed() ^ 0xA12F101DL;
|
||||
int dx, dz;
|
||||
if (island.relief().equals("volcano")) {
|
||||
// Even a 64-block island keeps (+/-2,+/-2) in the inner bowl. The maximum
|
||||
// offset is eleven, keeping both the candidate and its walls inside a chunk.
|
||||
int maximum = Math.clamp((int) Math.floor(island.radius() * 0.045), 2, 11);
|
||||
dx = (2 + (int) Math.floor(unit(seed) * (maximum - 1))) * (unit(seed + 1) < 0.5 ? -1 : 1);
|
||||
dz = (2 + (int) Math.floor(unit(seed + 2) * (maximum - 1))) * (unit(seed + 3) < 0.5 ? -1 : 1);
|
||||
} else {
|
||||
dx = (int) Math.round(island.radius() * (0.16 + 0.18 * unit(seed))) * (unit(seed + 1) < 0.5 ? -1 : 1);
|
||||
dz = (int) Math.round(island.radius() * (0.16 + 0.18 * unit(seed + 2))) * (unit(seed + 3) < 0.5 ? -1 : 1);
|
||||
}
|
||||
int x = insideChunk(island.centerX() + dx), z = insideChunk(island.centerZ() + dz);
|
||||
return new BlockPos(x, 0, z);
|
||||
}
|
||||
|
||||
private static BlockPos retainedPocket(ExpansionIsland island, ChunkAccess chunk, boolean volcanic) {
|
||||
BlockPos column = pocketCandidate(island);
|
||||
int x = column.getX(), z = column.getZ();
|
||||
if ((x >> 4) != chunk.getPos().x() || (z >> 4) != chunk.getPos().z()
|
||||
|| !island.contains(x, z) || (volcanic && !ExpansionRelief.inCrater(island, x, z))) return null;
|
||||
|
||||
int bottom = Math.max(chunk.getMinY() + 1, (int) Math.ceil(ExpansionRelief.worldY(island, volcanic ? 176.0 : 160.0)));
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int y = chunk.getMinY() + chunk.getHeight() - 2; y >= bottom; y--) {
|
||||
position.set(x, y, z);
|
||||
BlockState current = chunk.getBlockState(position);
|
||||
if (current.isAir()) continue;
|
||||
// Stop at the first real surface. Do not drill through soil, vegetation,
|
||||
// overhangs or an earlier fluid to reach an otherwise eligible cave below.
|
||||
if (!naturalRock(current) || !chunk.getBlockState(position.above()).isAir()) return null;
|
||||
BlockPos candidate = position.immutable();
|
||||
if (sealedSupport(chunk, candidate.below(), volcanic)
|
||||
&& sealedSupport(chunk, candidate.north(), volcanic)
|
||||
&& sealedSupport(chunk, candidate.south(), volcanic)
|
||||
&& sealedSupport(chunk, candidate.west(), volcanic)
|
||||
&& sealedSupport(chunk, candidate.east(), volcanic)) return candidate;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean sealedSupport(ChunkAccess chunk, BlockPos position, boolean volcanic) {
|
||||
BlockState support = chunk.getBlockState(position);
|
||||
// Full collision alone also accepts sand or ice, whose later falling/melting
|
||||
// would breach a pocket. Restrict all five walls to stable natural rock.
|
||||
boolean rock = naturalRock(support) || support.is(Blocks.ANDESITE) || support.is(Blocks.DIORITE)
|
||||
|| support.is(Blocks.GRANITE) || support.is(Blocks.TUFF) || support.is(Blocks.CALCITE)
|
||||
|| support.is(Blocks.BASALT) || support.is(Blocks.SMOOTH_BASALT) || support.is(Blocks.BLACKSTONE)
|
||||
|| support.is(Blocks.OBSIDIAN) || support.is(Blocks.MAGMA_BLOCK);
|
||||
return rock && support.getFluidState().isEmpty() && (!volcanic || !support.ignitedByLava())
|
||||
&& support.isCollisionShapeFullBlock(chunk, position);
|
||||
}
|
||||
|
||||
private static void volcanicPalette(ExpansionIsland island, ChunkAccess chunk) {
|
||||
int minX = chunk.getPos().getMinBlockX(), minZ = chunk.getPos().getMinBlockZ();
|
||||
int minY = Math.max(chunk.getMinY(), (int) Math.floor(ExpansionRelief.worldY(island, 160.0)));
|
||||
int maxY = Math.min(chunk.getMinY() + chunk.getHeight() - 1,
|
||||
(int) Math.ceil(ExpansionRelief.worldY(island, 330.0)));
|
||||
int patchSize = Math.clamp(island.diameter() / 64, 2, 8);
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) {
|
||||
double radial = Math.hypot((double) x - island.centerX(), (double) z - island.centerZ()) / island.radius();
|
||||
if (radial >= 0.62 || !island.contains(x, z)) continue;
|
||||
long column = island.seed() ^ Math.floorDiv(x - island.centerX(), patchSize) * 0x9e3779b97f4a7c15L
|
||||
^ Math.floorDiv(z - island.centerZ(), patchSize) * 0x632be59bd9b4e019L;
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
position.set(x, y, z);
|
||||
if (!naturalRock(chunk.getBlockState(position))) continue;
|
||||
double pick = unit(column ^ Math.floorDiv(y, 4) * 0x94d049bb133111ebL);
|
||||
boolean hot = radial < 0.28 && ExpansionRelief.referenceY(island, y) <= 230.0;
|
||||
BlockState replacement = hot && pick < 0.055 ? Blocks.OBSIDIAN.defaultBlockState()
|
||||
: hot && pick < 0.14 ? Blocks.MAGMA_BLOCK.defaultBlockState()
|
||||
: pick < 0.44 ? Blocks.BLACKSTONE.defaultBlockState() : Blocks.BASALT.defaultBlockState();
|
||||
chunk.setBlockState(position, replacement, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean naturalRock(BlockState state) {
|
||||
return state.is(Blocks.STONE) || state.is(Blocks.DEEPSLATE);
|
||||
}
|
||||
|
||||
private static int insideChunk(int coordinate) {
|
||||
return (coordinate & ~15) + Math.clamp(coordinate & 15, 1, 14);
|
||||
}
|
||||
|
||||
private static double unit(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
|
||||
/** Strict read-only proof of generated, untouched Sanctuary void, including its independent stores.
|
||||
* Fingerprints describe verified semantic emptiness; time counters and light caches may change on save. */
|
||||
public record ExpansionVoidCertificate(int x, int z, String terrain, String entities, String poi, boolean absent) {
|
||||
private static final Set<String> CHUNK_KEYS = Set.of("DataVersion", "xPos", "yPos", "zPos", "Status", "LastUpdate",
|
||||
"InhabitedTime", "isLightOn", "sections", "Heightmaps", "structures", "block_entities", "entities",
|
||||
"block_ticks", "fluid_ticks", "PostProcessing", "UpgradeData");
|
||||
private static final Set<String> HEIGHTMAPS = Set.of("WORLD_SURFACE_WG", "WORLD_SURFACE", "OCEAN_FLOOR_WG",
|
||||
"OCEAN_FLOOR", "MOTION_BLOCKING", "MOTION_BLOCKING_NO_LEAVES");
|
||||
private static final Set<String> FULL_HEIGHTMAPS = Set.of("WORLD_SURFACE", "OCEAN_FLOOR", "MOTION_BLOCKING", "MOTION_BLOCKING_NO_LEAVES");
|
||||
|
||||
public ChunkPos position() { return new ChunkPos(x, z); }
|
||||
|
||||
public static ExpansionVoidCertificate certify(ChunkPos pos, CompoundTag terrain, Optional<CompoundTag> entities,
|
||||
Optional<CompoundTag> poi, int dataVersion) throws IOException {
|
||||
return new ExpansionVoidCertificate(pos.x(), pos.z(), terrainFingerprint(pos, terrain, dataVersion),
|
||||
entityFingerprint(pos, entities, dataVersion), poiFingerprint(pos, poi, dataVersion), false);
|
||||
}
|
||||
|
||||
public static ExpansionVoidCertificate absent(ChunkPos pos, Optional<CompoundTag> entities,
|
||||
Optional<CompoundTag> poi, int dataVersion) throws IOException {
|
||||
return new ExpansionVoidCertificate(pos.x(), pos.z(), "absent", entityFingerprint(pos, entities, dataVersion),
|
||||
poiFingerprint(pos, poi, dataVersion), true);
|
||||
}
|
||||
|
||||
public static String terrainFingerprint(ChunkPos pos, CompoundTag tag, int dataVersion) throws IOException {
|
||||
keys(tag, CHUNK_KEYS, "chunk");
|
||||
for (String name : Set.of("DataVersion", "xPos", "yPos", "zPos")) type(tag, name, Tag.TAG_INT);
|
||||
type(tag, "LastUpdate", Tag.TAG_LONG); type(tag, "InhabitedTime", Tag.TAG_LONG);
|
||||
require(tag.getLong("LastUpdate").orElse(-1L) >= 0 && tag.getLong("InhabitedTime").orElse(-1L) >= 0,
|
||||
"invalid chunk time counter");
|
||||
if (tag.contains("isLightOn")) booleanByte(tag, "isLightOn");
|
||||
require(tag.getInt("DataVersion").orElse(-1) == dataVersion, "different data version");
|
||||
require(tag.getInt("xPos").orElse(Integer.MIN_VALUE) == pos.x()
|
||||
&& tag.getInt("zPos").orElse(Integer.MIN_VALUE) == pos.z() && tag.getInt("yPos").orElse(-1) == 0,
|
||||
"unexpected chunk coordinates or vertical extent");
|
||||
require(tag.getString("Status").orElse("").equals("minecraft:full"), "chunk is not FULL");
|
||||
for (String name : Set.of("block_entities", "block_ticks", "fluid_ticks"))
|
||||
require(list(tag, name).isEmpty(), "nonempty " + name);
|
||||
emptyList(tag, "entities"); // Legacy in-chunk entities are optional for native FULL chunks.
|
||||
if (tag.contains("UpgradeData")) require(compound(tag, "UpgradeData").isEmpty(), "pending upgrade data");
|
||||
var structures = compound(tag, "structures");
|
||||
keys(structures, Set.of("starts", "References"), "structures");
|
||||
for (String name : Set.of("starts", "References")) require(compound(structures, name).isEmpty(), "structure start or reference");
|
||||
var heightmaps = compound(tag, "Heightmaps");
|
||||
keys(heightmaps, HEIGHTMAPS, "heightmaps");
|
||||
require(heightmaps.keySet().containsAll(FULL_HEIGHTMAPS), "missing FULL heightmap");
|
||||
for (String name : heightmaps.keySet()) {
|
||||
var data = heightmaps.getLongArray(name).orElseThrow(() -> failure("invalid heightmap"));
|
||||
// Native 384-block height maps use nine bits: seven columns per long, 37 longs for 16x16.
|
||||
require(data.length == 37, "invalid heightmap length");
|
||||
for (long value : data) require(value == 0L, "nonempty heightmap");
|
||||
}
|
||||
var postProcessing = list(tag, "PostProcessing");
|
||||
require(postProcessing.size() == 24, "invalid post-processing section count");
|
||||
for (var section : postProcessing)
|
||||
require(section instanceof ListTag values && values.isEmpty(), "post-processing work");
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
for (var entry : list(tag, "sections")) {
|
||||
require(entry instanceof CompoundTag, "invalid section");
|
||||
var section = (CompoundTag) entry;
|
||||
keys(section, Set.of("Y", "block_states", "biomes", "BlockLight", "SkyLight"), "section");
|
||||
type(section, "Y", Tag.TAG_BYTE);
|
||||
int y = section.getByte("Y").map(Byte::intValue).orElse(Integer.MIN_VALUE);
|
||||
require(y >= -1 && y <= 24 && seen.add(y), "duplicate or outside section");
|
||||
for (String light : Set.of("BlockLight", "SkyLight")) if (section.contains(light))
|
||||
require(section.getByteArray(light).map(bytes -> bytes.length == 2048).orElse(false), "invalid light cache");
|
||||
if (y < 0 || y >= 24) {
|
||||
require(!section.contains("block_states") && !section.contains("biomes"), "terrain outside 384-block world");
|
||||
require(section.contains("BlockLight") || section.contains("SkyLight"), "empty non-native light halo");
|
||||
continue;
|
||||
}
|
||||
var blocks = compound(section, "block_states");
|
||||
keys(blocks, Set.of("palette", "data"), "block palette");
|
||||
var palette = list(blocks, "palette");
|
||||
require(palette.size() == 1, "non-pristine block palette");
|
||||
// 26.3 encodes states without properties as strings; its full dispatch codec uses lowercase id.
|
||||
if (palette.getFirst() instanceof CompoundTag only) {
|
||||
keys(only, Set.of("id"), "block state");
|
||||
require(only.getString("id").orElse("").equals("minecraft:air"), "block or fluid present");
|
||||
} else require(palette.getString(0).orElse("").equals("minecraft:air"), "block or fluid present");
|
||||
zeroData(blocks);
|
||||
var biomes = compound(section, "biomes");
|
||||
keys(biomes, Set.of("palette", "data"), "biome palette");
|
||||
var biomePalette = list(biomes, "palette");
|
||||
require(biomePalette.size() == 1 && biomePalette.getString(0).orElse("").equals("minecraft:the_void"),
|
||||
"chunk is outside the pristine void biome");
|
||||
zeroData(biomes);
|
||||
}
|
||||
for (int y = 0; y < 24; y++) require(seen.contains(y), "missing section; emptiness cannot be proved");
|
||||
return digest("sanctuary-void-v1|" + dataVersion + "|" + pos.x() + "|" + pos.z() + "|384|air|the_void");
|
||||
}
|
||||
|
||||
public static String entityFingerprint(ChunkPos pos, Optional<CompoundTag> raw, int dataVersion) throws IOException {
|
||||
if (raw.isPresent()) {
|
||||
var tag = raw.get(); keys(tag, Set.of("DataVersion", "Position", "Entities"), "entity storage");
|
||||
type(tag, "DataVersion", Tag.TAG_INT);
|
||||
require(tag.getInt("DataVersion").orElse(-1) == dataVersion, "different entity data version");
|
||||
var coordinates = tag.getIntArray("Position").orElseThrow(() -> failure("missing entity position"));
|
||||
require(coordinates.length == 2 && coordinates[0] == pos.x() && coordinates[1] == pos.z(), "misplaced entity storage");
|
||||
require(list(tag, "Entities").isEmpty(), "saved entity present");
|
||||
}
|
||||
return digest("sanctuary-empty-entities-v1|" + pos.x() + "|" + pos.z());
|
||||
}
|
||||
|
||||
public static String poiFingerprint(ChunkPos pos, Optional<CompoundTag> raw, int dataVersion) throws IOException {
|
||||
if (raw.isPresent()) {
|
||||
var tag = raw.get(); keys(tag, Set.of("DataVersion", "Sections"), "point-of-interest storage");
|
||||
type(tag, "DataVersion", Tag.TAG_INT);
|
||||
require(tag.getInt("DataVersion").orElse(-1) == dataVersion, "different point-of-interest data version");
|
||||
var sections = compound(tag, "Sections");
|
||||
for (String key : sections.keySet()) {
|
||||
int y;
|
||||
try { y = Integer.parseInt(key); } catch (NumberFormatException error) { throw failure("invalid point-of-interest section"); }
|
||||
require(y >= 0 && y < 24 && key.equals(Integer.toString(y)), "outside or noncanonical point-of-interest section");
|
||||
var section = compound(sections, key); keys(section, Set.of("Valid", "Records"), "point-of-interest section");
|
||||
booleanByte(section, "Valid");
|
||||
require(list(section, "Records").isEmpty(), "saved point of interest");
|
||||
}
|
||||
}
|
||||
return digest("sanctuary-empty-poi-v1|" + pos.x() + "|" + pos.z());
|
||||
}
|
||||
|
||||
private static void zeroData(CompoundTag tag) throws IOException {
|
||||
// A native single-valued palette has no packed indices; nonempty arrays are not proof of pristine storage.
|
||||
if (tag.contains("data")) require(tag.getLongArray("data")
|
||||
.map(values -> values.length == 0).orElse(false), "unexpected single-valued palette data");
|
||||
}
|
||||
private static void type(CompoundTag tag, String key, byte expected) throws IOException {
|
||||
require(tag.get(key) != null && tag.get(key).getId() == expected, "missing or invalid type for " + key);
|
||||
}
|
||||
private static void booleanByte(CompoundTag tag, String key) throws IOException {
|
||||
type(tag, key, Tag.TAG_BYTE);
|
||||
byte value = tag.getByte(key).orElse((byte) -1);
|
||||
require(value == 0 || value == 1, "invalid boolean " + key);
|
||||
}
|
||||
private static void emptyList(CompoundTag tag, String key) throws IOException {
|
||||
if (tag.contains(key)) require(list(tag, key).isEmpty(), "nonempty " + key);
|
||||
}
|
||||
private static ListTag list(CompoundTag tag, String key) throws IOException {
|
||||
return tag.getList(key).orElseThrow(() -> failure("missing or invalid " + key));
|
||||
}
|
||||
private static CompoundTag compound(CompoundTag tag, String key) throws IOException {
|
||||
return tag.getCompound(key).orElseThrow(() -> failure("missing or invalid " + key));
|
||||
}
|
||||
private static void keys(CompoundTag tag, Set<String> allowed, String where) throws IOException {
|
||||
require(allowed.containsAll(tag.keySet()), "foreign or unsupported metadata in " + where);
|
||||
}
|
||||
private static void require(boolean condition, String reason) throws IOException { if (!condition) throw failure(reason); }
|
||||
private static IOException failure(String reason) { return new IOException("void_not_pristine: " + reason); }
|
||||
public static String digest(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package fr.koka.sanctuary.expansion;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.mixin.ExpansionChunkMapAccessor;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidEntityManagerAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidEntityStorageAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidPoiStorageAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidServerLevelAccess;
|
||||
import fr.koka.sanctuary.mixin.ExpansionVoidTicksAccess;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.ai.village.poi.PoiManager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ImposterProtoChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.minecraft.world.ticks.LevelTicks;
|
||||
|
||||
/** Generation-14-only transaction: certify untouched void, bind an immutable manifest in the journal,
|
||||
* then let Minecraft rebuild certified chunks on the next world load. Never patches a live FULL chunk. */
|
||||
public final class ExpansionVoidReuse {
|
||||
public static final String NBT_MARKER = "sanctuary:void_reuse";
|
||||
private static final Gson JSON = new Gson();
|
||||
private static final Map<ServerLevel, State> STATES = new WeakHashMap<>();
|
||||
// Immutable snapshots are read by Minecraft's asynchronous NBT parser; mutable session state stays on the server thread.
|
||||
private static volatile Map<ServerLevel, Set<Long>> CAPTURE = Map.of();
|
||||
private static final int BATCH = 64;
|
||||
private static final int MAX_CHUNKS = 8000;
|
||||
private static final int MAX_MANIFEST_BYTES = 4_000_000;
|
||||
|
||||
private ExpansionVoidReuse() {}
|
||||
|
||||
public record Inspection(ServerLevel level, ExpansionIsland island, long deadline,
|
||||
List<ExpansionVoidCertificate> certificates) {
|
||||
public Inspection { certificates = List.copyOf(certificates); }
|
||||
}
|
||||
private record Manifest(String token, ExpansionIsland island, List<ExpansionVoidCertificate> certificates) {}
|
||||
private record Claim(Manifest manifest, ExpansionVoidCertificate certificate) {}
|
||||
private static final class State {
|
||||
final Map<String, Manifest> manifests = new HashMap<>();
|
||||
final Map<Long, Claim> claims = new HashMap<>();
|
||||
final Set<String> deferred = new HashSet<>();
|
||||
final Set<Long> completed = new HashSet<>();
|
||||
final Map<String, String> failures = new HashMap<>();
|
||||
}
|
||||
public record LoadDecision(ProtoChunk replacement, String trustedMarker) {
|
||||
static final LoadDecision KEEP = new LoadDecision(null, "");
|
||||
}
|
||||
|
||||
/** Every potential write chunk is inspected; existing halo neighbours are deliberately read-only. */
|
||||
public static Inspection inspect(ServerLevel level, ExpansionIsland island, long deadline) throws IOException {
|
||||
requireServerThread(level);
|
||||
if (!(level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)
|
||||
|| !ExpansionIsland.spatialGeneration(generator.rootGeneration()) || island.generation() != generator.rootGeneration() || island.origin())
|
||||
throw new IOException("Void reuse is restricted to a matching generation-14/15 expansion");
|
||||
List<ChunkPos> positions = positions(island);
|
||||
var entityStorage = entityRegion(level);
|
||||
var poiStorage = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage();
|
||||
var certificates = new ArrayList<ExpansionVoidCertificate>();
|
||||
var occupiedPoi = occupiedPoiChunks(level);
|
||||
for (int offset = 0; offset < positions.size(); offset += BATCH) {
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
int end = Math.min(offset + BATCH, positions.size());
|
||||
var reads = new ArrayList<Read>();
|
||||
for (int i = offset; i < end; i++) {
|
||||
ChunkPos pos = positions.get(i);
|
||||
reads.add(new Read(pos, level.getChunkSource().chunkMap.read(pos), entityStorage.read(pos), poiStorage.read(pos)));
|
||||
}
|
||||
await(CompletableFuture.allOf(reads.stream().flatMap(read -> java.util.stream.Stream.of(
|
||||
read.terrain(), read.entities(), read.poi())).toArray(CompletableFuture[]::new)), deadline);
|
||||
IOException refusal = null;
|
||||
for (Read read : reads) {
|
||||
// Drain all submitted operations even if a preceding position is occupied.
|
||||
var disk = await(read.terrain(), deadline);
|
||||
var entities = await(read.entities(), deadline);
|
||||
var poi = await(read.poi(), deadline);
|
||||
try {
|
||||
verifyMemory(level, read.position(), false, occupiedPoi);
|
||||
ExpansionVoidCertificate.entityFingerprint(read.position(), entities, dataVersion());
|
||||
ExpansionVoidCertificate.poiFingerprint(read.position(), poi, dataVersion());
|
||||
ChunkAccess loaded = loaded(level, read.position());
|
||||
if (disk.isPresent()) ExpansionVoidCertificate.terrainFingerprint(read.position(), disk.get(), dataVersion());
|
||||
if (loaded != null) {
|
||||
// The holder is inspected as-is, never promoted, cancelled or reconfigured.
|
||||
if (!(unwrap(loaded) instanceof LevelChunk)) throw new IOException("void_reuse_retry: chunk generation is in progress at " + read.position());
|
||||
var snapshot = SerializableChunkData.copyOf(level, unwrap(loaded)).write();
|
||||
certificates.add(ExpansionVoidCertificate.certify(read.position(), snapshot, entities, poi, dataVersion()));
|
||||
} else if (disk.isPresent()) {
|
||||
certificates.add(ExpansionVoidCertificate.certify(read.position(), disk.get(), entities, poi, dataVersion()));
|
||||
} else {
|
||||
certificates.add(ExpansionVoidCertificate.absent(read.position(), entities, poi, dataVersion()));
|
||||
}
|
||||
} catch (IOException error) { if (refusal == null) refusal = error; }
|
||||
}
|
||||
if (refusal != null) {
|
||||
if (refusal.getMessage().startsWith("void_not_pristine"))
|
||||
throw new ExpansionAdmission.OccupiedException(refusal.getMessage());
|
||||
throw refusal;
|
||||
}
|
||||
}
|
||||
ExpansionAdmission.checkDeadline(deadline);
|
||||
return new Inspection(level, island, deadline, certificates);
|
||||
}
|
||||
|
||||
/** Must run immediately before committing the reservation, on the same server turn. */
|
||||
public static String persist(Inspection inspection) throws IOException {
|
||||
Inspection verified = inspect(inspection.level(), inspection.island(), inspection.deadline());
|
||||
if (!verified.certificates().equals(inspection.certificates())) throw new IOException("void_reuse_retry: inspected chunks changed before commit");
|
||||
if (verified.certificates().stream().allMatch(ExpansionVoidCertificate::absent)) return "";
|
||||
var body = new JsonObject();
|
||||
body.addProperty("schema", 1); body.addProperty("world_seed", inspection.level().getSeed());
|
||||
body.addProperty("data_version", dataVersion());
|
||||
body.add("island", ExpansionIsland.CODEC.encodeStart(JsonOps.INSTANCE, identity(inspection.island())).getOrThrow());
|
||||
body.add("certificates", JSON.toJsonTree(inspection.certificates()));
|
||||
String encoded = body + "\n";
|
||||
String token = ExpansionVoidCertificate.digest(encoded);
|
||||
Path file = manifestPath(inspection.level(), token);
|
||||
Files.createDirectories(file.getParent());
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!readText(file).equals(encoded)) throw new IOException("Void certificate manifest changed on disk");
|
||||
return token;
|
||||
}
|
||||
Path temporary = Files.createTempFile(file.getParent(), ".certificate-", ".tmp");
|
||||
try {
|
||||
try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
ByteBuffer bytes = StandardCharsets.UTF_8.encode(encoded);
|
||||
while (bytes.hasRemaining()) output.write(bytes);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
if (!file.getFileSystem().getSeparator().equals("\\")) {
|
||||
try (FileChannel parent = FileChannel.open(file.getParent(), StandardOpenOption.READ)) { parent.force(true); }
|
||||
}
|
||||
if (!readText(file).equals(encoded)) throw new IOException("Void certificate read-back failed");
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Run before publishing the journal islands to the generator at level startup. Missing authority aborts startup. */
|
||||
public static void open(ServerLevel level, ExpansionChunkGenerator generator, List<ExpansionIsland> islands) throws IOException {
|
||||
requireServerThread(level); close(level);
|
||||
if (!ExpansionIsland.spatialGeneration(generator.rootGeneration())) return;
|
||||
var state = new State();
|
||||
for (var island : islands) if (!island.reuseToken().isEmpty()) add(state, loadManifest(level, island));
|
||||
// Validate every transaction before writing even an EMPTY placeholder or publishing any island.
|
||||
// In particular, an originally absent position may have been explored during the deferred session.
|
||||
var absent = new ArrayList<Claim>();
|
||||
for (var manifest : state.manifests.values()) absent.addAll(preflight(level, manifest));
|
||||
stageAbsent(level, absent);
|
||||
STATES.put(level, state);
|
||||
var captures = new HashMap<>(CAPTURE);
|
||||
captures.put(level, Set.copyOf(state.claims.keySet()));
|
||||
CAPTURE = Map.copyOf(captures);
|
||||
}
|
||||
|
||||
public static void close(ServerLevel level) {
|
||||
STATES.remove(level);
|
||||
var captures = new HashMap<>(CAPTURE); captures.remove(level); CAPTURE = Map.copyOf(captures);
|
||||
}
|
||||
|
||||
public static boolean needsOriginal(net.minecraft.world.level.LevelHeightAccessor height, CompoundTag raw) {
|
||||
if (!(height instanceof ServerLevel level)) return false;
|
||||
var claims = CAPTURE.get(level);
|
||||
if (claims == null || claims.isEmpty()) return false;
|
||||
var x = raw.getInt("xPos"); var z = raw.getInt("zPos");
|
||||
return x.isPresent() && z.isPresent() && claims.contains(new ChunkPos(x.get(), z.get()).pack());
|
||||
}
|
||||
|
||||
/** The just-committed reservation stays invisible until a real reload; a live FULL holder is never reset. */
|
||||
public static void deferred(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
requireServerThread(level);
|
||||
if (island.reuseToken().isEmpty()) return;
|
||||
State state = STATES.get(level);
|
||||
if (state == null) throw new IOException("Void reuse session is unavailable");
|
||||
state.deferred.add(island.id());
|
||||
add(state, loadManifest(level, island));
|
||||
}
|
||||
public static boolean requiresReload(ServerLevel level, String island) {
|
||||
State state = STATES.get(level); return state != null && state.deferred.contains(island);
|
||||
}
|
||||
public static String failure(ServerLevel level, String island) {
|
||||
State state = STATES.get(level); return state == null ? null : state.failures.get(island);
|
||||
}
|
||||
public static boolean complete(ServerLevel level, ExpansionIsland island) {
|
||||
if (island.reuseToken().isEmpty()) return true;
|
||||
State state = STATES.get(level);
|
||||
if (state == null || state.deferred.contains(island.id()) || state.failures.containsKey(island.id())) return false;
|
||||
Manifest manifest = state.manifests.get(island.reuseToken());
|
||||
return manifest != null && manifest.certificates().stream().allMatch(c -> state.completed.contains(c.position().pack()));
|
||||
}
|
||||
|
||||
/** Called by SerializableChunkData.read on Minecraft's main-thread load executor. Refusal preserves the original data. */
|
||||
public static LoadDecision read(ServerLevel level, ChunkPos position, CompoundTag raw) {
|
||||
State state = STATES.get(level);
|
||||
if (state == null) return LoadDecision.KEEP;
|
||||
Claim claim = state.claims.get(position.pack());
|
||||
if (claim == null) return LoadDecision.KEEP;
|
||||
String island = claim.manifest().island().id();
|
||||
if (raw == null && state.deferred.contains(island)) return LoadDecision.KEEP;
|
||||
try {
|
||||
requireServerThread(level);
|
||||
if (raw == null) throw new IOException("Original chunk NBT is unavailable");
|
||||
if (trustedMarker(level, position, raw, claim.manifest().token()))
|
||||
return new LoadDecision(null, claim.manifest().token());
|
||||
if (state.deferred.contains(island)) return LoadDecision.KEEP;
|
||||
if (claim.certificate().absent()) throw new IOException("An originally absent chunk appeared without its transaction marker");
|
||||
if (state.failures.containsKey(island)) return LoadDecision.KEEP;
|
||||
long deadline = ExpansionAdmission.searchDeadline();
|
||||
var entities = await(entityRegion(level).read(position), deadline);
|
||||
var poi = await(((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage().read(position), deadline);
|
||||
verifyMemory(level, position, true);
|
||||
if (loaded(level, position, true) != null) throw new IOException("A live chunk holder must not be replaced");
|
||||
var freshProof = ExpansionVoidCertificate.certify(position, raw, entities, poi, dataVersion());
|
||||
if (!freshProof.equals(claim.certificate())) throw new IOException("Void certificate no longer matches the saved chunk");
|
||||
var replacement = new ProtoChunk(position, UpgradeData.EMPTY, level, level.palettedContainerFactory(), null);
|
||||
marker(replacement, claim.manifest().token());
|
||||
SanctuaryMod.LOGGER.debug("Rebuilding certified void {} for expansion {} through the native pipeline", position, island);
|
||||
return new LoadDecision(replacement, claim.manifest().token());
|
||||
} catch (Exception error) {
|
||||
String failure = "void_reuse_blocked: " + position + ": " + error.getMessage();
|
||||
state.failures.put(island, failure);
|
||||
state.deferred.add(island);
|
||||
if (level.getChunkSource().getGenerator() instanceof ExpansionChunkGenerator generator)
|
||||
generator.setIslands(generator.source().islands().stream().filter(i -> !i.id().equals(island)).toList());
|
||||
SanctuaryMod.LOGGER.error("Expansion {} stopped; the original chunk is preserved: {}", island, failure);
|
||||
// ChunkMap treats an IOException from loading as permission to create EMPTY. Never throw one here.
|
||||
return LoadDecision.KEEP;
|
||||
}
|
||||
}
|
||||
|
||||
public static String marker(ChunkAccess chunk) { return ((ExpansionReuseMarker) unwrap(chunk)).sanctuary$reuseMarker(); }
|
||||
public static void marker(ChunkAccess chunk, String token) { ((ExpansionReuseMarker) unwrap(chunk)).sanctuary$reuseMarker(token); }
|
||||
public static void loaded(ServerLevel level, ChunkAccess chunk, String token) {
|
||||
if (token == null || token.isEmpty()) return;
|
||||
marker(chunk, token);
|
||||
State state = STATES.get(level);
|
||||
Claim claim = state == null ? null : state.claims.get(chunk.getPos().pack());
|
||||
if (claim != null && claim.manifest().token().equals(token) && unwrap(chunk) instanceof LevelChunk)
|
||||
state.completed.add(chunk.getPos().pack());
|
||||
}
|
||||
|
||||
/** Global startup gate. A native marked chunk may now contain gameplay data and is preserved whole. */
|
||||
private static List<Claim> preflight(ServerLevel level, Manifest manifest) throws IOException {
|
||||
var missing = new ArrayList<Claim>();
|
||||
var occupiedPoi = occupiedPoiChunks(level);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120);
|
||||
var entityStorage = entityRegion(level);
|
||||
var poiStorage = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$simpleRegionStorage();
|
||||
for (int offset = 0; offset < manifest.certificates().size(); offset += BATCH) {
|
||||
var reads = new ArrayList<Read>();
|
||||
int end = Math.min(offset + BATCH, manifest.certificates().size());
|
||||
for (int i = offset; i < end; i++) {
|
||||
ChunkPos pos = manifest.certificates().get(i).position();
|
||||
reads.add(new Read(pos, level.getChunkSource().chunkMap.read(pos), entityStorage.read(pos), poiStorage.read(pos)));
|
||||
}
|
||||
await(CompletableFuture.allOf(reads.stream().flatMap(read -> java.util.stream.Stream.of(
|
||||
read.terrain(), read.entities(), read.poi())).toArray(CompletableFuture[]::new)), deadline);
|
||||
for (int i = 0; i < reads.size(); i++) {
|
||||
var read = reads.get(i);
|
||||
var certificate = manifest.certificates().get(offset + i);
|
||||
var raw = await(read.terrain(), deadline);
|
||||
var entities = await(read.entities(), deadline);
|
||||
var poi = await(read.poi(), deadline);
|
||||
if (raw.isPresent() && trustedMarker(level, read.position(), raw.get(), manifest.token())) continue;
|
||||
verifyMemory(level, read.position(), false, occupiedPoi);
|
||||
if (loaded(level, read.position()) != null) throw new IOException("Reload has a live uncertified holder at " + read.position());
|
||||
ExpansionVoidCertificate proof;
|
||||
if (raw.isEmpty()) {
|
||||
if (!certificate.absent()) throw new IOException("Certified saved void disappeared at " + read.position());
|
||||
proof = ExpansionVoidCertificate.absent(read.position(), entities, poi, dataVersion());
|
||||
missing.add(new Claim(manifest, certificate));
|
||||
} else {
|
||||
if (certificate.absent()) throw new IOException("Unclaimed saved chunk appeared after reservation at " + read.position());
|
||||
proof = ExpansionVoidCertificate.certify(read.position(), raw.get(), entities, poi, dataVersion());
|
||||
}
|
||||
if (!proof.equals(certificate)) throw new IOException("Void reuse preflight changed at " + read.position());
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** Durably starts the native pipeline in positions proven absent by the entire preflight.
|
||||
* These EMPTY records contain no generated terrain. A crash leaves journal-authorized records
|
||||
* which the next preflight recognizes, rather than an ambiguous partially activated footprint. */
|
||||
private static void stageAbsent(ServerLevel level, List<Claim> claims) throws IOException {
|
||||
if (claims.isEmpty()) return;
|
||||
var storage = level.getChunkSource().chunkMap;
|
||||
var occupiedPoi = occupiedPoiChunks(level);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120);
|
||||
for (int offset = 0; offset < claims.size(); offset += BATCH) {
|
||||
var writes = new ArrayList<CompletableFuture<Void>>();
|
||||
int end = Math.min(offset + BATCH, claims.size());
|
||||
for (int i = offset; i < end; i++) {
|
||||
var claim = claims.get(i); ChunkPos pos = claim.certificate().position();
|
||||
// This server turn cannot interleave gameplay. Re-read the owning IO worker to include queued writes.
|
||||
if (await(storage.read(pos), deadline).isPresent() || loaded(level, pos) != null)
|
||||
throw new IOException("Previously absent chunk changed before staging at " + pos);
|
||||
verifyMemory(level, pos, false, occupiedPoi);
|
||||
var fresh = new ProtoChunk(pos, UpgradeData.EMPTY, level, level.palettedContainerFactory(), null);
|
||||
marker(fresh, claim.manifest().token());
|
||||
writes.add(storage.write(pos, SerializableChunkData.copyOf(level, fresh).write()));
|
||||
}
|
||||
await(CompletableFuture.allOf(writes.toArray(CompletableFuture[]::new)), deadline);
|
||||
}
|
||||
await(storage.synchronize(true), deadline);
|
||||
for (var claim : claims) {
|
||||
var pos = claim.certificate().position();
|
||||
var raw = await(storage.read(pos), deadline).orElseThrow(() -> new IOException("Staged chunk was not saved at " + pos));
|
||||
if (!trustedMarker(level, pos, raw, claim.manifest().token())
|
||||
|| SerializableChunkData.getChunkStatusFromTag(raw) != ChunkStatus.EMPTY)
|
||||
throw new IOException("Staged native EMPTY read-back failed at " + pos);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean trustedMarker(ServerLevel level, ChunkPos position, CompoundTag raw, String token) throws IOException {
|
||||
if (!raw.contains(NBT_MARKER)) return false;
|
||||
if (!raw.getString(NBT_MARKER).orElse("").equals(token)) throw new IOException("Chunk has a foreign reuse marker at " + position);
|
||||
if (raw.getInt("DataVersion").orElse(-1) != dataVersion()
|
||||
|| raw.getInt("xPos").orElse(Integer.MIN_VALUE) != position.x()
|
||||
|| raw.getInt("zPos").orElse(Integer.MIN_VALUE) != position.z()
|
||||
|| raw.read("Status", ChunkStatus.CODEC).isEmpty()
|
||||
|| SerializableChunkData.parse(level, level.palettedContainerFactory(), raw) == null)
|
||||
throw new IOException("Marked chunk cannot be read safely at " + position);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ChunkAccess unwrap(ChunkAccess chunk) { return chunk instanceof ImposterProtoChunk proxy ? proxy.getWrapped() : chunk; }
|
||||
private static int dataVersion() { return SharedConstants.getCurrentVersion().dataVersion().version(); }
|
||||
private static ExpansionIsland identity(ExpansionIsland island) { return island.withReuseToken("").ready(); }
|
||||
private static Path manifestPath(ServerLevel level, String token) {
|
||||
if (!token.matches("[a-f0-9]{64}")) throw new IllegalArgumentException("Invalid void reuse token");
|
||||
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory() + "/void-reuse").resolve(token + ".json");
|
||||
}
|
||||
private static String readText(Path file) throws IOException {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > MAX_MANIFEST_BYTES)
|
||||
throw new IOException("Missing or invalid immutable void certificate: " + file.getFileName());
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
private static Manifest loadManifest(ServerLevel level, ExpansionIsland island) throws IOException {
|
||||
try {
|
||||
String encoded = readText(manifestPath(level, island.reuseToken()));
|
||||
if (!ExpansionVoidCertificate.digest(encoded).equals(island.reuseToken())) throw new IOException("Void certificate checksum differs from the journal");
|
||||
JsonObject body = JsonParser.parseString(encoded).getAsJsonObject();
|
||||
if (!body.keySet().equals(Set.of("schema", "world_seed", "data_version", "island", "certificates"))
|
||||
|| body.get("schema").getAsInt() != 1 || body.get("world_seed").getAsLong() != level.getSeed()
|
||||
|| body.get("data_version").getAsInt() != dataVersion()) throw new IOException("Unsupported void certificate contract");
|
||||
var identity = ExpansionIsland.CODEC.parse(JsonOps.INSTANCE, body.get("island")).getOrThrow();
|
||||
if (!identity.equals(identity(island))) throw new IOException("Void certificate belongs to a different island");
|
||||
var certificates = new ArrayList<ExpansionVoidCertificate>();
|
||||
var positions = new HashSet<Long>();
|
||||
for (var element : body.getAsJsonArray("certificates")) {
|
||||
if (!element.getAsJsonObject().keySet().equals(Set.of("x", "z", "absent", "terrain", "entities", "poi"))) throw new IOException("Foreign void certificate fields");
|
||||
var certificate = JSON.fromJson(element, ExpansionVoidCertificate.class);
|
||||
if (!island.ownsChunk(certificate.x(), certificate.z()) || !positions.add(certificate.position().pack())
|
||||
|| !(certificate.absent() ? certificate.terrain().equals("absent") : certificate.terrain().matches("[a-f0-9]{64}"))
|
||||
|| !certificate.entities().matches("[a-f0-9]{64}")
|
||||
|| !certificate.poi().matches("[a-f0-9]{64}")) throw new IOException("Invalid certified write scope");
|
||||
certificates.add(certificate);
|
||||
}
|
||||
if (certificates.isEmpty() || certificates.size() > MAX_CHUNKS
|
||||
|| certificates.stream().allMatch(ExpansionVoidCertificate::absent)
|
||||
|| !positions.equals(positions(island).stream().map(ChunkPos::pack).collect(java.util.stream.Collectors.toSet())))
|
||||
throw new IOException("Void certificate does not cover the complete write scope");
|
||||
return new Manifest(island.reuseToken(), identity, List.copyOf(certificates));
|
||||
} catch (RuntimeException error) { throw new IOException("Could not decode the journal's immutable void certificate", error); }
|
||||
}
|
||||
private static void add(State state, Manifest manifest) throws IOException {
|
||||
Manifest old = state.manifests.get(manifest.token());
|
||||
if (old != null) return;
|
||||
for (var certificate : manifest.certificates()) {
|
||||
Claim previous = state.claims.get(certificate.position().pack());
|
||||
if (previous != null && !previous.manifest().token().equals(manifest.token()))
|
||||
throw new IOException("Two void reuse transactions claim the same chunk");
|
||||
}
|
||||
state.manifests.put(manifest.token(), manifest);
|
||||
for (var certificate : manifest.certificates()) state.claims.put(certificate.position().pack(), new Claim(manifest, certificate));
|
||||
}
|
||||
private static List<ChunkPos> positions(ExpansionIsland island) throws IOException {
|
||||
int radius = island.terrainRadius() + ExpansionIsland.DECORATION_MARGIN;
|
||||
var result = new ArrayList<ChunkPos>();
|
||||
for (int z = Math.floorDiv(island.centerZ() - radius, 16); z <= Math.floorDiv(island.centerZ() + radius, 16); z++)
|
||||
for (int x = Math.floorDiv(island.centerX() - radius, 16); x <= Math.floorDiv(island.centerX() + radius, 16); x++)
|
||||
if (island.ownsChunk(x, z)) {
|
||||
result.add(new ChunkPos(x, z));
|
||||
if (result.size() > MAX_CHUNKS) throw new IOException("Expansion exceeds the bounded admission scope");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private static ChunkAccess loaded(ServerLevel level, ChunkPos pos) throws IOException {
|
||||
return loaded(level, pos, false);
|
||||
}
|
||||
private static ChunkAccess loaded(ServerLevel level, ChunkPos pos, boolean currentLoad) throws IOException {
|
||||
var map = level.getChunkSource().chunkMap;
|
||||
if (((ExpansionChunkMapAccessor) map).sanctuary$pendingUnloads().containsKey(pos.pack()))
|
||||
throw new IOException("void_reuse_retry: chunk is unloading at " + pos);
|
||||
var holder = map.getUpdatingChunkIfPresent(pos.pack());
|
||||
if (holder == null) return null;
|
||||
var chunk = holder.getLatestChunk();
|
||||
if (chunk == null && !currentLoad) throw new IOException("void_reuse_retry: chunk load is in progress at " + pos);
|
||||
return chunk;
|
||||
}
|
||||
private static SimpleRegionStorage entityRegion(ServerLevel level) throws IOException {
|
||||
var manager = ((ExpansionVoidServerLevelAccess) level).sanctuary$entityManager();
|
||||
var storage = ((ExpansionVoidEntityManagerAccess) manager).sanctuary$permanentStorage();
|
||||
if (!(storage instanceof ExpansionVoidEntityStorageAccess access)) throw new IOException("Entity storage cannot be verified");
|
||||
return access.sanctuary$simpleRegionStorage();
|
||||
}
|
||||
private static void verifyMemory(ServerLevel level, ChunkPos pos) throws IOException {
|
||||
verifyMemory(level, pos, false);
|
||||
}
|
||||
private static void verifyMemory(ServerLevel level, ChunkPos pos, boolean currentLoad) throws IOException {
|
||||
verifyMemory(level, pos, currentLoad, null);
|
||||
}
|
||||
private static void verifyMemory(ServerLevel level, ChunkPos pos, boolean currentLoad, Set<Long> occupiedPoi) throws IOException {
|
||||
requireServerThread(level);
|
||||
var access = (ExpansionVoidServerLevelAccess) level;
|
||||
var manager = (ExpansionVoidEntityManagerAccess) access.sanctuary$entityManager();
|
||||
var entityStatus = manager.sanctuary$chunkLoadStatuses().get(pos.pack());
|
||||
if (entityStatus != null && entityStatus.name().equals("PENDING"))
|
||||
throw new IOException("void_reuse_retry: entity load is in progress at " + pos);
|
||||
var poiAccess = (ExpansionVoidPoiStorageAccess) level.getPoiManager();
|
||||
boolean prefetched = false;
|
||||
synchronized (poiAccess.sanctuary$loadLock()) {
|
||||
var pending = poiAccess.sanctuary$pendingLoads().get(pos.pack());
|
||||
if (pending != null) {
|
||||
// ChunkMap's own LOAD waits for this prefetch but native unpacking happens inside read.
|
||||
if (!currentLoad || !pending.isDone() || pending.isCompletedExceptionally())
|
||||
throw new IOException("void_reuse_retry: point-of-interest load is in progress at " + pos);
|
||||
prefetched = true;
|
||||
}
|
||||
}
|
||||
if (prefetched && level.getPoiManager().getInChunk(holder -> true, pos, PoiManager.Occupancy.ANY).findAny().isPresent())
|
||||
throw new IOException("void_not_pristine: prefetched point of interest at " + pos);
|
||||
if (manager.sanctuary$sectionStorage().getExistingSectionsInChunk(pos.pack()).anyMatch(section -> !section.isEmpty())
|
||||
|| manager.sanctuary$loadingInbox().stream().anyMatch(chunk -> chunk.getPos().equals(pos) && !chunk.isEmpty()))
|
||||
throw new IOException("void_not_pristine: loaded or loading entity at " + pos);
|
||||
if ((occupiedPoi == null ? occupiedPoiChunks(level) : occupiedPoi).contains(pos.pack()))
|
||||
throw new IOException("void_not_pristine: loaded point of interest at " + pos);
|
||||
verifyTicks(level.getBlockTicks(), pos); verifyTicks(level.getFluidTicks(), pos);
|
||||
if (access.sanctuary$blockEvents().stream().anyMatch(event -> ChunkPos.containing(event.pos()).equals(pos))
|
||||
|| access.sanctuary$blockEventsToReschedule().stream().anyMatch(event -> ChunkPos.containing(event.pos()).equals(pos)))
|
||||
throw new IOException("void_not_pristine: pending block event at " + pos);
|
||||
}
|
||||
/** Snapshot once per admission/preflight turn; include every cached Y, even unsupported out-of-world sections.
|
||||
* The server thread does not yield while inspecting, so loaded records cannot change between its chunk checks. */
|
||||
private static Set<Long> occupiedPoiChunks(ServerLevel level) throws IOException {
|
||||
requireServerThread(level);
|
||||
var result = new HashSet<Long>();
|
||||
var poi = ((ExpansionVoidPoiStorageAccess) level.getPoiManager()).sanctuary$storage();
|
||||
for (var entry : poi.long2ObjectEntrySet()) if (entry.getValue().isPresent()
|
||||
&& entry.getValue().get().getRecords(holder -> true, PoiManager.Occupancy.ANY).findAny().isPresent())
|
||||
result.add(new ChunkPos(SectionPos.x(entry.getLongKey()), SectionPos.z(entry.getLongKey())).pack());
|
||||
return result;
|
||||
}
|
||||
private static void verifyTicks(LevelTicks<?> ticks, ChunkPos pos) throws IOException {
|
||||
var access = (ExpansionVoidTicksAccess) ticks;
|
||||
var container = access.sanctuary$allContainers().get(pos.pack());
|
||||
if (container != null && container.count() != 0
|
||||
|| access.sanctuary$toRunThisTick().stream().anyMatch(tick -> ChunkPos.containing(tick.pos()).equals(pos))
|
||||
|| access.sanctuary$alreadyRunThisTick().stream().anyMatch(tick -> ChunkPos.containing(tick.pos()).equals(pos)))
|
||||
throw new IOException("void_not_pristine: scheduled tick at " + pos);
|
||||
}
|
||||
private static void requireServerThread(ServerLevel level) throws IOException {
|
||||
if (!level.getServer().isSameThread()) throw new IOException("Void inspection must run on the server thread");
|
||||
}
|
||||
private static <T> T await(CompletableFuture<T> future, long deadline) throws IOException {
|
||||
try {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) throw new TimeoutException();
|
||||
return future.get(remaining, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IOException("Void inspection interrupted", error); }
|
||||
catch (ExecutionException error) { throw new IOException("Void storage read failed", error.getCause()); }
|
||||
catch (TimeoutException error) { throw new IOException("search_timeout", error); }
|
||||
}
|
||||
private record Read(ChunkPos position, CompletableFuture<Optional<CompoundTag>> terrain,
|
||||
CompletableFuture<Optional<CompoundTag>> entities, CompletableFuture<Optional<CompoundTag>> poi) {}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.CavernSpringOutlets;
|
||||
import fr.koka.sanctuary.worldgen.PopulationSpringOutlets;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
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.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class CavernSpringOutletMixin {
|
||||
@Unique
|
||||
private PopulationSpringOutlets.Pending sanctuary$pendingPopulationOutlets = PopulationSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Unique
|
||||
private CavernSpringOutlets.Pending sanctuary$pendingCavernOutlets = CavernSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
|
||||
private void sanctuary$captureCavernOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
sanctuary$pendingPopulationOutlets = PopulationSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
sanctuary$pendingCavernOutlets = CavernSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
}
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
|
||||
private void sanctuary$finishCavernOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
var population = sanctuary$pendingPopulationOutlets;
|
||||
sanctuary$pendingPopulationOutlets = PopulationSpringOutlets.Pending.EMPTY;
|
||||
PopulationSpringOutlets.finish(level, (LevelChunk) (Object) this, population);
|
||||
var pending = sanctuary$pendingCavernOutlets;
|
||||
sanctuary$pendingCavernOutlets = CavernSpringOutlets.Pending.EMPTY;
|
||||
CavernSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** The laboratory keeps the source island's cavities; vanilla carvers must not remove approved foundations. */
|
||||
@Mixin(NoiseBasedChunkGenerator.class)
|
||||
public abstract class ExpansionCarverMixin {
|
||||
@Inject(method = "generateCarvers", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$keepExpansionCavities(CallbackInfo callback) {
|
||||
var generator = (NoiseBasedChunkGenerator) (Object) this;
|
||||
if (generator.getBiomeSource() instanceof ExpansionBiomeSource) callback.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
import net.minecraft.server.level.ChunkHolder;
|
||||
import net.minecraft.server.level.ChunkMap;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Admission must also reject chunks removed from the active map but not saved yet. */
|
||||
@Mixin(ChunkMap.class)
|
||||
public interface ExpansionChunkMapAccessor {
|
||||
@Accessor("pendingUnloads")
|
||||
Long2ObjectLinkedOpenHashMap<ChunkHolder> sanctuary$pendingUnloads();
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.expansion.ExpansionReuseMarker;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
|
||||
/** Authority follows the actual chunk object; coordinates alone never confer permission to mark a chunk. */
|
||||
@Mixin(ChunkAccess.class)
|
||||
public abstract class ExpansionReuseChunkMarkerMixin implements ExpansionReuseMarker {
|
||||
@Unique private String sanctuary$reuseMarker = "";
|
||||
@Override public String sanctuary$reuseMarker() { return sanctuary$reuseMarker; }
|
||||
@Override public void sanctuary$reuseMarker(String token) { sanctuary$reuseMarker = token; }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.expansion.ExpansionVoidReuse;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class ExpansionReusePromotionMixin {
|
||||
@Inject(method = "<init>(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/level/chunk/ProtoChunk;Lnet/minecraft/world/level/chunk/LevelChunk$PostLoadProcessor;)V", at = @At("RETURN"))
|
||||
private void sanctuary$promoteCertificate(ServerLevel level, ProtoChunk proto, LevelChunk.PostLoadProcessor processor, CallbackInfo ci) {
|
||||
ExpansionVoidReuse.loaded(level, (LevelChunk) (Object) this, ExpansionVoidReuse.marker(proto));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.expansion.ExpansionVoidReuse;
|
||||
import fr.koka.sanctuary.expansion.ExpansionReuseSerializedData;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.ai.village.poi.PoiManager;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
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.callback.CallbackInfoReturnable;
|
||||
|
||||
/** Retains the original NBT until the main-thread read boundary, before any native recovery can discard fields. */
|
||||
@Mixin(SerializableChunkData.class)
|
||||
public abstract class ExpansionReuseSerializationMixin implements ExpansionReuseSerializedData {
|
||||
@Unique private CompoundTag sanctuary$originalNbt;
|
||||
@Unique private String sanctuary$reuseMarker = "";
|
||||
@Override public void sanctuary$originalReuseNbt(CompoundTag tag) { sanctuary$originalNbt = tag; }
|
||||
@Override public void sanctuary$serializedReuseMarker(String token) { sanctuary$reuseMarker = token; }
|
||||
|
||||
@Inject(method = "parse", at = @At("RETURN"))
|
||||
private static void sanctuary$captureOriginal(LevelHeightAccessor height, PalettedContainerFactory factory, CompoundTag raw,
|
||||
CallbackInfoReturnable<SerializableChunkData> cir) {
|
||||
if (cir.getReturnValue() != null && ExpansionVoidReuse.needsOriginal(height, raw))
|
||||
((ExpansionReuseSerializedData) (Object) cir.getReturnValue()).sanctuary$originalReuseNbt(raw.copy());
|
||||
}
|
||||
|
||||
@Inject(method = "read", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$restoreCertifiedVoid(ServerLevel level, PoiManager pois, RegionStorageInfo storage, ChunkPos position,
|
||||
CallbackInfoReturnable<ProtoChunk> cir) {
|
||||
var decision = ExpansionVoidReuse.read(level, position, sanctuary$originalNbt);
|
||||
sanctuary$originalNbt = null;
|
||||
sanctuary$reuseMarker = decision.trustedMarker();
|
||||
if (decision.replacement() != null) cir.setReturnValue(decision.replacement());
|
||||
}
|
||||
|
||||
@Inject(method = "read", at = @At("RETURN"))
|
||||
private void sanctuary$restoreNativeMarker(ServerLevel level, PoiManager pois, RegionStorageInfo storage, ChunkPos position,
|
||||
CallbackInfoReturnable<ProtoChunk> cir) {
|
||||
if (cir.getReturnValue() != null) ExpansionVoidReuse.loaded(level, cir.getReturnValue(), sanctuary$reuseMarker);
|
||||
}
|
||||
|
||||
@Inject(method = "copyOf", at = @At("RETURN"))
|
||||
private static void sanctuary$captureNativeMarker(ServerLevel level, ChunkAccess chunk, CallbackInfoReturnable<SerializableChunkData> cir) {
|
||||
((ExpansionReuseSerializedData) (Object) cir.getReturnValue()).sanctuary$serializedReuseMarker(ExpansionVoidReuse.marker(chunk));
|
||||
}
|
||||
|
||||
@Inject(method = "write", at = @At("RETURN"))
|
||||
private void sanctuary$persistNativeMarker(CallbackInfoReturnable<CompoundTag> cir) {
|
||||
if (!sanctuary$reuseMarker.isEmpty()) cir.getReturnValue().putString(ExpansionVoidReuse.NBT_MARKER, sanctuary$reuseMarker);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import java.util.Queue;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.entity.ChunkEntities;
|
||||
import net.minecraft.world.level.entity.EntityPersistentStorage;
|
||||
import net.minecraft.world.level.entity.EntitySectionStorage;
|
||||
import net.minecraft.world.level.entity.PersistentEntitySectionManager;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Exposes both loaded and pending entity collections to the conservative admission check. */
|
||||
@Mixin(PersistentEntitySectionManager.class)
|
||||
public interface ExpansionVoidEntityManagerAccess {
|
||||
@Accessor("permanentStorage")
|
||||
EntityPersistentStorage<Entity> sanctuary$permanentStorage();
|
||||
|
||||
@Accessor("sectionStorage")
|
||||
EntitySectionStorage<Entity> sanctuary$sectionStorage();
|
||||
|
||||
@Accessor("loadingInbox")
|
||||
Queue<ChunkEntities<Entity>> sanctuary$loadingInbox();
|
||||
|
||||
@Accessor("chunkLoadStatuses")
|
||||
Long2ObjectMap<? extends Enum<?>> sanctuary$chunkLoadStatuses();
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import net.minecraft.world.level.chunk.storage.EntityStorage;
|
||||
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Lets admission inspect saved entities without pretending an unloaded chunk is empty. */
|
||||
@Mixin(EntityStorage.class)
|
||||
public interface ExpansionVoidEntityStorageAccess {
|
||||
@Accessor("simpleRegionStorage")
|
||||
SimpleRegionStorage sanctuary$simpleRegionStorage();
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.minecraft.world.entity.ai.village.poi.PoiSection;
|
||||
import net.minecraft.world.level.chunk.storage.SectionStorage;
|
||||
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Used only on the world's POI manager to inspect its persisted and already loaded records. */
|
||||
@Mixin(SectionStorage.class)
|
||||
public interface ExpansionVoidPoiStorageAccess {
|
||||
@Accessor("simpleRegionStorage")
|
||||
SimpleRegionStorage sanctuary$simpleRegionStorage();
|
||||
|
||||
@Accessor("storage")
|
||||
Long2ObjectMap<Optional<PoiSection>> sanctuary$storage();
|
||||
|
||||
@Accessor("pendingLoads")
|
||||
Long2ObjectMap<CompletableFuture<?>> sanctuary$pendingLoads();
|
||||
|
||||
@Accessor("loadLock")
|
||||
Object sanctuary$loadLock();
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet;
|
||||
import java.util.List;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.BlockEventData;
|
||||
import net.minecraft.world.level.entity.PersistentEntitySectionManager;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Read-only access for checking entity ownership before deferred void admission. */
|
||||
@Mixin(ServerLevel.class)
|
||||
public interface ExpansionVoidServerLevelAccess {
|
||||
@Accessor("entityManager")
|
||||
PersistentEntitySectionManager<Entity> sanctuary$entityManager();
|
||||
|
||||
@Accessor("blockEvents")
|
||||
ObjectLinkedOpenHashSet<BlockEventData> sanctuary$blockEvents();
|
||||
|
||||
@Accessor("blockEventsToReschedule")
|
||||
List<BlockEventData> sanctuary$blockEventsToReschedule();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import net.minecraft.world.ticks.LevelChunkTicks;
|
||||
import net.minecraft.world.ticks.LevelTicks;
|
||||
import net.minecraft.world.ticks.ScheduledTick;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Admission checks scheduled, queued and currently executing ticks, not just chunk containers. */
|
||||
@Mixin(LevelTicks.class)
|
||||
public interface ExpansionVoidTicksAccess {
|
||||
@Accessor("allContainers")
|
||||
Long2ObjectMap<LevelChunkTicks<?>> sanctuary$allContainers();
|
||||
|
||||
@Accessor("toRunThisTick")
|
||||
Queue<ScheduledTick<?>> sanctuary$toRunThisTick();
|
||||
|
||||
@Accessor("alreadyRunThisTick")
|
||||
List<ScheduledTick<?>> sanctuary$alreadyRunThisTick();
|
||||
}
|
||||
@@ -15,6 +15,11 @@ public abstract class InitialSpawnMixin {
|
||||
@Inject(method = "setInitialSpawn", at = @At("RETURN"))
|
||||
private static void sanctuary$initialSpawn(ServerLevel level, ServerLevelData data, boolean bonusChest,
|
||||
boolean debugWorld, LevelLoadListener listener, CallbackInfo ci) {
|
||||
if (level.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.expansion.ExpansionChunkGenerator) {
|
||||
var pos = fr.koka.sanctuary.expansion.ExpansionRuntime.session(level).landing("sanctuary");
|
||||
level.setRespawnData(net.minecraft.world.level.storage.LevelData.RespawnData.of(net.minecraft.world.level.Level.OVERWORLD, pos, 0, 0));
|
||||
return;
|
||||
}
|
||||
SanctuarySpawn.initialize(level);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.LayeredSpringOutlets;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
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.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class LayeredSpringOutletMixin {
|
||||
@Unique
|
||||
private LayeredSpringOutlets.Pending sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
|
||||
private void sanctuary$captureSpringOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
sanctuary$pendingSpringOutlets = LayeredSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
}
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
|
||||
private void sanctuary$finishSpringOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
var pending = sanctuary$pendingSpringOutlets;
|
||||
sanctuary$pendingSpringOutlets = LayeredSpringOutlets.Pending.EMPTY;
|
||||
LayeredSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.RiftSpringOutlets;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
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.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class RiftSpringOutletMixin {
|
||||
@Unique
|
||||
private RiftSpringOutlets.Pending sanctuary$pendingRiftOutlets = RiftSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
|
||||
private void sanctuary$captureRiftOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
sanctuary$pendingRiftOutlets = RiftSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
}
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
|
||||
private void sanctuary$finishRiftOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
var pending = sanctuary$pendingRiftOutlets;
|
||||
sanctuary$pendingRiftOutlets = RiftSpringOutlets.Pending.EMPTY;
|
||||
RiftSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.HydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.NaturalHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.LayeredHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.WoodlandHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.RiftHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.CavernHydrologyRuntime;
|
||||
import fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** The terrain future completes before decoration starts; never reads or writes another chunk. */
|
||||
@Mixin(NoiseBasedChunkGenerator.class)
|
||||
public abstract class SurfaceHydrologyMixin {
|
||||
@Inject(method = "buildTerrain", at = @At("RETURN"), cancellable = true)
|
||||
private void sanctuary$surfaceWater(ChunkAccess chunk, Blender blender, RandomState randomState,
|
||||
StructureManager structures, BiomeManager biomeManager, WorldGenRegion region,
|
||||
Set<Holder<Biome>> biomes, CallbackInfoReturnable<CompletableFuture<ChunkAccess>> callback) {
|
||||
var generator = (NoiseBasedChunkGenerator) (Object) this;
|
||||
if (PopulationHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
PopulationHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (CavernHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
CavernHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (RiftHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
RiftHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (WoodlandHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
WoodlandHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (LayeredHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
LayeredHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (NaturalHydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
NaturalHydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
} else if (HydrologyRuntime.enabled(generator)) {
|
||||
callback.setReturnValue(callback.getReturnValue().thenApply(generated -> {
|
||||
HydrologyRuntime.apply(generator, randomState, generated);
|
||||
return generated;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
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 {
|
||||
/** Some native features (notably ore veins) write directly to a chunk section after
|
||||
* checking ensureCanWrite, so setBlock alone cannot enforce island ownership. */
|
||||
@Inject(method = "ensureCanWrite", at = @At("HEAD"), cancellable = true)
|
||||
private void sanctuary$keepIslandOwnership(BlockPos pos, CallbackInfoReturnable<Boolean> callback) {
|
||||
var region = (WorldGenRegion) (Object) this;
|
||||
if (!(region.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator generator)
|
||||
|| !fr.koka.sanctuary.expansion.ExpansionIsland.spatialGeneration(generator.rootGeneration())) return;
|
||||
var center = region.getCenter();
|
||||
var owner = generator.islandForChunk(center.x(), center.z());
|
||||
var destination = generator.islandForChunk(pos.getX() >> 4, pos.getZ() >> 4);
|
||||
if (owner == null || destination == null || !owner.id().equals(destination.id())
|
||||
|| !fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing()
|
||||
&& !fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.isPlacing() && generator.protectsCityDecoration(pos))
|
||||
callback.setReturnValue(false);
|
||||
}
|
||||
|
||||
@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;
|
||||
// Generation16 reserves its architectural volume before decoration. Only its own
|
||||
// native pieces may replace water there; published generations retain the old rule.
|
||||
if ((generator.rootGeneration() == 16 || generator.rootGeneration() == 17)
|
||||
&& fr.koka.sanctuary.worldgen.city.LostCityStructures.isPlacing()
|
||||
&& generator.protectsCityDecoration(pos)) return;
|
||||
if (generator.protectsDecoration(pos) && !region.getBlockState(pos).equals(state)) {
|
||||
generator.rejectedDecorationWrite();
|
||||
callback.setReturnValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.mixin;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.WoodlandSpringOutlets;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
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.callback.CallbackInfo;
|
||||
|
||||
/** Finish only pending generation markers; completed saved chunks are never repaired on reload. */
|
||||
@Mixin(LevelChunk.class)
|
||||
public abstract class WoodlandSpringOutletMixin {
|
||||
@Unique
|
||||
private WoodlandSpringOutlets.Pending sanctuary$pendingWoodlandOutlets = WoodlandSpringOutlets.Pending.EMPTY;
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("HEAD"))
|
||||
private void sanctuary$captureWoodlandOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
sanctuary$pendingWoodlandOutlets = WoodlandSpringOutlets.capturePending(level, (LevelChunk) (Object) this);
|
||||
}
|
||||
|
||||
@Inject(method = "postProcessGeneration", at = @At("TAIL"))
|
||||
private void sanctuary$finishWoodlandOutlets(ServerLevel level, CallbackInfo callback) {
|
||||
var pending = sanctuary$pendingWoodlandOutlets;
|
||||
sanctuary$pendingWoodlandOutlets = WoodlandSpringOutlets.Pending.EMPTY;
|
||||
WoodlandSpringOutlets.finish(level, (LevelChunk) (Object) this, pending);
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
/** Alpha.9 keeps the upper woodland climate and specializes sheltered lower layers. */
|
||||
public final class CavernBiomePalette {
|
||||
public enum Kind {
|
||||
OAK_FOREST, BIRCH_FOREST, CLEARING, DRY_WOODLAND, ROCKY_HEATH,
|
||||
DARK_GROVE, BAMBOO_GROVE, SULFUR_DEPTHS, LUSH_CAVES, DRIPSTONE_CAVES
|
||||
}
|
||||
|
||||
private CavernBiomePalette() {}
|
||||
|
||||
public static Kind select(int blockY, float variation, float moisture) {
|
||||
double elevation = blockY - Math.clamp(variation, -1.0F, 1.0F) * 16
|
||||
- Math.clamp(moisture, -1.0F, 1.0F) * 10;
|
||||
// Small climate pockets replace alpha.8's broad sulfur province. Neither
|
||||
// mineral pockets nor cave vegetation can take over the upper forest.
|
||||
if (blockY < 176 && elevation < 168 && variation > 0.22F && variation < 0.42F
|
||||
&& moisture > -0.42F && moisture < -0.12F) return Kind.SULFUR_DEPTHS;
|
||||
if (variation > 0.48F) return Kind.ROCKY_HEATH;
|
||||
if (blockY < 184 && elevation < 180) {
|
||||
if (moisture > 0.05F && variation > -0.32F) return Kind.LUSH_CAVES;
|
||||
if (moisture < -0.18F && variation < 0.22F) return Kind.DRIPSTONE_CAVES;
|
||||
}
|
||||
if (elevation < 214) {
|
||||
if (variation < -0.45F && moisture > 0.40F) return Kind.BAMBOO_GROVE;
|
||||
double shelteredMoisture = moisture + Math.min(0.55, (214 - elevation) / 160);
|
||||
if (shelteredMoisture > -0.08) return Kind.DARK_GROVE;
|
||||
}
|
||||
if (moisture < -0.55F && variation > -0.25F) return Kind.DRY_WOODLAND;
|
||||
if (Math.abs(variation) < 0.09F && moisture < 0.35F) return Kind.CLEARING;
|
||||
if (variation < -0.33F) return Kind.BIRCH_FOREST;
|
||||
return Kind.OAK_FOREST;
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.AmethystClusterBlock;
|
||||
import net.minecraft.world.level.block.CaveVines;
|
||||
import net.minecraft.world.level.block.GrowingPlantHeadBlock;
|
||||
import net.minecraft.world.level.block.PotentSulfurBlock;
|
||||
import net.minecraft.world.level.block.SpeleothemBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.PotentSulfurState;
|
||||
import net.minecraft.world.level.block.state.properties.SpeleothemThickness;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.feature.GeodeFeature;
|
||||
|
||||
/** Decorations in existing lower cavities only. All writes belong to the owner's chunk. */
|
||||
public final class CavernDecorationsFeature implements Feature {
|
||||
public static final MapCodec<CavernDecorationsFeature> CODEC = MapCodec.unit(CavernDecorationsFeature::new);
|
||||
private static final ResourceKey<Feature> GEODE = ResourceKey.create(Registries.FEATURE, SanctuaryMod.id("cavern_amethyst_geode"));
|
||||
private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST};
|
||||
public enum Kind { LUSH, DRIPSTONE, SULFUR, GEODE }
|
||||
public record Candidate(Kind kind, BlockPos floor, BlockPos ceiling) {
|
||||
public Candidate { floor = floor.immutable(); ceiling = ceiling.immutable(); }
|
||||
}
|
||||
|
||||
@Override public MapCodec<CavernDecorationsFeature> codec() { return CODEC; }
|
||||
|
||||
/** Pure, bounded probes for development diagnostics; no chunk loads or successful-placement promise. */
|
||||
public static List<Candidate> candidates(NoiseBasedChunkGenerator generator, RandomState state, int chunkX, int chunkZ) {
|
||||
if (!CavernHydrologyRuntime.enabled(generator) || Math.abs(chunkX) > 17 || Math.abs(chunkZ) > 17) return List.of();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
|
||||
var biomes = generator.getBiomeSource().createUncachedResolver(state);
|
||||
List<Candidate> found = new ArrayList<>();
|
||||
for (int[] offset : new int[][]{{4, 4}, {4, 12}, {12, 4}, {12, 12}, {8, 8}}) {
|
||||
int x = chunkX * 16 + offset[0], z = chunkZ * 16 + offset[1], floors = 0;
|
||||
for (int y = 182; y >= 40; y--) {
|
||||
if (sampler.sample(x, y, z) <= 0 || sampler.sample(x, y + 1, z) > 0
|
||||
|| sampler.sample(x, y + 2, z) > 0 || sampler.sample(x, y - 1, z) <= 0
|
||||
|| sampler.sample(x, y - 2, z) <= 0) continue;
|
||||
int roof = y + 3;
|
||||
while (roof <= Math.min(230, y + 48) && sampler.sample(x, roof, z) <= 0) roof++;
|
||||
if (roof > Math.min(230, y + 48) || roof - y < 6
|
||||
|| sampler.sample(x, roof + 1, z) <= 0) continue;
|
||||
BlockPos floor = new BlockPos(x, y, z), ceiling = new BlockPos(x, roof, z);
|
||||
var biome = biomes.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2);
|
||||
if (biome.is(CavernIslandBiomeSource.LUSH_CAVES)) found.add(new Candidate(Kind.LUSH, floor, ceiling));
|
||||
else if (biome.is(CavernIslandBiomeSource.DRIPSTONE_CAVES)) found.add(new Candidate(Kind.DRIPSTONE, floor, ceiling));
|
||||
else if (biome.is(CavernIslandBiomeSource.SULFUR_DEPTHS) && y < 176)
|
||||
found.add(new Candidate(Kind.SULFUR, floor, ceiling));
|
||||
if (offset[0] == 8 && offset[1] == 8 && Math.floorMod(chunkSeed(state.seed(), chunkX, chunkZ, 0x9501L), 8) == 0)
|
||||
found.add(new Candidate(Kind.GEODE, floor, ceiling));
|
||||
if (++floors >= 3) break;
|
||||
}
|
||||
}
|
||||
// Geodes keep their entry free before this feature's moss, grass or pointed
|
||||
// dripstone can occupy it. Stable sorting preserves every other candidate order.
|
||||
found.sort(java.util.Comparator.comparingInt(candidate -> candidate.kind() == Kind.GEODE ? 0 : 1));
|
||||
return List.copyOf(found);
|
||||
}
|
||||
|
||||
@Override public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
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;
|
||||
boolean changed = false, geyser = false, geode = false;
|
||||
for (Candidate candidate : candidates(noise, state, cx, cz)) {
|
||||
BlockPos floor = candidate.floor();
|
||||
if (!stable(level, sampler, floor) || !level.getBlockState(floor.above()).isAir()) continue;
|
||||
long seed = WoodlandGroveGeometry.seed(state.seed(), floor.getX(), floor.getY(), floor.getZ(), 0x9502L);
|
||||
switch (candidate.kind()) {
|
||||
case LUSH -> changed |= lush(level, noise, state, sampler, candidate, seed);
|
||||
case DRIPSTONE -> changed |= dripstone(level, noise, state, sampler, candidate, seed);
|
||||
case SULFUR -> {
|
||||
if (!geyser && Math.floorMod(chunkSeed(state.seed(), cx, cz, 0x9503L), 2) == 0)
|
||||
changed |= geyser = geyser(level, noise, state, sampler, candidate);
|
||||
}
|
||||
case GEODE -> { if (!geode) changed |= geode = geode(level, noise, state, sampler, candidate, seed); }
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean lush(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) {
|
||||
BlockPos floor = site.floor(), roof = site.ceiling();
|
||||
int gap = roof.getY() - floor.getY();
|
||||
if (CavernHydrologyRuntime.protects(noise, state, floor, 3, gap + 2, 3)) return false;
|
||||
boolean changed = false;
|
||||
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) {
|
||||
if (dx * dx + dz * dz > 5) continue;
|
||||
BlockPos p = floor.offset(dx, 0, dz);
|
||||
if (!stable(level, sampler, p) || !stable(level, sampler, p.below()) || !stable(level, sampler, p.below(2))
|
||||
|| !level.getBlockState(p.above()).isAir()) continue;
|
||||
setBlock(level, p, Blocks.MOSS_BLOCK.defaultBlockState());
|
||||
long local = WoodlandGroveGeometry.seed(seed, p.getX(), p.getY(), p.getZ(), 0x9504L);
|
||||
BlockState plant = (Math.floorMod(local, 5) == 0 ? Blocks.AZALEA : Math.floorMod(local, 3) == 0
|
||||
? Blocks.MOSS_CARPET : Blocks.SHORT_GRASS).defaultBlockState();
|
||||
if (plant.canSurvive(level, p.above())) setBlock(level, p.above(), plant);
|
||||
changed = true;
|
||||
}
|
||||
// Real cave-vine heads and bodies remain harvestable and luminous via BERRIES.
|
||||
int length = Math.min(gap - 3, 3 + Math.floorMod(seed, 6));
|
||||
if (stable(level, sampler, roof) && stable(level, sampler, roof.above())) {
|
||||
boolean free = true;
|
||||
for (int d = 1; d <= length; d++) free &= level.getBlockState(roof.below(d)).isAir();
|
||||
if (free) for (int d = 1; d <= length; d++) {
|
||||
BlockPos p = roof.below(d);
|
||||
BlockState vine = (d == length ? Blocks.CAVE_VINES : Blocks.CAVE_VINES_PLANT).defaultBlockState()
|
||||
.setValue(CaveVines.BERRIES, d == length || Math.floorMod(seed + d, 3) == 0);
|
||||
if (d == length) vine = vine.setValue(GrowingPlantHeadBlock.AGE, 25);
|
||||
if (!vine.canSurvive(level, p)) break;
|
||||
setBlock(level, p, vine); changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean dripstone(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) {
|
||||
BlockPos floor = site.floor(), roof = site.ceiling();
|
||||
int gap = roof.getY() - floor.getY();
|
||||
if (CavernHydrologyRuntime.protects(noise, state, floor, 1, gap + 2, 3)) return false;
|
||||
int length = Math.min((gap - 3) / 2, 1 + Math.floorMod(seed, 4));
|
||||
boolean changed = spike(level, sampler, floor, Direction.UP, length);
|
||||
changed |= spike(level, sampler, roof, Direction.DOWN, length);
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean spike(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, BlockPos support, Direction direction, int length) {
|
||||
if (length < 1 || !stable(level, sampler, support) || !stable(level, sampler, support.relative(direction.getOpposite()))) return false;
|
||||
for (int d = 1; d <= length; d++) {
|
||||
BlockPos p = support.relative(direction, d);
|
||||
if (!level.getBlockState(p).isAir() || sampler.sample(p.getX(), p.getY(), p.getZ()) > 0) return false;
|
||||
}
|
||||
setBlock(level, support, Blocks.DRIPSTONE_BLOCK.defaultBlockState());
|
||||
for (int d = 1; d <= length; d++) {
|
||||
SpeleothemThickness thickness = d == length ? SpeleothemThickness.TIP
|
||||
: d == length - 1 ? SpeleothemThickness.FRUSTUM : d == 1 ? SpeleothemThickness.BASE : SpeleothemThickness.MIDDLE;
|
||||
BlockState pointed = Blocks.POINTED_DRIPSTONE.defaultBlockState().setValue(SpeleothemBlock.TIP_DIRECTION, direction)
|
||||
.setValue(SpeleothemBlock.THICKNESS, thickness);
|
||||
setBlock(level, support.relative(direction, d), pointed);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean geyser(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site) {
|
||||
BlockPos water = site.floor(), potent = water.below(), heat = water.below(2);
|
||||
if (site.ceiling().getY() - water.getY() < 8
|
||||
|| CavernHydrologyRuntime.protects(noise, state, water, 2, 8, 5)) return false;
|
||||
for (int d = 0; d <= 4; d++) if (!stable(level, sampler, water.below(d))) return false;
|
||||
for (Direction side : SIDES) for (int d = 0; d <= 2; d++)
|
||||
if (!stable(level, sampler, water.below(d).relative(side))) return false;
|
||||
for (int d = 1; d <= 7; d++) if (!level.getBlockState(water.above(d)).isAir()) return false;
|
||||
// One source, four original rock walls, original deep support. Vanilla's
|
||||
// periodic geyser ticker requires magma below potent sulfur and source water above.
|
||||
setBlock(level, heat, Blocks.MAGMA_BLOCK.defaultBlockState());
|
||||
setBlock(level, water, Blocks.WATER.defaultBlockState());
|
||||
setBlock(level, potent, Blocks.POTENT_SULFUR.defaultBlockState().setValue(PotentSulfurBlock.STATE, PotentSulfurState.DORMANT));
|
||||
level.getChunk(water).markPosForPostProcessing(water);
|
||||
level.getChunk(potent).markPosForPostProcessing(potent);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean geode(WorldGenLevel level, NoiseBasedChunkGenerator noise, RandomState state,
|
||||
WoodlandGroveGeometry.Sampler sampler, Candidate site, long seed) {
|
||||
BlockPos origin = site.floor().offset(-1, -9, -1);
|
||||
// Include the vanilla write range, its crystal neighbours and the entrance.
|
||||
// This asymmetric envelope is centred explicitly so its positive edge is protected too.
|
||||
if (origin.getY() - 6 < 24 || !level.getBlockState(site.floor().above(2)).isAir()
|
||||
|| CavernHydrologyRuntime.protects(noise, state, origin.offset(1, -6, 1), 7, 17, 0)) return false;
|
||||
Map<BlockPos, BlockState> before = new LinkedHashMap<>();
|
||||
for (int dx = -6; dx <= 8; dx++) for (int dz = -6; dz <= 8; dz++) for (int dy = -6; dy <= 11; dy++) {
|
||||
BlockPos p = origin.offset(dx, dy, dz);
|
||||
BlockState original = level.getBlockState(p);
|
||||
// No block-entity data or fluid scheduler state needs to be restored.
|
||||
if (original.hasBlockEntity() || !original.getFluidState().isEmpty()) return false;
|
||||
before.put(p, original);
|
||||
}
|
||||
Feature feature = level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(GEODE).value();
|
||||
if (!(feature instanceof GeodeFeature geode) || geode.minGenOffset() != -5 || geode.maxGenOffset() != 7) return false;
|
||||
boolean accepted = false;
|
||||
try {
|
||||
if (!geode.place(level, noise, RandomSource.create(seed), origin) || !naturalGeodeChanges(level, sampler, before)) return false;
|
||||
var entrance = geodeEntrance(level, sampler, site.floor().above(), origin, before);
|
||||
if (entrance.isEmpty()) return false;
|
||||
for (BlockPos p : entrance) if (!level.getBlockState(p).isAir()) setBlock(level, p, Blocks.AIR.defaultBlockState());
|
||||
if (!naturalGeodeChanges(level, sampler, before)) return false;
|
||||
accepted = true;
|
||||
return true;
|
||||
} finally {
|
||||
if (!accepted) before.forEach((p, original) -> {
|
||||
if (!level.getBlockState(p).equals(original)) setBlock(level, p, original);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Test the actual vanilla mask, not unused bounding-box corners. A geode may
|
||||
* excavate original rock but cannot fill original air or remove earlier decoration. */
|
||||
private static boolean naturalGeodeChanges(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler,
|
||||
Map<BlockPos, BlockState> before) {
|
||||
for (var entry : before.entrySet()) {
|
||||
BlockPos p = entry.getKey();
|
||||
BlockState original = entry.getValue(), current = level.getBlockState(p);
|
||||
if (original.equals(current)) continue;
|
||||
if (!carvableOriginal(level, sampler, p, original)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean carvableOriginal(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler,
|
||||
BlockPos p, BlockState original) {
|
||||
return original != null && original.isCollisionShapeFullBlock(level, p)
|
||||
&& !original.hasBlockEntity() && original.getFluidState().isEmpty()
|
||||
&& !original.is(BlockTags.LOGS) && !original.is(BlockTags.LEAVES)
|
||||
&& !original.is(Blocks.BUDDING_AMETHYST) && !original.is(Blocks.MAGMA_BLOCK)
|
||||
&& sampler.sample(p.getX(), p.getY(), p.getZ()) > 0;
|
||||
}
|
||||
|
||||
private static List<BlockPos> geodeEntrance(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler,
|
||||
BlockPos start, BlockPos origin, Map<BlockPos, BlockState> before) {
|
||||
var previous = new java.util.HashMap<BlockPos, BlockPos>();
|
||||
var distances = new java.util.HashMap<BlockPos, Integer>();
|
||||
var queue = new java.util.ArrayDeque<BlockPos>();
|
||||
queue.add(start); distances.put(start, 0);
|
||||
BlockPos destination = null;
|
||||
while (!queue.isEmpty()) {
|
||||
BlockPos p = queue.removeFirst();
|
||||
int distance = distances.get(p);
|
||||
boolean adjacentBudding = false;
|
||||
for (Direction direction : Direction.values())
|
||||
adjacentBudding |= level.getBlockState(p.relative(direction)).is(Blocks.BUDDING_AMETHYST)
|
||||
|| level.getBlockState(p.above().relative(direction)).is(Blocks.BUDDING_AMETHYST);
|
||||
if (p.getY() <= start.getY() - 3 && level.getBlockState(p).isAir()
|
||||
&& level.getBlockState(p.above()).isAir() && adjacentBudding) { destination = p; break; }
|
||||
if (distance >= 16) continue;
|
||||
for (Direction direction : Direction.values()) {
|
||||
BlockPos next = p.relative(direction);
|
||||
if (Math.abs(next.getX() - start.getX()) > 6 || Math.abs(next.getZ() - start.getZ()) > 6
|
||||
|| next.getY() < origin.getY() - 5 || next.getY() > start.getY()
|
||||
|| distances.containsKey(next) || !before.containsKey(next) || !before.containsKey(next.above())) continue;
|
||||
if (!entranceBlock(level, sampler, next, before) || !entranceBlock(level, sampler, next.above(), before)) continue;
|
||||
distances.put(next, distance + 1); previous.put(next, p); queue.addLast(next);
|
||||
}
|
||||
}
|
||||
if (destination == null) return List.of();
|
||||
var passage = new java.util.LinkedHashSet<BlockPos>();
|
||||
for (BlockPos p = destination; p != null; p = previous.get(p)) { passage.add(p); passage.add(p.above()); }
|
||||
return List.copyOf(passage);
|
||||
}
|
||||
|
||||
private static boolean entranceBlock(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler,
|
||||
BlockPos p, Map<BlockPos, BlockState> before) {
|
||||
BlockState current = level.getBlockState(p);
|
||||
if (current.isAir()) return true;
|
||||
// Keep every budding block and attached crystal. Search around them instead.
|
||||
if (current.is(Blocks.BUDDING_AMETHYST) || current.is(Blocks.AMETHYST_CLUSTER)
|
||||
|| current.is(Blocks.SMALL_AMETHYST_BUD) || current.is(Blocks.MEDIUM_AMETHYST_BUD)
|
||||
|| current.is(Blocks.LARGE_AMETHYST_BUD)) return false;
|
||||
for (Direction direction : Direction.values()) {
|
||||
BlockState neighbour = level.getBlockState(p.relative(direction));
|
||||
if (neighbour.getBlock() instanceof AmethystClusterBlock
|
||||
&& neighbour.getValue(AmethystClusterBlock.FACING) == direction) return false;
|
||||
}
|
||||
return carvableOriginal(level, sampler, p, before.get(p));
|
||||
}
|
||||
|
||||
private static boolean stable(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, BlockPos p) {
|
||||
BlockState state = level.getBlockState(p);
|
||||
return sampler.sample(p.getX(), p.getY(), p.getZ()) > 0 && state.isCollisionShapeFullBlock(level, p)
|
||||
&& state.getFluidState().isEmpty() && !state.hasBlockEntity() && !state.is(BlockTags.LOGS)
|
||||
&& !state.is(BlockTags.LEAVES) && !state.is(Blocks.BUDDING_AMETHYST) && !state.is(Blocks.MAGMA_BLOCK);
|
||||
}
|
||||
|
||||
private static long chunkSeed(long seed, int x, int z, long salt) {
|
||||
return WoodlandGroveGeometry.seed(seed, x * 16, 0, z * 16, salt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.data.worldgen.features.TreeFeatures;
|
||||
import net.minecraft.data.worldgen.features.VegetationFeatures;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.VegetationBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature;
|
||||
|
||||
/** Alpha.9: sheltered vanilla mushroom trees and dark oak on natural lower floors,
|
||||
* plus one globally selected remarkable tree.
|
||||
* Placement runs only in new generation. No world journal or mutable placement counter is retained. */
|
||||
public final class CavernGrovesFeature implements Feature {
|
||||
public static final MapCodec<CavernGrovesFeature> CODEC = MapCodec.unit(CavernGrovesFeature::new);
|
||||
private static final Map<RandomState, Optional<RemarkableTree>> REMARKABLE =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
public record RemarkableTree(BlockPos base, String featureId, int radius, int height) {
|
||||
public RemarkableTree { base = base.immutable(); }
|
||||
}
|
||||
private record Species(ResourceKey<Feature> key, int width, int radius, int height) {}
|
||||
private record Candidate(int x, int z, long rank) {}
|
||||
@FunctionalInterface
|
||||
private interface Protection {
|
||||
boolean intersects(BlockPos base, int radius, int height, int rootDepth);
|
||||
}
|
||||
private static final Species CHERRY = new Species(TreeFeatures.CHERRY, 1, 9, 15);
|
||||
private static final Species[] SPECIES = {CHERRY, CHERRY, CHERRY, CHERRY,
|
||||
new Species(TreeFeatures.SPRUCE, 1, 5, 15), new Species(TreeFeatures.JUNGLE_TREE_NO_VINE, 1, 5, 14),
|
||||
new Species(TreeFeatures.ACACIA, 1, 7, 14), new Species(TreeFeatures.MANGROVE, 1, 10, 20),
|
||||
new Species(TreeFeatures.PALE_OAK_BONEMEAL, 2, 6, 12)};
|
||||
|
||||
@Override public MapCodec<CavernGrovesFeature> 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());
|
||||
}
|
||||
|
||||
public static Optional<RemarkableTree> remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) {
|
||||
if (!CavernHydrologyRuntime.enabled(noise)) return Optional.empty();
|
||||
return REMARKABLE.computeIfAbsent(state, ignored -> {
|
||||
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) ->
|
||||
CavernHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth);
|
||||
Species species = SPECIES[Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), 0, 0, 0, 0x70EEL), SPECIES.length)];
|
||||
var candidates = new ArrayList<Candidate>();
|
||||
for (int x = -152; x <= 152; x += 16) for (int z = -152; z <= 152; z += 16) {
|
||||
if (Math.hypot(x, z) <= 168) candidates.add(new Candidate(x, z,
|
||||
WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x71AFL)));
|
||||
}
|
||||
candidates.sort(Comparator.comparingLong(Candidate::rank).thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
for (Candidate candidate : candidates) {
|
||||
int bed = WoodlandGroveGeometry.highestSurface(sampler, candidate.x(), candidate.z());
|
||||
if (!WoodlandGroveGeometry.naturalFootprint(sampler, candidate.x(), bed, candidate.z(), species.width())) continue;
|
||||
BlockPos base = new BlockPos(candidate.x(), bed + 1, candidate.z());
|
||||
if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species))) continue;
|
||||
if (!WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(),
|
||||
species.width(), species.radius(), species.height())) continue;
|
||||
return Optional.of(new RemarkableTree(base, species.key().identifier().toString(), species.radius(), species.height()));
|
||||
}
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false;
|
||||
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
|
||||
if (Math.abs((long) minX) > 272 || Math.abs((long) minZ) > 272) return false;
|
||||
var state = level.getLevel().getChunkSource().randomState();
|
||||
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) ->
|
||||
CavernHydrologyRuntime.protects(noise, state, base, radius, height, rootDepth);
|
||||
boolean changed = false;
|
||||
int[] heights = new int[256];
|
||||
Arrays.fill(heights, Integer.MIN_VALUE);
|
||||
var remarkable = remarkablePlan(noise, state);
|
||||
if (remarkable.isPresent()) {
|
||||
var tree = remarkable.get();
|
||||
BlockPos base = tree.base();
|
||||
if ((base.getX() >> 4) == (minX >> 4) && (base.getZ() >> 4) == (minZ >> 4)) {
|
||||
for (Species species : SPECIES) if (species.key().identifier().toString().equals(tree.featureId())) {
|
||||
changed |= tree(level, generator, sampler, state.seed(), base, species, protection, 0x723BL);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The scarce tall rooms usually go to dark oak first; one quarter of chunks
|
||||
// give mushroom trees first choice. This changes priority, never attempt budgets
|
||||
// or natural fit requirements, and uses a separate deterministic chunk seed.
|
||||
boolean mushroomsFirst = Math.floorMod(WoodlandGroveGeometry.seed(state.seed(), minX, 0, minZ, 0x8503L), 4) == 0;
|
||||
if (mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights);
|
||||
// Four spaced origins per chunk; the 2x2 trunk and full canopy still have to fit the cave.
|
||||
for (int x = minX + 4; x < minX + 16; x += 8) for (int z = minZ + 4; z < minZ + 16; z += 8) {
|
||||
int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), placed = 0;
|
||||
for (int bed = highest - 8; bed >= 40; bed--) {
|
||||
BlockPos base = new BlockPos(x, bed + 1, z);
|
||||
if (!ground(level, base.below()) || !level.getBlockState(base).isAir()) continue;
|
||||
var biome = level.getBiome(base);
|
||||
boolean dark = biome.is(CavernIslandBiomeSource.DARK_GROVE);
|
||||
boolean bamboo = biome.is(CavernIslandBiomeSource.BAMBOO_GROVE);
|
||||
if (!dark && !bamboo) continue;
|
||||
long placementSeed = WoodlandGroveGeometry.seed(state.seed(), x, bed, z, 0x730DL);
|
||||
if (dark) {
|
||||
if (tree(level, generator, sampler, state.seed(), base,
|
||||
new Species(TreeFeatures.DARK_OAK, 2, 6, 12), protection, 0x730DL)) { changed = true; placed++; }
|
||||
} else if (Math.floorMod(placementSeed, 8) == 0) {
|
||||
if (tree(level, generator, sampler, state.seed(), base,
|
||||
new Species(TreeFeatures.JUNGLE_TREE_NO_VINE, 1, 5, 14), protection, 0x735BL)) { changed = true; placed++; }
|
||||
} else if (Math.floorMod(placementSeed, 3) == 0
|
||||
&& WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1)
|
||||
&& WoodlandGroveGeometry.treeRoom(sampler, x, bed + 1, z, 1, 3, 16)
|
||||
&& !blocked(protection, base, 4, 17) && actualRoom(level, base, 1, 3, 16)) {
|
||||
setBlock(level, base.below(), Blocks.PODZOL.defaultBlockState());
|
||||
changed |= level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(VegetationFeatures.BAMBOO_NO_PODZOL)
|
||||
.value().place(level, generator, RandomSource.create(placementSeed), base);
|
||||
placed++;
|
||||
}
|
||||
if (placed >= 2) break;
|
||||
}
|
||||
}
|
||||
if (!mushroomsFirst) changed |= mushroomTrees(level, generator, sampler, state.seed(), protection, minX, minZ, heights);
|
||||
// Coherent ground patches: about 40% moss, 40% podzol, 20% mycelium.
|
||||
// Small mushrooms are accents (one opportunity per hundred columns), not the canopy.
|
||||
for (int x = minX; x < minX + 16; x++) for (int z = minZ; z < minZ + 16; z++) {
|
||||
int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights), floors = 0;
|
||||
long detail = WoodlandGroveGeometry.seed(state.seed(), x, 0, z, 0x843EL);
|
||||
boolean smallMushroomPlaced = false;
|
||||
for (int bed = highest - 8; bed >= 32; bed--) {
|
||||
BlockPos floor = new BlockPos(x, bed, z), plant = floor.above();
|
||||
if (!ground(level, floor) || !level.getBlockState(plant).isAir()) continue;
|
||||
var biome = level.getBiome(plant);
|
||||
if (!biome.is(CavernIslandBiomeSource.DARK_GROVE) && !biome.is(CavernIslandBiomeSource.BAMBOO_GROVE)) continue;
|
||||
if (!WoodlandGroveGeometry.naturalFootprint(sampler, x, bed, z, 1) || blocked(protection, plant, 1, 2)) continue;
|
||||
long patch = WoodlandGroveGeometry.seed(state.seed(), Math.floorDiv(x, 7), 0, Math.floorDiv(z, 7), 0x742DL);
|
||||
int soilKind = Math.floorMod(patch, 5);
|
||||
BlockState soil = (soilKind < 2 ? Blocks.MOSS_BLOCK : soilKind < 4 ? Blocks.PODZOL : Blocks.MYCELIUM).defaultBlockState();
|
||||
setBlock(level, floor, soil);
|
||||
if (!smallMushroomPlaced && Math.floorMod(detail, 100) == 0) {
|
||||
BlockState mushroom = (Math.floorMod(detail >>> 8, 2) == 0 ? Blocks.BROWN_MUSHROOM : Blocks.RED_MUSHROOM).defaultBlockState();
|
||||
if (mushroom.canSurvive(level, plant)) {
|
||||
setBlock(level, plant, mushroom);
|
||||
smallMushroomPlaced = true;
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
if (++floors >= 3) break;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean mushroomTrees(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler,
|
||||
long seed, Protection protection, int minX, int minZ, int[] heights) {
|
||||
boolean changed = false;
|
||||
RandomSource mushroomSites = RandomSource.create(WoodlandGroveGeometry.seed(seed, minX, 0, minZ, 0x8501L));
|
||||
int mushroomAttempts = 1 + mushroomSites.nextInt(3);
|
||||
for (int attempt = 0; attempt < mushroomAttempts; attempt++) {
|
||||
int x = minX + 3 + mushroomSites.nextInt(10), z = minZ + 3 + mushroomSites.nextInt(10);
|
||||
int highest = localTop(level, sampler, protection, minX, minZ, x, z, heights);
|
||||
for (int bed = highest - 8; bed >= 32; bed--) {
|
||||
BlockPos base = new BlockPos(x, bed + 1, z);
|
||||
if (!ground(level, base.below()) || !level.getBlockState(base).isAir()
|
||||
|| !level.getBiome(base).is(CavernIslandBiomeSource.DARK_GROVE)) continue;
|
||||
long mushroomSeed = WoodlandGroveGeometry.seed(seed, x, bed, z, 0x8502L + attempt);
|
||||
var key = Math.floorMod(mushroomSeed, 2) == 0 ? TreeFeatures.HUGE_BROWN_MUSHROOM : TreeFeatures.HUGE_RED_MUSHROOM;
|
||||
Feature feature = level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(key).value();
|
||||
if (!(feature instanceof AbstractHugeMushroomFeature mushroom)) break;
|
||||
int height = mushroom.getTreeHeight(RandomSource.create(mushroomSeed));
|
||||
int radius = mushroom.foliageRadius();
|
||||
if (!mushroomRoom(level, sampler, protection, base, radius, height)) continue;
|
||||
BlockState originalFloor = level.getBlockState(base.below());
|
||||
setBlock(level, base.below(), (Math.floorMod(mushroomSeed, 5) == 0 ? Blocks.MYCELIUM : Blocks.PODZOL).defaultBlockState());
|
||||
// Vanilla consumes its height draw first. Replaying the same random seed
|
||||
// gives precisely the height whose natural envelope was checked above.
|
||||
boolean placed = mushroom.place(level, generator, RandomSource.create(mushroomSeed), base);
|
||||
if (!placed) setBlock(level, base.below(), originalFloor);
|
||||
changed |= placed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean tree(WorldGenLevel level, ChunkGenerator generator, WoodlandGroveGeometry.Sampler sampler, long seed,
|
||||
BlockPos base, Species species, Protection protection, long salt) {
|
||||
if (blocked(protection, base, species.radius() + 1, species.height(), rootDepth(species))
|
||||
|| !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), species.width())
|
||||
|| !actualRoom(level, base, species.width(), species.radius(), species.height())
|
||||
|| !WoodlandGroveGeometry.treeRoom(sampler, base.getX(), base.getY(), base.getZ(), species.width(), species.radius(), species.height())) return false;
|
||||
for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) {
|
||||
BlockPos floor = base.offset(dx, -1, dz);
|
||||
if (!ground(level, floor)) return false;
|
||||
}
|
||||
for (int dx = 0; dx < species.width(); dx++) for (int dz = 0; dz < species.width(); dz++) {
|
||||
setBlock(level, base.offset(dx, -1, dz), Blocks.PODZOL.defaultBlockState());
|
||||
}
|
||||
return level.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(species.key()).value()
|
||||
.place(level, generator, RandomSource.create(WoodlandGroveGeometry.seed(seed, base.getX(), base.getY(), base.getZ(), salt)), base);
|
||||
}
|
||||
|
||||
private static boolean actualRoom(WorldGenLevel level, BlockPos base, int width, int radius, int height) {
|
||||
for (int y = 0; y <= height; y++) {
|
||||
int from = y < 3 ? 0 : -radius, to = y < 3 ? width - 1 : radius;
|
||||
for (int dx = from; dx <= to; dx++) for (int dz = from; dz <= to; dz++) {
|
||||
BlockState current = level.getBlockState(base.offset(dx, y, dz));
|
||||
if (!current.getFluidState().isEmpty() || !(current.isAir() || current.is(BlockTags.LEAVES)
|
||||
|| current.getBlock() instanceof VegetationBlock)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean ground(WorldGenLevel level, BlockPos floor) {
|
||||
BlockState state = level.getBlockState(floor);
|
||||
if (state.is(Blocks.DEEPSLATE)) {
|
||||
// Deep humid ledges can support organic soil. Check every tree foot rather
|
||||
// than admitting this rock globally, including for the remarkable tree.
|
||||
var biome = level.getBiome(floor.above());
|
||||
return biome.is(CavernIslandBiomeSource.DARK_GROVE) || biome.is(CavernIslandBiomeSource.BAMBOO_GROVE);
|
||||
}
|
||||
return state.is(Blocks.STONE) || state.is(Blocks.DIRT) || state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.PODZOL)
|
||||
|| state.is(Blocks.MYCELIUM) || state.is(Blocks.ROOTED_DIRT) || state.is(Blocks.COARSE_DIRT)
|
||||
|| state.is(Blocks.GRANITE) || state.is(Blocks.DIORITE) || state.is(Blocks.ANDESITE) || state.is(Blocks.TUFF)
|
||||
|| state.is(Blocks.COBBLESTONE) || state.is(Blocks.MOSSY_COBBLESTONE) || state.is(Blocks.PACKED_MUD);
|
||||
}
|
||||
|
||||
private static int localTop(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection,
|
||||
int minX, int minZ, int x, int z, int[] heights) {
|
||||
int index = (x - minX) * 16 + z - minZ;
|
||||
if (heights[index] != Integer.MIN_VALUE) return heights[index];
|
||||
// In an untouched column the heightmap bounds the original surface cheaply.
|
||||
// A protected water/lava column may have been carved: recover its raw top
|
||||
// without depending on the current hydrology plan's representation.
|
||||
int ceiling = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - 1;
|
||||
if (protection.intersects(new BlockPos(x, level.getMinY(), z), 0, level.getHeight() - 1, 0))
|
||||
ceiling = level.getMaxY();
|
||||
return heights[index] = WoodlandGroveGeometry.highestSurfaceBelow(sampler, x, z, ceiling);
|
||||
}
|
||||
|
||||
private static boolean mushroomRoom(WorldGenLevel level, WoodlandGroveGeometry.Sampler sampler, Protection protection,
|
||||
BlockPos base, int radius, int height) {
|
||||
if (blocked(protection, base, radius + 1, height + 1, 4)
|
||||
|| !WoodlandGroveGeometry.naturalFootprint(sampler, base.getX(), base.getY() - 1, base.getZ(), 1)) return false;
|
||||
for (int depth = 1; depth <= 3; depth++) {
|
||||
BlockPos floor = base.below(depth);
|
||||
if (!level.getBlockState(floor).isCollisionShapeFullBlock(level, floor)) return false;
|
||||
}
|
||||
// A complete conservative column around the cap handles both brown umbrellas
|
||||
// and the lower rounded red cap. Neither natural rock nor existing logs move.
|
||||
for (int y = 0; y <= height + 1; y++) for (int dx = -radius; dx <= radius; dx++) for (int dz = -radius; dz <= radius; dz++) {
|
||||
BlockPos p = base.offset(dx, y, dz);
|
||||
BlockState current = level.getBlockState(p);
|
||||
if (sampler.sample(p.getX(), p.getY(), p.getZ()) > 0 || !current.getFluidState().isEmpty()
|
||||
|| !(current.isAir() || current.is(BlockTags.LEAVES))) return false;
|
||||
}
|
||||
// Being below another surface is not enough: the same column needs an actual
|
||||
// original rock ceiling above the whole mushroom, not an open hillside.
|
||||
for (int y = base.getY() + height + 2; y <= level.getMaxY(); y++)
|
||||
if (sampler.sample(base.getX(), y, base.getZ()) > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int rootDepth(Species species) { return species.key().equals(TreeFeatures.MANGROVE) ? 16 : 4; }
|
||||
|
||||
private static boolean blocked(Protection protection, BlockPos base, int radius, int height) {
|
||||
return blocked(protection, base, radius, height, 4);
|
||||
}
|
||||
|
||||
private static boolean blocked(Protection protection, BlockPos base, int radius, int height, int rootDepth) {
|
||||
return protection.intersects(base, radius, height, rootDepth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Set;
|
||||
|
||||
/** Alpha.9 river catchments, wider basins and rocky wall springs.
|
||||
* This version is separate from NaturalHydrology so previously created worlds keep their original terrain.
|
||||
* Results retain no chunks, sampler, random state or Minecraft objects. */
|
||||
public final class CavernHydrology {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int MAX_CARVE = 16;
|
||||
public static final int MAX_DEPTH = 8;
|
||||
public static final int MAX_LAKE_CARVE = 12;
|
||||
public static final int MAX_RIVER_BANK_CARVE = 12;
|
||||
private static final int GRID = 8;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
|
||||
|
||||
private CavernHydrology() {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Sampler { float sample(int x, int y, int z); }
|
||||
|
||||
public enum Kind { POND, LAKE, RIVER, TERRACE }
|
||||
public enum ShoreMaterial { SAND, GRAVEL, CLAY, STONE, GRASS }
|
||||
public record Point(int x, int z) {}
|
||||
public record Bounds(int minX, int minZ, int maxX, int maxZ) {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
public record FlowBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {}
|
||||
public record Spring(long id, Position source, Position outlet, List<Position> flowPath, FlowBounds flowBounds) {
|
||||
public Spring { flowPath = List.copyOf(flowPath); }
|
||||
}
|
||||
public record Terrace(long id, List<Long> basinIds) {
|
||||
public Terrace { basinIds = List.copyOf(basinIds); }
|
||||
}
|
||||
/** A retained upper basin supplies this outlet. toFeatureId=-1 denotes an open terminal fall. */
|
||||
public record Spill(long id, long fromFeatureId, long toFeatureId, Position source, Position outlet,
|
||||
List<Position> flowPath, FlowBounds flowBounds) {
|
||||
public Spill { flowPath = List.copyOf(flowPath); }
|
||||
}
|
||||
|
||||
/** Replaces existing solids bedY-sedimentDepth+1..bedY, with two intact natural supports below.
|
||||
* Water occupies bedY+1..waterY; everything above it through carveTop is removed. For a dry
|
||||
* terrace waterY=-1, and bedY+1..carveTop is removed (at most two original blocks). */
|
||||
public record Cell(int x, int z, int waterY, int bedY, int carveTop,
|
||||
ShoreMaterial material, long featureId, int sedimentDepth) {
|
||||
public boolean hasWater() { return waterY >= 0; }
|
||||
}
|
||||
|
||||
public record Feature(long id, Kind kind, int waterY, List<Point> path, int waterCells, Bounds bounds) {
|
||||
public Feature { path = List.copyOf(path); }
|
||||
}
|
||||
|
||||
public static final class Plan {
|
||||
private final Map<Long, Cell> cells;
|
||||
private final List<Cell> allCells;
|
||||
private final Map<Long, List<Cell>> layers;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final List<Feature> features;
|
||||
private final List<Spring> springs;
|
||||
private final Map<Long, List<Spring>> springChunks;
|
||||
private final Set<Position> springFlow;
|
||||
private final int sampledColumns;
|
||||
private final long densitySamples;
|
||||
private final List<Terrace> terraces;
|
||||
private final List<Spill> spills;
|
||||
private final Set<Position> spillOpenings;
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow, int columns, long samples) {
|
||||
this(source, features, springs, springFlow, columns, samples, CavernTerraces.Result.EMPTY);
|
||||
}
|
||||
|
||||
private Plan(Map<Long, Cell> source, List<Feature> features, List<Spring> springs, Set<Position> springFlow,
|
||||
int columns, long samples, CavernTerraces.Result lower) {
|
||||
List<Cell> combined = new ArrayList<>(source.values());
|
||||
combined.addAll(lower.cells());
|
||||
List<Cell> sorted = combined.stream()
|
||||
.sorted(Comparator.comparingInt(Cell::x).thenComparingInt(Cell::z).thenComparingInt(Cell::bedY)).toList();
|
||||
allCells = List.copyOf(sorted);
|
||||
Map<Long, Cell> ordered = new LinkedHashMap<>();
|
||||
Map<Long, List<Cell>> stacked = new HashMap<>();
|
||||
Map<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : sorted) {
|
||||
ordered.put(key(cell.x(), cell.z()), cell);
|
||||
stacked.computeIfAbsent(key(cell.x(), cell.z()), ignored -> new ArrayList<>()).add(cell);
|
||||
byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4), ignored -> new ArrayList<>()).add(cell);
|
||||
}
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
cells = Collections.unmodifiableMap(ordered);
|
||||
stacked.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
layers = Map.copyOf(stacked);
|
||||
chunks = Map.copyOf(byChunk);
|
||||
List<Feature> allFeatures = new ArrayList<>(features);
|
||||
allFeatures.addAll(lower.features());
|
||||
this.features = List.copyOf(allFeatures);
|
||||
this.springs = List.copyOf(springs);
|
||||
Set<Position> allFlow = new HashSet<>(springFlow);
|
||||
allFlow.addAll(lower.predictedFlow());
|
||||
this.springFlow = Set.copyOf(allFlow);
|
||||
terraces = List.copyOf(lower.terraces());
|
||||
spills = List.copyOf(lower.spills());
|
||||
spillOpenings = spills.stream().map(Spill::outlet).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
Map<Long, List<Spring>> groupedSprings = new HashMap<>();
|
||||
for (Spring spring : springs) groupedSprings.computeIfAbsent(
|
||||
key(spring.source().x() >> 4, spring.source().z() >> 4), ignored -> new ArrayList<>()).add(spring);
|
||||
groupedSprings.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
springChunks = Map.copyOf(groupedSprings);
|
||||
sampledColumns = columns;
|
||||
densitySamples = samples;
|
||||
}
|
||||
|
||||
public Cell cellAt(int x, int z) { return cells.get(key(x, z)); }
|
||||
public List<Cell> cellsAt(int x, int z) { return layers.getOrDefault(key(x, z), List.of()); }
|
||||
/** Return the modified vertical interval, including its two unchanged support layers. */
|
||||
public Cell cellAt(int x, int y, int z) {
|
||||
for (Cell cell : cellsAt(x, z)) if (y >= cell.bedY() - cell.sedimentDepth() - 1
|
||||
&& y <= Math.max(cell.waterY(), cell.carveTop())) return cell;
|
||||
return null;
|
||||
}
|
||||
public Collection<Cell> cells() { return allCells; }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Feature> features() { return features; }
|
||||
public boolean isRiverCell(Cell cell) { return features.stream().anyMatch(f -> f.kind() == Kind.RIVER && f.id() == cell.featureId()); }
|
||||
public boolean isTerraceCell(Cell cell) { return features.stream().anyMatch(f -> f.kind() == Kind.TERRACE && f.id() == cell.featureId()); }
|
||||
public List<Terrace> terraces() { return terraces; }
|
||||
public List<Spill> spills() { return spills; }
|
||||
public boolean isSpillOpening(int x, int y, int z) { return spillOpenings.contains(new Position(x, y, z)); }
|
||||
public List<Spring> springs() { return springs; }
|
||||
public List<Spring> springsInChunk(int x, int z) { return springChunks.getOrDefault(key(x, z), List.of()); }
|
||||
/** Prediction in undecorated density, not a restriction on Minecraft fluid simulation. */
|
||||
public boolean allowsSpringFlow(int x, int y, int z) { return springFlow.contains(new Position(x, y, z)); }
|
||||
public int sampledColumns() { return sampledColumns; }
|
||||
public long densitySamples() { return densitySamples; }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, Sampler sampler) {
|
||||
return new Planner(seed, sampler).build();
|
||||
}
|
||||
|
||||
private record Column(int top, int solidBottom) {}
|
||||
private record Candidate(int x, int z, double score) {}
|
||||
private record Node(int x, int z, double cost) {}
|
||||
private static final Comparator<Node> NODE_ORDER = Comparator.comparingDouble(Node::cost)
|
||||
.thenComparingInt(Node::x).thenComparingInt(Node::z);
|
||||
|
||||
private static final class Planner {
|
||||
private final long seed;
|
||||
private final Sampler sampler;
|
||||
private final Map<Long, Column> columns = new HashMap<>();
|
||||
private final Map<Long, Integer> coarse = new HashMap<>();
|
||||
private final Map<Long, Cell> cells = new HashMap<>();
|
||||
private final List<Feature> features = new ArrayList<>();
|
||||
private final List<Spring> springs = new ArrayList<>();
|
||||
private final Set<Position> springFlow = new HashSet<>();
|
||||
private long samples;
|
||||
|
||||
Planner(long seed, Sampler sampler) { this.seed = seed; this.sampler = sampler; }
|
||||
|
||||
Plan build() {
|
||||
List<Candidate> candidates = candidates();
|
||||
addRiver();
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (features.size() >= 4 || attempts++ >= 96) break;
|
||||
Point center = new Point(candidate.x(), candidate.z());
|
||||
if (nearWater(center.x(), center.z(), 38)) continue;
|
||||
Column column = column(center.x(), center.z());
|
||||
if (column.top() < 64 || column.top() - column.solidBottom() < 7) continue;
|
||||
Kind kind = features.size() < 2 ? Kind.LAKE : Kind.POND;
|
||||
int target = kind == Kind.LAKE ? 700 + (int) (random(seed, center.x(), center.z()) * 400)
|
||||
: 100 + (int) (random(seed + 1, center.x(), center.z()) * 140);
|
||||
// Prefer an existing low spot; a shallow cut is the fallback on level terrain.
|
||||
for (int level : new int[]{column.top() + 1, column.top(), column.top() - 1, column.top() - 2}) {
|
||||
Set<Long> footprint = growBasin(center, level, target);
|
||||
if (footprint == null) continue;
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> wet = kind == Kind.LAKE ? lakeCells(footprint, level, id) : waterCells(footprint, level, id);
|
||||
if (wet == null || !contained(wet)) continue;
|
||||
addFeature(id, kind, level, List.of(center), wet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
addRiverSpring();
|
||||
addSedimentSheets();
|
||||
addLowerWallSprings();
|
||||
addSprings();
|
||||
Plan surface = new Plan(cells, features, springs, springFlow, columns.size(), samples);
|
||||
var lower = CavernTerraces.create(seed, sampler, surface);
|
||||
return new Plan(cells, features, springs, springFlow, columns.size(), samples, lower);
|
||||
}
|
||||
|
||||
/** A connected catchment is excavated only in a thick natural plateau. The route is
|
||||
* selected on the actual fissured density, not painted across missing rock. Its two
|
||||
* terminal pools share the same retained waterline; a separate rock spring feeds it. */
|
||||
private void addRiver() {
|
||||
List<Integer> heights = coarse.values().stream().filter(y -> y >= 160).sorted().toList();
|
||||
if (heights.isEmpty()) return;
|
||||
Set<Integer> levels = new java.util.LinkedHashSet<>();
|
||||
for (double quantile : new double[]{.55, .35, .75, .15, .90}) {
|
||||
int top = heights.get(Math.min(heights.size() - 1, (int) (heights.size() * quantile)));
|
||||
for (int cut : new int[]{4, 8, 0}) levels.add(top - cut);
|
||||
}
|
||||
for (int level : levels) {
|
||||
Set<Long> allowed = new HashSet<>();
|
||||
for (int x = -168; x <= 168; x += 4) for (int z = -168; z <= 168; z += 4) {
|
||||
if (!inside(x, z, 170)) continue;
|
||||
boolean safe = riverRock(x, z, level);
|
||||
for (int dx : new int[]{-3, 3}) for (int dz : new int[]{-3, 3}) {
|
||||
if (safe && !riverRock(x + dx, z + dz, level)) safe = false;
|
||||
}
|
||||
if (safe) allowed.add(key(x, z));
|
||||
}
|
||||
for (int attempt = 0; attempt < 4 && allowed.size() > 100; attempt++) {
|
||||
List<Point> route = longestRoute(allowed, level);
|
||||
if (route.size() < 36) break;
|
||||
Point first = route.getFirst(), last = route.getLast();
|
||||
if (Math.hypot(last.x() - first.x(), last.z() - first.z()) < 100) break;
|
||||
List<Point> path = smoothRoute(route);
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Set<Long> bad = new HashSet<>();
|
||||
for (Point at : path) {
|
||||
int radius = field(at.x(), at.z(), 23, 8119) > .52 ? 3 : 2;
|
||||
for (int dx = -radius - 1; dx <= radius + 1; dx++) for (int dz = -radius - 1; dz <= radius + 1; dz++) {
|
||||
if (!riverRock(at.x() + dx, at.z() + dz, level)) {
|
||||
for (int gx = -1; gx <= 1; gx++) for (int gz = -1; gz <= 1; gz++)
|
||||
bad.add(key(Math.floorDiv(at.x(), 4) * 4 + gx * 4, Math.floorDiv(at.z(), 4) * 4 + gz * 4));
|
||||
}
|
||||
if (dx * dx + dz * dz <= radius * radius + 1) wet.add(key(at.x() + dx, at.z() + dz));
|
||||
}
|
||||
}
|
||||
if (!bad.isEmpty()) { allowed.removeAll(bad); continue; }
|
||||
// Both end pools are required and remain connected to the channel.
|
||||
boolean pools = true;
|
||||
for (Point endpoint : List.of(first, last)) {
|
||||
Set<Long> pool = growRiverPool(endpoint, level);
|
||||
if (pool.size() < 90) { pools = false; break; }
|
||||
wet.addAll(pool);
|
||||
}
|
||||
if (!pools) { allowed.remove(key(first.x(), first.z())); allowed.remove(key(last.x(), last.z())); continue; }
|
||||
long id = featureId(features.size());
|
||||
Map<Long, Cell> river = new HashMap<>();
|
||||
boolean safe = true;
|
||||
for (long k : wet) {
|
||||
Point at = point(k);
|
||||
Column rock = column(at.x(), at.z());
|
||||
int depth = 2;
|
||||
boolean edge = false;
|
||||
for (int[] d : CARDINALS) edge |= !wet.contains(key(at.x() + d[0], at.z() + d[1]));
|
||||
if (edge) depth = 1;
|
||||
int bed = level - depth;
|
||||
if (rock.top() < bed || rock.top() - bed > MAX_CARVE || rock.solidBottom() > bed - 6) { safe = false; break; }
|
||||
river.put(k, new Cell(at.x(), at.z(), level, bed, Math.max(level, rock.top()),
|
||||
wetMaterial(at.x(), at.z(), edge), id, 3));
|
||||
}
|
||||
if (!safe || !contained(river)) break;
|
||||
addFeature(id, Kind.RIVER, level, path, river);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Long> growRiverPool(Point center, int level) {
|
||||
Set<Long> result = new HashSet<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
Set<Long> seen = new HashSet<>();
|
||||
queue.add(new Node(center.x(), center.z(), 0));
|
||||
while (!queue.isEmpty() && result.size() < 240) {
|
||||
Node at = queue.remove();
|
||||
if (!seen.add(key(at.x(), at.z())) || Math.hypot(at.x() - center.x(), at.z() - center.z()) > 12
|
||||
|| !riverRock(at.x(), at.z(), level)) continue;
|
||||
boolean retained = true;
|
||||
for (int[] d : CARDINALS) retained &= solid(at.x() + d[0], level, at.z() + d[1]);
|
||||
if (!retained) continue;
|
||||
result.add(key(at.x(), at.z()));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = at.x() + d[0], z = at.z() + d[1];
|
||||
queue.add(new Node(x, z, Math.hypot(x - center.x(), z - center.z())
|
||||
+ field(x, z, 8, 411) * 6 + Math.max(0, column(x,z).top() - level) * .4));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean riverRock(int x, int z, int level) {
|
||||
Column rock = column(x, z);
|
||||
return rock.top() >= level && rock.top() <= level + 12 && rock.solidBottom() <= level - 8;
|
||||
}
|
||||
|
||||
private List<Point> longestRoute(Set<Long> allowed, int level) {
|
||||
Set<Long> remaining = new HashSet<>(allowed);
|
||||
List<Point> longest = List.of();
|
||||
while (!remaining.isEmpty()) {
|
||||
long start = remaining.stream().min(Long::compare).orElseThrow();
|
||||
Map<Long, Long> first = routeTree(start, allowed);
|
||||
remaining.removeAll(first.keySet());
|
||||
if (first.size() < 36) continue;
|
||||
long far = lastKey(first);
|
||||
Map<Long, Long> second = routeTree(far, first.keySet());
|
||||
long end = lastKey(second);
|
||||
Map<Long, Long> shaped = riverRouteTree(far, end, first.keySet(), level);
|
||||
List<Point> path = new ArrayList<>();
|
||||
for (long at = end; ; at = shaped.get(at)) {
|
||||
path.add(point(at));
|
||||
if (at == far) break;
|
||||
}
|
||||
if (path.size() > longest.size()) longest = path;
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
|
||||
private Map<Long, Long> riverRouteTree(long start, long end, Set<Long> allowed, int level) {
|
||||
Map<Long, Long> parent = new HashMap<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
Point origin = point(start);
|
||||
parent.put(start, start); costs.put(start, 0.0); queue.add(new Node(origin.x(), origin.z(), 0));
|
||||
while (!queue.isEmpty()) {
|
||||
Node at = queue.remove();
|
||||
long current = key(at.x(), at.z());
|
||||
if (at.cost() > costs.getOrDefault(current, Double.POSITIVE_INFINITY)) continue;
|
||||
if (current == end) break;
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = at.x() + d[0] * 4, z = at.z() + d[1] * 4;
|
||||
long next = key(x, z);
|
||||
if (!allowed.contains(next)) continue;
|
||||
double cost = at.cost() + Math.hypot(d[0], d[1]) * (1 + Math.max(0, column(x,z).top() - level) * .14
|
||||
+ field(x, z, 27, 8113) * 1.8);
|
||||
if (cost >= costs.getOrDefault(next, Double.POSITIVE_INFINITY)) continue;
|
||||
parent.put(next, current); costs.put(next, cost); queue.add(new Node(x,z,cost));
|
||||
}
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
private List<Point> smoothRoute(List<Point> coarsePath) {
|
||||
record Smooth(double x, double z) {}
|
||||
List<Smooth> values = coarsePath.stream().map(p -> new Smooth(p.x(), p.z())).toList();
|
||||
// Bend over several grid steps rather than rounding each voxel-sized corner.
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
List<Smooth> next = new ArrayList<>();
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
if (i == 0 || i == values.size() - 1) { next.add(values.get(i)); continue; }
|
||||
double x = 0, z = 0, sum = 0;
|
||||
for (int offset = -4; offset <= 4; offset++) {
|
||||
int weight = 5 - Math.abs(offset);
|
||||
Smooth p = values.get(Math.clamp(i + offset, 0, values.size() - 1));
|
||||
x += p.x() * weight; z += p.z() * weight; sum += weight;
|
||||
}
|
||||
next.add(new Smooth(x / sum, z / sum));
|
||||
}
|
||||
values = next;
|
||||
}
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
List<Smooth> next = new ArrayList<>(); next.add(values.getFirst());
|
||||
for (int i = 1; i < values.size(); i++) {
|
||||
Smooth a = values.get(i - 1), b = values.get(i);
|
||||
next.add(new Smooth(a.x() * .75 + b.x() * .25, a.z() * .75 + b.z() * .25));
|
||||
next.add(new Smooth(a.x() * .25 + b.x() * .75, a.z() * .25 + b.z() * .75));
|
||||
}
|
||||
next.add(values.getLast()); values = next;
|
||||
}
|
||||
List<Point> path = new ArrayList<>();
|
||||
for (int i = 1; i < values.size(); i++) {
|
||||
Smooth a = values.get(i - 1), b = values.get(i);
|
||||
int steps = Math.max(1, (int) Math.ceil(Math.max(Math.abs(b.x() - a.x()), Math.abs(b.z() - a.z()))));
|
||||
for (int step = 0; step <= steps; step++) {
|
||||
double t = step / (double) steps;
|
||||
Point p = new Point((int) Math.round(a.x() + (b.x() - a.x()) * t), (int) Math.round(a.z() + (b.z() - a.z()) * t));
|
||||
if (path.isEmpty() || !path.getLast().equals(p)) path.add(p);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private Map<Long, Long> routeTree(long start, Set<Long> allowed) {
|
||||
Map<Long, Long> parent = new LinkedHashMap<>();
|
||||
ArrayDeque<Long> queue = new ArrayDeque<>();
|
||||
parent.put(start, start); queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
long current = queue.removeFirst();
|
||||
Point at = point(current);
|
||||
for (int[] d : NEIGHBORS) {
|
||||
long next = key(at.x() + d[0] * 4, at.z() + d[1] * 4);
|
||||
if (allowed.contains(next) && !parent.containsKey(next)) { parent.put(next, current); queue.addLast(next); }
|
||||
}
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
private long lastKey(Map<Long, Long> values) {
|
||||
long result = 0;
|
||||
for (long key : values.keySet()) result = key;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A real spring emerges from one of the river's taller rock banks and drops into the
|
||||
* connected retained channel. The source replaces rock; its mouth is the carved valley. */
|
||||
private void addRiverSpring() {
|
||||
Feature river = features.stream().filter(f -> f.kind() == Kind.RIVER).findFirst().orElse(null);
|
||||
if (river == null) return;
|
||||
List<Cell> bank = cells.values().stream().filter(c -> c.featureId() == river.id())
|
||||
.sorted(Comparator.comparingInt(Cell::carveTop).reversed().thenComparingInt(Cell::x).thenComparingInt(Cell::z)).toList();
|
||||
for (Cell wet : bank) for (int[] d : CARDINALS) {
|
||||
int x = wet.x() + d[0], z = wet.z() + d[1];
|
||||
if (cells.containsKey(key(x, z))) continue;
|
||||
for (int height : new int[]{8, 7, 6, 5}) {
|
||||
int y = wet.waterY() + height;
|
||||
if (wet.carveTop() < y) continue;
|
||||
Position source = new Position(x, y, z), outlet = new Position(wet.x(), y, wet.z());
|
||||
boolean safe = true;
|
||||
for (int sy = y - 3; sy <= y + 1; sy++) safe &= flowSolid(new Position(x, sy, z), outlet);
|
||||
for (int[] side : CARDINALS) {
|
||||
if (side[0] == -d[0] && side[1] == -d[1]) continue;
|
||||
safe &= flowSolid(new Position(x + side[0], y, z + side[1]), outlet);
|
||||
}
|
||||
if (!safe) continue;
|
||||
Flow flow = traceSpring(source, outlet);
|
||||
if (flow == null || flow.volume().stream().noneMatch(p -> {
|
||||
Cell c = cells.get(key(p.x(), p.z()));
|
||||
return c != null && c.featureId() == river.id() && p.y() <= c.waterY() && p.y() > c.bedY();
|
||||
})) continue;
|
||||
long id = mix(seed ^ 0xA81BE2L);
|
||||
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
|
||||
springFlow.addAll(flow.volume());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Candidate> candidates() {
|
||||
for (int x = -RADIUS; x <= RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS; z <= RADIUS; z += GRID) {
|
||||
if (inside(x, z, RADIUS)) coarse.put(key(x, z), coarseTop(x, z));
|
||||
}
|
||||
}
|
||||
List<Candidate> result = new ArrayList<>();
|
||||
for (int x = -RADIUS + GRID; x < RADIUS; x += GRID) {
|
||||
for (int z = -RADIUS + GRID; z < RADIUS; z += GRID) {
|
||||
int height = coarse.getOrDefault(key(x, z), -1);
|
||||
if (height < 64 || !inside(x, z, RADIUS - 24)) continue;
|
||||
double average = 0;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
int neighbor = coarse.getOrDefault(key(x + d[0] * GRID, z + d[1] * GRID), -1);
|
||||
if (neighbor < 64 || Math.abs(neighbor - height) > 16) safe = false;
|
||||
average += neighbor * 0.25;
|
||||
}
|
||||
if (safe) result.add(new Candidate(x, z,
|
||||
(average - height) * 2.0 + random(seed + 2, x, z) * 3.0));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z));
|
||||
return result;
|
||||
}
|
||||
|
||||
private int coarseTop(int x, int z) {
|
||||
for (int y = 383; y >= 32; y -= 4) if (solid(x, y, z)) return y;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private Column column(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Column old = columns.get(key);
|
||||
if (old != null) return old;
|
||||
int top = -1;
|
||||
// Exact scan, including everything above the planned water: a cave roof cannot be
|
||||
// mistaken for a surface. Only candidates and their nearby shore need this refinement.
|
||||
for (int y = 383; y >= 24; y--) {
|
||||
if (solid(x, y, z)) { top = y; break; }
|
||||
}
|
||||
int bottom = top;
|
||||
if (top >= 0) {
|
||||
while (bottom > Math.max(16, top - 40) && solid(x, bottom - 1, z)) bottom--;
|
||||
}
|
||||
Column created = new Column(top, bottom);
|
||||
columns.put(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private boolean eligible(int x, int z, int level) {
|
||||
if (level < 32 || level >= 383 || !inside(x, z, RADIUS - 2)) return false;
|
||||
Cell old = cells.get(key(x, z));
|
||||
if (old != null && old.hasWater() && old.waterY() != level) return false;
|
||||
Column column = column(x, z);
|
||||
return column.top() >= level - 3 && column.top() < level + 6
|
||||
&& column.solidBottom() <= Math.min(column.top(), level - 1) - 4;
|
||||
}
|
||||
|
||||
private Set<Long> growBasin(Point center, int level, int target) {
|
||||
if (!eligible(center.x(), center.z(), level)) return null;
|
||||
Set<Long> wet = new HashSet<>();
|
||||
Map<Long, Double> costs = new HashMap<>();
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(NODE_ORDER);
|
||||
queue.add(new Node(center.x(), center.z(), 0));
|
||||
costs.put(key(center.x(), center.z()), 0.0);
|
||||
while (!queue.isEmpty() && wet.size() < target) {
|
||||
Node current = queue.remove();
|
||||
if (current.cost() > costs.getOrDefault(key(current.x(), current.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
if (!eligible(current.x(), current.z(), level)) continue;
|
||||
wet.add(key(current.x(), current.z()));
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = current.x() + d[0], z = current.z() + d[1];
|
||||
if (Math.hypot(x - center.x(), z - center.z()) > 40 || wet.contains(key(x, z))) continue;
|
||||
if (!eligible(x, z, level)) continue;
|
||||
double step = Math.hypot(d[0], d[1]);
|
||||
double cost = current.cost() + step * (1 + Math.max(0, column(x, z).top() - level) * 0.8
|
||||
+ terrainNoise(x, z) * 4.5 + field(x + 71, z - 93, 21, 107) * 3.5);
|
||||
if (cost >= costs.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
|
||||
costs.put(key(x, z), cost);
|
||||
queue.add(new Node(x, z, cost));
|
||||
}
|
||||
}
|
||||
wet = connectedTo(wet, key(center.x(), center.z()));
|
||||
if (wet.size() < Math.min(target, 30)) return null;
|
||||
// Filling a hollow requires every lower connected outlet to be included. Abort rather
|
||||
// than place a dam if this natural basin runs to a cliff, cave or an oversized catchment.
|
||||
List<Long> work = new ArrayList<>(wet);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = point.x() + d[0], z = point.z() + d[1];
|
||||
long neighbor = key(x, z);
|
||||
if (wet.contains(neighbor) || column(x, z).top() >= level) continue;
|
||||
if (wet.size() >= 1200 || Math.hypot(x - center.x(), z - center.z()) > 48
|
||||
|| !eligible(x, z, level)) return null;
|
||||
wet.add(neighbor);
|
||||
work.add(neighbor);
|
||||
}
|
||||
}
|
||||
return wet;
|
||||
}
|
||||
|
||||
private Map<Long, Cell> waterCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Cell> result = new HashMap<>();
|
||||
for (long key : footprint) {
|
||||
Point p = point(key);
|
||||
if (!eligible(p.x(), p.z(), level)) return null;
|
||||
boolean edge = false;
|
||||
for (int[] d : CARDINALS) edge |= !footprint.contains(key(p.x() + d[0], p.z() + d[1]));
|
||||
Column column = column(p.x(), p.z());
|
||||
int depth = Math.max(level - column.top() + 1, edge ? 1 : 2 + (terrainNoise(p.x(), p.z()) > 0.55 ? 1 : 0));
|
||||
depth = Math.min(depth, 8 - Math.max(0, column.top() - level));
|
||||
if (depth < 1 || depth > MAX_DEPTH) return null;
|
||||
int bed = level - depth;
|
||||
if (column.top() < bed || column.solidBottom() > bed - 2) return null;
|
||||
ShoreMaterial material = wetMaterial(p.x(), p.z(), edge);
|
||||
int sedimentDepth = sedimentDepth(p.x(), p.z());
|
||||
if (column.solidBottom() > bed - sedimentDepth - 1) return null;
|
||||
result.put(key, new Cell(p.x(), p.z(), level, bed, Math.max(level, column.top()),
|
||||
material, id, sedimentDepth));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Deepen only sufficiently thick interiors, with one-block shore steps. Natural weak
|
||||
* columns keep a shallower bed instead of receiving a manufactured lake foundation. */
|
||||
private Map<Long, Cell> lakeCells(Set<Long> footprint, int level, long id) {
|
||||
Map<Long, Integer> distance = new HashMap<>();
|
||||
ArrayDeque<Long> queue = new ArrayDeque<>();
|
||||
for (long k : footprint) {
|
||||
Point at = point(k);
|
||||
for (int[] d : CARDINALS) if (!footprint.contains(key(at.x() + d[0], at.z() + d[1]))) {
|
||||
distance.put(k, 0); queue.addLast(k); break;
|
||||
}
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
long k = queue.removeFirst(); Point at = point(k);
|
||||
for (int[] d : CARDINALS) {
|
||||
long next = key(at.x() + d[0], at.z() + d[1]);
|
||||
if (footprint.contains(next) && !distance.containsKey(next)) {
|
||||
distance.put(next, distance.get(k) + 1); queue.addLast(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<Long, Cell> result = new HashMap<>();
|
||||
for (long k : footprint) {
|
||||
Point at = point(k);
|
||||
if (!eligible(at.x(), at.z(), level)) return null;
|
||||
Column rock = column(at.x(), at.z());
|
||||
int sediment = sedimentDepth(at.x(), at.z());
|
||||
int wanted = Math.max(level - rock.top() + 1, Math.min(MAX_DEPTH, 1 + distance.get(k)));
|
||||
int depth = Math.min(wanted, Math.min(level - rock.solidBottom() - sediment - 1,
|
||||
MAX_LAKE_CARVE - Math.max(0, rock.top() - level)));
|
||||
if (depth < 1 || rock.top() < level - depth) return null;
|
||||
int bed = level - depth;
|
||||
result.put(k, new Cell(at.x(), at.z(), level, bed, Math.max(level, rock.top()),
|
||||
wetMaterial(at.x(), at.z(), distance.get(k) == 0), id, sediment));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean contained(Map<Long, Cell> wet) {
|
||||
for (Cell cell : wet.values()) {
|
||||
for (int y = cell.bedY() - 2; y <= cell.bedY(); y++) {
|
||||
if (!solid(cell.x(), y, cell.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
int x = cell.x() + d[0], z = cell.z() + d[1];
|
||||
Cell neighbor = wet.get(key(x, z));
|
||||
if (neighbor == null) neighbor = cells.get(key(x, z));
|
||||
for (int y = cell.bedY() + 1; y <= cell.waterY(); y++) {
|
||||
if (neighbor != null && neighbor.hasWater()) {
|
||||
if (neighbor.waterY() != cell.waterY()) return false;
|
||||
if (y > neighbor.bedY()) continue;
|
||||
}
|
||||
if (!solid(x, y, z)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addFeature(long id, Kind kind, int level, List<Point> path, Map<Long, Cell> wet) {
|
||||
int minX = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
int count = 0;
|
||||
for (Cell cell : wet.values()) {
|
||||
Cell old = cells.get(key(cell.x(), cell.z()));
|
||||
if (old == null || !old.hasWater()) { cells.put(key(cell.x(), cell.z()), cell); count++; }
|
||||
minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z());
|
||||
maxX = Math.max(maxX, cell.x()); maxZ = Math.max(maxZ, cell.z());
|
||||
}
|
||||
features.add(new Feature(id, kind, level, path, count, new Bounds(minX, minZ, maxX, maxZ)));
|
||||
}
|
||||
|
||||
private static Set<Long> connectedTo(Set<Long> footprint, long start) {
|
||||
Set<Long> connected = new HashSet<>();
|
||||
List<Long> work = new ArrayList<>();
|
||||
connected.add(start); work.add(start);
|
||||
for (int i = 0; i < work.size(); i++) {
|
||||
Point point = point(work.get(i));
|
||||
for (int[] d : CARDINALS) {
|
||||
long next = key(point.x() + d[0], point.z() + d[1]);
|
||||
if (footprint.contains(next) && connected.add(next)) work.add(next);
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
private record ShoreNode(int x, int z, int level, long feature, double cost) {}
|
||||
|
||||
/** Sediment follows shallow topography in broad, discontinuous sheets. A second nearby
|
||||
* pocket can share the same sheet; the result is not a one-block material ring. */
|
||||
private void addSedimentSheets() {
|
||||
Comparator<ShoreNode> order = Comparator.comparingDouble(ShoreNode::cost)
|
||||
.thenComparingInt(ShoreNode::x).thenComparingInt(ShoreNode::z).thenComparingLong(ShoreNode::feature);
|
||||
Set<Long> rivers = features.stream().filter(f -> f.kind() == Kind.RIVER).map(Feature::id)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
PriorityQueue<ShoreNode> queue = new PriorityQueue<>(order);
|
||||
Map<Long, Double> best = new HashMap<>();
|
||||
for (Cell cell : cells.values()) if (cell.hasWater()) {
|
||||
queue.add(new ShoreNode(cell.x(), cell.z(), cell.waterY(), cell.featureId(), 0));
|
||||
best.put(key(cell.x(), cell.z()), 0.0);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
ShoreNode node = queue.remove();
|
||||
if (node.cost() > best.getOrDefault(key(node.x(), node.z()), Double.POSITIVE_INFINITY)) continue;
|
||||
boolean riverBank = rivers.contains(node.feature());
|
||||
Column column = column(node.x(), node.z());
|
||||
Cell existing = cells.get(key(node.x(), node.z()));
|
||||
if (existing == null) {
|
||||
boolean springRock = springs.stream().anyMatch(spring ->
|
||||
Math.abs(spring.source().x() - node.x()) <= 4 && Math.abs(spring.source().z() - node.z()) <= 4);
|
||||
if (springRock) continue;
|
||||
double extent = riverBank ? 18 + field(node.x(), node.z(), 25, 131) * 6
|
||||
: 9 + field(node.x(), node.z(), 25, 131) * 10;
|
||||
if (node.cost() > extent) continue;
|
||||
int delta = column.top() - node.level();
|
||||
if (delta < 0 || delta > (riverBank ? 14 : 5)) continue;
|
||||
// Low shore: waterline, then one-block steps. Cut at most two existing blocks;
|
||||
// far deposits keep the original landform and feather back into grass/stone.
|
||||
int terrace = node.level() + (int) Math.floor(node.cost() / (riverBank ? 2.5 : 4.5));
|
||||
int cut = riverBank ? MAX_RIVER_BANK_CARVE : 2;
|
||||
int bed = Math.max(node.level(), Math.max(column.top() - cut, Math.min(column.top(), terrace)));
|
||||
int depth = sedimentDepth(node.x(), node.z());
|
||||
if (column.solidBottom() > bed - depth - 1) continue;
|
||||
boolean safe = true;
|
||||
for (int[] d : CARDINALS) {
|
||||
Cell next = cells.get(key(node.x() + d[0], node.z() + d[1]));
|
||||
if (next != null && next.hasWater() && bed < next.waterY()) safe = false;
|
||||
}
|
||||
if (!safe) continue;
|
||||
ShoreMaterial material = dryMaterial(node.x(), node.z(), node.cost() / extent);
|
||||
cells.put(key(node.x(), node.z()), new Cell(node.x(), node.z(), -1, bed,
|
||||
column.top(), material, node.feature(), depth));
|
||||
}
|
||||
for (int[] d : NEIGHBORS) {
|
||||
int x = node.x() + d[0], z = node.z() + d[1];
|
||||
if (!inside(x, z, RADIUS - 2)) continue;
|
||||
Cell water = cells.get(key(x, z));
|
||||
if (water != null && water.hasWater()) continue;
|
||||
Column next = column(x, z);
|
||||
if (next.top() < node.level() || next.top() > node.level() + (riverBank ? 14 : 5)) continue;
|
||||
double cost = node.cost() + Math.hypot(d[0], d[1])
|
||||
* (0.7 + Math.abs(next.top() - column.top()) * 0.32 + field(x, z, 12, 211) * 0.6);
|
||||
if (cost > (riverBank ? 24 : 19) || cost >= best.getOrDefault(key(x, z), Double.POSITIVE_INFINITY)) continue;
|
||||
best.put(key(x, z), cost);
|
||||
queue.add(new ShoreNode(x, z, node.level(), node.feature(), cost));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int sedimentDepth(int x, int z) { return 3 + (int) (field(x, z, 15, 319) * 2.999); }
|
||||
|
||||
private ShoreMaterial wetMaterial(int x, int z, boolean edge) {
|
||||
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
|
||||
if (geology < 0.46) return ShoreMaterial.STONE;
|
||||
if (geology < 0.65) return ShoreMaterial.GRAVEL;
|
||||
if (!edge && geology > 0.78) return ShoreMaterial.CLAY;
|
||||
return ShoreMaterial.SAND;
|
||||
}
|
||||
|
||||
private ShoreMaterial dryMaterial(int x, int z, double distance) {
|
||||
double geology = field(x, z, 24, 503) * 0.75 + field(x, z, 7, 509) * 0.25;
|
||||
double grass = field(x + 103, z - 137, 19, 521);
|
||||
if (grass > 0.62 || distance > 0.60 + grass * 0.65) return ShoreMaterial.GRASS;
|
||||
if (geology < 0.46) return ShoreMaterial.STONE;
|
||||
if (geology < 0.65) return ShoreMaterial.GRAVEL;
|
||||
return ShoreMaterial.SAND;
|
||||
}
|
||||
|
||||
private record Cliff(int x, int z, int dx, int dz, double score) {}
|
||||
private record FlowNode(Position position, int reach) {}
|
||||
private record Flow(Set<Position> volume, List<Position> path, FlowBounds bounds) {}
|
||||
|
||||
/** Pick rare exposed rock niches at the edges of real upper surfaces. The source replaces
|
||||
* one rock block under an existing roof. Its outlet is already air over an eight-block drop;
|
||||
* no source, shelf, dam, rock column or ocean is ever placed in empty terrain. */
|
||||
private void addSprings() {
|
||||
List<Cliff> cliffs = new ArrayList<>();
|
||||
for (int x = -264; x <= 264; x += GRID) for (int z = -264; z <= 264; z += GRID) {
|
||||
if (!inside(x, z, 264)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top < 88) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
int nx = x + d[0] * GRID, nz = z + d[1] * GRID;
|
||||
int lower = coarse.computeIfAbsent(key(nx, nz), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
if (top - lower < 16) continue;
|
||||
cliffs.add(new Cliff(x, z, d[0], d[1],
|
||||
Math.min(64, top - lower) * 0.06 + random(seed + 811, x + d[0], z + d[1]) * 3));
|
||||
}
|
||||
}
|
||||
cliffs.sort(Comparator.comparingDouble(Cliff::score).reversed()
|
||||
.thenComparingInt(Cliff::x).thenComparingInt(Cliff::z)
|
||||
.thenComparingInt(Cliff::dx).thenComparingInt(Cliff::dz));
|
||||
int attempts = 0;
|
||||
for (Cliff cliff : cliffs) {
|
||||
if (springs.size() >= 3 || attempts++ >= 160) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2, -4, 4}) {
|
||||
for (int along = 0; along < GRID; along++) {
|
||||
int x = cliff.x() + cliff.dx() * along - cliff.dz() * tangent;
|
||||
int z = cliff.z() + cliff.dz() * along + cliff.dx() * tangent;
|
||||
if (!inside(x, z, 266)) continue;
|
||||
int ox = x + cliff.dx(), oz = z + cliff.dz();
|
||||
Column rock = column(x, z), outletColumn = column(ox, oz);
|
||||
if (rock.top() < 80 || rock.top() - outletColumn.top() < 14) continue;
|
||||
// Five blocks below the highest natural surface is below the soil cap.
|
||||
int floor = Math.max(64, Math.max(rock.top() - 20, outletColumn.top() + 8));
|
||||
for (int y = rock.top() - 5; y >= floor; y -= 3) {
|
||||
Position source = new Position(x, y, z), outlet = new Position(ox, y, oz);
|
||||
if (!separatedSpring(source) || !springNiche(source, cliff.dx(), cliff.dz()) || nearModifiedGround(source, 32)) continue;
|
||||
Flow flow = traceSpring(source, outlet);
|
||||
if (flow == null || flow.bounds().minY() > y - 12) continue;
|
||||
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
|
||||
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
|
||||
springFlow.addAll(flow.volume());
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean nearModifiedGround(Position source, int distance) {
|
||||
for (Cell cell : cells.values()) {
|
||||
int dy = Math.max(Math.max(cell.bedY() - cell.sedimentDepth() - source.y(), 0),
|
||||
source.y() - cell.carveTop());
|
||||
long dx = source.x() - cell.x(), dz = source.z() - cell.z();
|
||||
if (dx * dx + dz * dz + (long) dy * dy < (long) distance * distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean separatedSpring(Position source) {
|
||||
for (Spring spring : springs) {
|
||||
double horizontal = Math.hypot(source.x() - spring.source().x(), source.z() - spring.source().z());
|
||||
int dy = Math.abs(source.y() - spring.source().y());
|
||||
if (Math.hypot(horizontal, dy) < 64 || dy < 12) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private record Wall(int x, int y, int z, int dx, int dz, double score) {}
|
||||
|
||||
/** Probe a few exposed faces below the highest surface as well. This finds overhangs and
|
||||
* lower strata that a highest-column heightmap cannot represent, without inventing a ledge. */
|
||||
private void addLowerWallSprings() {
|
||||
List<Wall> candidates = new ArrayList<>();
|
||||
for (int x = -240; x <= 240; x += 16) for (int z = -240; z <= 240; z += 16) {
|
||||
if (!inside(x, z, 240)) continue;
|
||||
int top = coarse.computeIfAbsent(key(x, z), ignored -> coarseTop(point(ignored).x(), point(ignored).z()));
|
||||
for (int y = top - 32; y >= 64; y -= 16) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int[] direction : CARDINALS) {
|
||||
if (solid(x + direction[0] * 4, y, z + direction[1] * 4)) continue;
|
||||
candidates.add(new Wall(x, y, z, direction[0], direction[1],
|
||||
random(seed + 1217 + y, x + direction[0], z + direction[1]) * 3
|
||||
+ Math.min(100, top - y) * 0.015));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Wall::score).reversed()
|
||||
.thenComparingInt(Wall::x).thenComparingInt(Wall::z).thenComparingInt(Wall::y)
|
||||
.thenComparingInt(Wall::dx).thenComparingInt(Wall::dz));
|
||||
int attempts = 0;
|
||||
for (Wall candidate : candidates) {
|
||||
if (springs.size() >= 2 || attempts++ >= 96) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2}) {
|
||||
for (int along = 0; along < 4; along++) {
|
||||
int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent;
|
||||
for (int dy : new int[]{0, 3, -3, 6, -6}) {
|
||||
int y = candidate.y() + dy;
|
||||
if (y < 56 || column(x, z).top() - y < 24) continue;
|
||||
Position source = new Position(x, y, z);
|
||||
if (!separatedSpring(source) || !springNiche(source, candidate.dx(), candidate.dz())
|
||||
|| nearModifiedGround(source, 32)) continue;
|
||||
Position outlet = new Position(x + candidate.dx(), y, z + candidate.dz());
|
||||
Flow flow = traceSpring(source, outlet);
|
||||
if (flow == null || flow.bounds().minY() > y - 12) continue;
|
||||
long id = mix(seed ^ (0x5F81A6L + springs.size() * 0x9E3779B97F4A7C15L));
|
||||
springs.add(new Spring(id, source, outlet, flow.path(), flow.bounds()));
|
||||
springFlow.addAll(flow.volume());
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean springNiche(Position source, int dx, int dz) {
|
||||
for (int y = source.y() - 3; y <= source.y() + 1; y++) {
|
||||
if (!solid(source.x(), y, source.z())) return false;
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
if (d[0] == dx && d[1] == dz) continue;
|
||||
if (!solid(source.x() + d[0], source.y(), source.z() + d[1])) return false;
|
||||
}
|
||||
for (int y = source.y(); y >= source.y() - 8; y--) {
|
||||
if (solid(source.x() + dx, y, source.z() + dz)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Predict drainage through undecorated rock to select springs and inspection bounds.
|
||||
* Descend through air first, then explore seven horizontal steps on a shelf. Later trees
|
||||
* can divert the actual fluid: this prediction is not a bound on decorated-world physics.
|
||||
* Integration tests trace actual water back to its source within the inspected chunk halo. */
|
||||
private Flow traceSpring(Position source, Position outlet) {
|
||||
Map<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<FlowNode> queue = new ArrayDeque<>();
|
||||
reach.put(source, 7);
|
||||
queue.add(new FlowNode(source, 7));
|
||||
Position lowest = source;
|
||||
while (!queue.isEmpty()) {
|
||||
FlowNode node = queue.removeFirst();
|
||||
Position at = node.position();
|
||||
if (node.reach() < reach.getOrDefault(at, -1)) continue;
|
||||
if (at.y() < lowest.y()) lowest = at;
|
||||
if (reach.size() > 24000) return null;
|
||||
if (at.y() <= 0) continue;
|
||||
Position below = new Position(at.x(), at.y() - 1, at.z());
|
||||
if (!flowSolid(below, source)) {
|
||||
enqueueFlow(below, 7, at, queue, reach, parent);
|
||||
continue;
|
||||
}
|
||||
if (node.reach() <= 0) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
|
||||
if (flowSolid(next, source)) continue;
|
||||
enqueueFlow(next, node.reach() - 1, at, queue, reach, parent);
|
||||
}
|
||||
}
|
||||
if (!reach.containsKey(outlet)) return null;
|
||||
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY;
|
||||
int minZ = source.z(), maxZ = minZ;
|
||||
for (Position position : reach.keySet()) {
|
||||
minX = Math.min(minX, position.x()); maxX = Math.max(maxX, position.x());
|
||||
minY = Math.min(minY, position.y()); maxY = Math.max(maxY, position.y());
|
||||
minZ = Math.min(minZ, position.z()); maxZ = Math.max(maxZ, position.z());
|
||||
}
|
||||
List<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Flow(Set.copyOf(reach.keySet()), path, new FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Position source) {
|
||||
if (at.equals(source)) return false;
|
||||
Cell cell = cells.get(key(at.x(), at.z()));
|
||||
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
|
||||
return solid(at.x(), at.y(), at.z());
|
||||
}
|
||||
|
||||
private static void enqueueFlow(Position next, int available, Position from, ArrayDeque<FlowNode> queue,
|
||||
Map<Position, Integer> reach, Map<Position, Position> parent) {
|
||||
if (available <= reach.getOrDefault(next, -1)) return;
|
||||
if (!reach.containsKey(next)) parent.put(next, from);
|
||||
reach.put(next, available);
|
||||
queue.addLast(new FlowNode(next, available));
|
||||
}
|
||||
|
||||
private boolean nearWater(int x, int z, int distance) {
|
||||
for (Feature feature : features) {
|
||||
Point point = feature.path().getFirst();
|
||||
if (Math.hypot(x - point.x(), z - point.z()) < distance) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { samples++; return sampler.sample(x, y, z) > 0; }
|
||||
private long featureId(int index) { return mix(seed ^ (0x51A7E7L + index * 0x9E3779B97F4A7C15L)); }
|
||||
|
||||
private double terrainNoise(int x, int z) { return field(x, z, 8, 31); }
|
||||
|
||||
private double field(int x, int z, int scale, long salt) {
|
||||
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
|
||||
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
|
||||
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
|
||||
double a = random(seed + salt, gx, gz) * (1 - tx) + random(seed + salt, gx + 1, gz) * tx;
|
||||
double b = random(seed + salt, gx, gz + 1) * (1 - tx) + random(seed + salt, gx + 1, gz + 1) * tx;
|
||||
return a * (1 - tz) + b * tz;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return Math.hypot((double) x, z) < radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static Point point(long key) { return new Point((int) (key >> 32), (int) key); }
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double random(long seed, int x, int z) { return (mix(seed ^ key(x, z)) >>> 11) * 0x1.0p-53; }
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
|
||||
/** Alpha.9 only: immutable plans cached by world state, applied to fresh terrain before decoration. */
|
||||
public final class CavernHydrologyRuntime {
|
||||
private static final Map<RandomState, CavernHydrology.Plan> PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private static final Map<RandomState, CavernLavaDeposit.Plan> LAVA_PLANS =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private CavernHydrologyRuntime() {}
|
||||
|
||||
public static boolean enabled(NoiseBasedChunkGenerator generator) {
|
||||
return generator.generatorSettings().is(SanctuarySpawn.CAVERN_SETTINGS);
|
||||
}
|
||||
|
||||
public static CavernHydrology.Plan cavernPlan(ServerLevel level) {
|
||||
return plan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static CavernHydrology.Plan plan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
if (!enabled(generator)) throw new IllegalArgumentException("Cavern hydrology is not enabled for this generator");
|
||||
return PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var plan = CavernHydrology.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z));
|
||||
SanctuaryMod.LOGGER.info("Sanctuary cavern hydrology: seed {}, {} features, {} columns, {} springs, {} ms",
|
||||
state.seed(), plan.features().size(), plan.cells().size(), plan.springs().size(),
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary cavern catchments: {}", plan.features().stream().map(f -> f.kind() + " Y" + f.waterY() + " water=" + f.waterCells() + " path=" + f.path().size() + " " + f.bounds()).toList());
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
public static CavernLavaDeposit.Plan lavaPlan(ServerLevel level) {
|
||||
return lavaPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(),
|
||||
level.getChunkSource().randomState());
|
||||
}
|
||||
|
||||
public static CavernLavaDeposit.Plan lavaPlan(NoiseBasedChunkGenerator generator, RandomState randomState) {
|
||||
// Acquire the water plan before the lava cache lock; the water planner never acquires it.
|
||||
var water = plan(generator, randomState);
|
||||
return LAVA_PLANS.computeIfAbsent(randomState, state -> {
|
||||
long start = System.nanoTime();
|
||||
var density = generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
var lava = CavernLavaDeposit.create(state.seed(),
|
||||
(x, y, z) -> state.sampleBlockValueUncached(density, x, y, z), water);
|
||||
SanctuaryMod.LOGGER.info("Sanctuary starter lava: seed {}, {} sources, access {}, {} ms",
|
||||
state.seed(), lava.lavaCells().size(), lava.access(), (System.nanoTime() - start) / 1_000_000);
|
||||
return lava;
|
||||
});
|
||||
}
|
||||
|
||||
public static void apply(NoiseBasedChunkGenerator generator, RandomState randomState, ChunkAccess chunk) {
|
||||
if (!enabled(generator)) return;
|
||||
var chunkPos = chunk.getPos();
|
||||
if (Math.abs((long) chunkPos.x() * 16) > IslandShape.TERRAIN_LIMIT + 16L
|
||||
|| Math.abs((long) chunkPos.z() * 16) > IslandShape.TERRAIN_LIMIT + 16L) return;
|
||||
var plan = plan(generator, randomState);
|
||||
var cells = plan.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var springs = plan.springsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var outlets = new java.util.ArrayList<>(plan.springs().stream().map(CavernHydrology.Spring::outlet)
|
||||
.filter(outlet -> (outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()).toList());
|
||||
for (var spill : plan.spills()) {
|
||||
var outlet = spill.outlet();
|
||||
if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z()) outlets.add(outlet);
|
||||
}
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
for (var fall : lava.falls()) {
|
||||
var outlet = fall.outlet();
|
||||
if ((outlet.x() >> 4) == chunkPos.x() && (outlet.z() >> 4) == chunkPos.z())
|
||||
outlets.add(new CavernHydrology.Position(outlet.x(), outlet.y(), outlet.z()));
|
||||
}
|
||||
var lavaCells = lava.cellsInChunk(chunkPos.x(), chunkPos.z());
|
||||
var lavaSupports = lava.supportsInChunk(chunkPos.x(), chunkPos.z());
|
||||
if (cells.isEmpty() && springs.isEmpty() && outlets.isEmpty() && lavaCells.isEmpty() && lavaSupports.isEmpty()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
// Validate all replacements and two intact support layers before modifying this chunk.
|
||||
for (var cell : cells) {
|
||||
for (int y = cell.bedY() - cell.sedimentDepth() - 1; y <= cell.bedY(); y++) {
|
||||
requireSolid(chunk, pos.set(cell.x(), y, cell.z()), randomState.seed());
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
requireSolid(chunk, pos.set(source.x(), source.y(), source.z()), randomState.seed());
|
||||
}
|
||||
for (var support : lavaSupports) {
|
||||
requireSolid(chunk, pos.set(support.x(), support.y(), support.z()), randomState.seed());
|
||||
if (chunk.getBlockState(pos).ignitedByLava()) {
|
||||
throw new IllegalStateException("Lava pocket requires nonflammable natural support at " + pos);
|
||||
}
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
requireSolid(chunk, pos.set(cell.x(), cell.y(), cell.z()), randomState.seed());
|
||||
}
|
||||
for (var cell : cells) {
|
||||
for (int depth = 0; depth < cell.sedimentDepth(); depth++) {
|
||||
pos.set(cell.x(), cell.bedY() - depth, cell.z());
|
||||
BlockState material = CavernMaterials.sedimentBlock(cell, randomState.seed(), depth);
|
||||
chunk.setBlockState(pos, material, 0);
|
||||
}
|
||||
for (int y = cell.bedY() + 1; y <= Math.max(cell.waterY(), cell.carveTop()); y++) {
|
||||
pos.set(cell.x(), y, cell.z());
|
||||
chunk.setBlockState(pos, cell.hasWater() && y <= cell.waterY()
|
||||
? Blocks.WATER.defaultBlockState() : Blocks.AIR.defaultBlockState(), 0);
|
||||
if (cell.hasWater() && y <= cell.waterY()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
}
|
||||
for (var spring : springs) {
|
||||
var source = spring.source();
|
||||
pos.set(source.x(), source.y(), source.z());
|
||||
chunk.setBlockState(pos, Blocks.WATER.defaultBlockState(), 0);
|
||||
// Only the source is generated. Vanilla fluid ticks create the descending waterfall.
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
for (var cell : lavaCells) {
|
||||
pos.set(cell.x(), cell.y(), cell.z());
|
||||
chunk.setBlockState(pos, (cell.lava() ? Blocks.LAVA : Blocks.AIR).defaultBlockState(), 0);
|
||||
if (cell.lava()) chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
// This existing vanilla queue is consumed after neighbouring chunks finish decoration.
|
||||
// Mark the outlet in its own chunk, including when its source belongs to a neighbour.
|
||||
for (var outlet : outlets) {
|
||||
pos.set(outlet.x(), outlet.y(), outlet.z());
|
||||
chunk.markPosForPostProcessing(pos);
|
||||
}
|
||||
Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
Heightmap.Types.OCEAN_FLOOR_WG));
|
||||
}
|
||||
|
||||
/** Reserve water, sediment and hot-rock volumes before placing vegetation. */
|
||||
public static boolean protects(NoiseBasedChunkGenerator generator, RandomState randomState, BlockPos base,
|
||||
int radius, int height, int rootDepth) {
|
||||
var water = plan(generator, randomState);
|
||||
var lava = lavaPlan(generator, randomState);
|
||||
int low = base.getY() - rootDepth, high = base.getY() + height;
|
||||
for (int x = base.getX() - radius; x <= base.getX() + radius; x++) {
|
||||
for (int z = base.getZ() - radius; z <= base.getZ() + radius; z++) {
|
||||
for (var cell : water.cellsAt(x, z))
|
||||
if (low <= cell.carveTop() && high >= cell.bedY() - cell.sedimentDepth() - 1) return true;
|
||||
}
|
||||
}
|
||||
for (var spring : water.springs()) {
|
||||
var bounds = spring.flowBounds();
|
||||
if (base.getX() + radius >= bounds.minX() - 2 && base.getX() - radius <= bounds.maxX() + 2
|
||||
&& base.getZ() + radius >= bounds.minZ() - 2 && base.getZ() - radius <= bounds.maxZ() + 2
|
||||
&& low <= bounds.maxY() + 2 && high >= bounds.minY() - 2) return true;
|
||||
var source = spring.source();
|
||||
if (Math.abs(base.getX() - source.x()) <= radius + 3 && Math.abs(base.getZ() - source.z()) <= radius + 3
|
||||
&& low <= source.y() + 3 && high >= source.y() - 3) return true;
|
||||
}
|
||||
for (var bounds : lava.flowBounds()) {
|
||||
if (base.getX() + radius >= bounds.minX() - 8 && base.getX() - radius <= bounds.maxX() + 8
|
||||
&& base.getZ() + radius >= bounds.minZ() - 8 && base.getZ() - radius <= bounds.maxZ() + 8
|
||||
&& low <= bounds.maxY() + 8 && high >= bounds.minY() - 8) return true;
|
||||
}
|
||||
for (var spill : water.spills()) {
|
||||
var bounds = spill.flowBounds();
|
||||
if (base.getX() + radius >= bounds.minX() - 3 && base.getX() - radius <= bounds.maxX() + 3
|
||||
&& base.getZ() + radius >= bounds.minZ() - 3 && base.getZ() - radius <= bounds.maxZ() + 3
|
||||
&& low <= bounds.maxY() + 3 && high >= bounds.minY() - 3) return true;
|
||||
}
|
||||
for (var cell : lava.cells()) if (Math.abs(base.getX() - cell.x()) <= radius + 2 && Math.abs(base.getZ() - cell.z()) <= radius + 2
|
||||
&& low <= cell.y() + 2 && high >= cell.y() - 2) return true;
|
||||
for (var support : lava.supports()) if (Math.abs(base.getX() - support.x()) <= radius + 2 && Math.abs(base.getZ() - support.z()) <= radius + 2
|
||||
&& low <= support.y() + 2 && high >= support.y() - 2) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void requireSolid(ChunkAccess chunk, BlockPos pos, long seed) {
|
||||
if (!chunk.getBlockState(pos).isCollisionShapeFullBlock(chunk, pos)) {
|
||||
throw new IllegalStateException("Cavern hydrology differs from terrain at " + pos + " for seed " + seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
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.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
|
||||
/** Seeded alpha.9 rift climate, separate from all saved earlier biome-source codecs. */
|
||||
public final class CavernIslandBiomeSource extends BiomeSource {
|
||||
public static final ResourceKey<Biome> OAK_FOREST = key("cavern_oak_forest");
|
||||
public static final ResourceKey<Biome> BIRCH_FOREST = key("cavern_birch_forest");
|
||||
public static final ResourceKey<Biome> CLEARING = key("cavern_clearing");
|
||||
public static final ResourceKey<Biome> DRY_WOODLAND = key("cavern_dry_woodland");
|
||||
public static final ResourceKey<Biome> ROCKY_HEATH = key("cavern_rocky_heath");
|
||||
public static final ResourceKey<Biome> DARK_GROVE = key("cavern_dark_grove");
|
||||
public static final ResourceKey<Biome> BAMBOO_GROVE = key("cavern_bamboo_grove");
|
||||
public static final ResourceKey<Biome> SULFUR_DEPTHS = key("cavern_sulfur_depths");
|
||||
public static final ResourceKey<Biome> LUSH_CAVES = key("cavern_lush_caves");
|
||||
public static final ResourceKey<Biome> DRIPSTONE_CAVES = key("cavern_dripstone_caves");
|
||||
|
||||
public static final MapCodec<CavernIslandBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
Biome.CODEC.fieldOf("oak_forest").forGetter(source -> source.oakForest),
|
||||
Biome.CODEC.fieldOf("birch_forest").forGetter(source -> source.birchForest),
|
||||
Biome.CODEC.fieldOf("clearing").forGetter(source -> source.clearing),
|
||||
Biome.CODEC.fieldOf("dry_woodland").forGetter(source -> source.dryWoodland),
|
||||
Biome.CODEC.fieldOf("rocky_heath").forGetter(source -> source.rockyHeath),
|
||||
Biome.CODEC.fieldOf("dark_grove").forGetter(source -> source.darkGrove),
|
||||
Biome.CODEC.fieldOf("bamboo_grove").forGetter(source -> source.bambooGrove),
|
||||
Biome.CODEC.fieldOf("sulfur_depths").forGetter(source -> source.sulfurDepths),
|
||||
Biome.CODEC.fieldOf("lush_caves").forGetter(source -> source.lushCaves),
|
||||
Biome.CODEC.fieldOf("dripstone_caves").forGetter(source -> source.dripstoneCaves)
|
||||
).apply(instance, CavernIslandBiomeSource::new));
|
||||
|
||||
private final Holder<Biome> oakForest;
|
||||
private final Holder<Biome> birchForest;
|
||||
private final Holder<Biome> clearing;
|
||||
private final Holder<Biome> dryWoodland;
|
||||
private final Holder<Biome> rockyHeath;
|
||||
private final Holder<Biome> darkGrove;
|
||||
private final Holder<Biome> bambooGrove;
|
||||
private final Holder<Biome> sulfurDepths;
|
||||
private final Holder<Biome> lushCaves;
|
||||
private final Holder<Biome> dripstoneCaves;
|
||||
|
||||
public CavernIslandBiomeSource(Holder<Biome> oakForest, Holder<Biome> birchForest, Holder<Biome> clearing,
|
||||
Holder<Biome> dryWoodland, Holder<Biome> rockyHeath, Holder<Biome> darkGrove,
|
||||
Holder<Biome> bambooGrove, Holder<Biome> sulfurDepths, Holder<Biome> lushCaves, Holder<Biome> dripstoneCaves) {
|
||||
this.oakForest = oakForest;
|
||||
this.birchForest = birchForest;
|
||||
this.clearing = clearing;
|
||||
this.dryWoodland = dryWoodland;
|
||||
this.rockyHeath = rockyHeath;
|
||||
this.darkGrove = darkGrove;
|
||||
this.bambooGrove = bambooGrove;
|
||||
this.sulfurDepths = sulfurDepths;
|
||||
this.lushCaves = lushCaves;
|
||||
this.dripstoneCaves = dripstoneCaves;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MapCodec<CavernIslandBiomeSource> codec() { return CODEC; }
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return Stream.of(oakForest, birchForest, clearing, dryWoodland, rockyHeath, darkGrove, bambooGrove, sulfurDepths, lushCaves, dripstoneCaves);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
return (quartX, quartY, quartZ) -> {
|
||||
int x = quartX * 4, y = quartY * 4, z = quartZ * 4;
|
||||
float variation = sampler.temperature().sampleValue(x, 0, z);
|
||||
float moisture = sampler.humidity().sampleValue(x, 0, z);
|
||||
return switch (CavernBiomePalette.select(y, variation, moisture)) {
|
||||
case OAK_FOREST -> oakForest;
|
||||
case BIRCH_FOREST -> birchForest;
|
||||
case CLEARING -> clearing;
|
||||
case DRY_WOODLAND -> dryWoodland;
|
||||
case ROCKY_HEATH -> rockyHeath;
|
||||
case DARK_GROVE -> darkGrove;
|
||||
case BAMBOO_GROVE -> bambooGrove;
|
||||
case SULFUR_DEPTHS -> sulfurDepths;
|
||||
case LUSH_CAVES -> lushCaves;
|
||||
case DRIPSTONE_CAVES -> dripstoneCaves;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
private static ResourceKey<Biome> key(String path) {
|
||||
return ResourceKey.create(Registries.BIOME, SanctuaryMod.id(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Alpha.9 lava: an optional covered pocket and up to two deep rock outlets.
|
||||
* All sources replace natural rock; Minecraft alone produces the falling lava. */
|
||||
public final class CavernLavaDeposit {
|
||||
public static final int RADIUS = 192;
|
||||
public static final int WATER_CLEARANCE = 32;
|
||||
public static final int LAVA_VOLUME = 18;
|
||||
private static final int ENTRY_DISTANCE = 8;
|
||||
private static final int[][] DIRECTIONS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final Comparator<Position> POSITION_ORDER = Comparator.comparingInt(Position::x)
|
||||
.thenComparingInt(Position::z).thenComparingInt(Position::y);
|
||||
private static final Comparator<Cell> CELL_ORDER = Comparator.comparingInt(Cell::x)
|
||||
.thenComparingInt(Cell::z).thenComparingInt(Cell::y);
|
||||
|
||||
private CavernLavaDeposit() {}
|
||||
|
||||
public record Position(int x, int y, int z) {}
|
||||
/** lava=false removes existing rock to open the two-block-high chamber or approach. */
|
||||
public record Cell(int x, int y, int z, boolean lava) {}
|
||||
|
||||
public static final class Plan {
|
||||
private final List<Cell> cells;
|
||||
private final List<Cell> lavaCells;
|
||||
private final List<Position> supports;
|
||||
private final Optional<Position> access;
|
||||
private final Map<Long, List<Cell>> chunks;
|
||||
private final Map<Long, List<Position>> supportChunks;
|
||||
private final List<CavernLavaFlows.Fall> falls;
|
||||
private final Map<Long, List<CavernLavaFlows.Fall>> fallChunks;
|
||||
|
||||
private Plan(List<Cell> cells, Set<Position> supports, Position access) {
|
||||
this(cells, supports, access, List.of());
|
||||
}
|
||||
|
||||
private Plan(List<Cell> cells, Set<Position> supports, Position access, List<CavernLavaFlows.Fall> falls) {
|
||||
this.falls = List.copyOf(falls);
|
||||
Map<Long, List<CavernLavaFlows.Fall>> groupedFalls = new HashMap<>();
|
||||
for (var fall : falls) groupedFalls.computeIfAbsent(
|
||||
key(fall.source().x() >> 4, fall.source().z() >> 4), ignored -> new ArrayList<>()).add(fall);
|
||||
groupedFalls.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
fallChunks = Map.copyOf(groupedFalls);
|
||||
this.cells = cells.stream().sorted(CELL_ORDER).toList();
|
||||
lavaCells = this.cells.stream().filter(Cell::lava).toList();
|
||||
this.supports = supports.stream().sorted(POSITION_ORDER).toList();
|
||||
this.access = Optional.ofNullable(access);
|
||||
Map<Long, List<Cell>> byChunk = new HashMap<>();
|
||||
for (Cell cell : this.cells) byChunk.computeIfAbsent(key(cell.x() >> 4, cell.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(cell);
|
||||
byChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
chunks = Map.copyOf(byChunk);
|
||||
Map<Long, List<Position>> bySupportChunk = new HashMap<>();
|
||||
for (Position support : this.supports) bySupportChunk.computeIfAbsent(key(support.x() >> 4, support.z() >> 4),
|
||||
ignored -> new ArrayList<>()).add(support);
|
||||
bySupportChunk.replaceAll((ignored, values) -> List.copyOf(values));
|
||||
supportChunks = Map.copyOf(bySupportChunk);
|
||||
}
|
||||
|
||||
public List<Cell> cells() { return cells; }
|
||||
public List<Cell> lavaCells() { return lavaCells; }
|
||||
public List<Position> supports() { return supports; }
|
||||
public List<CavernLavaFlows.Fall> falls() { return falls; }
|
||||
public List<CavernLavaFlows.Fall> fallsInChunk(int x, int z) { return fallChunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Position> flowSources() { return falls.stream().map(CavernLavaFlows.Fall::source).toList(); }
|
||||
public List<CavernHydrology.FlowBounds> flowBounds() { return falls.stream().map(CavernLavaFlows.Fall::flowBounds).toList(); }
|
||||
/** Player feet on a natural ledge, with three blocks of headroom below a roof or sky. */
|
||||
public Optional<Position> access() { return access; }
|
||||
public List<Cell> cellsInChunk(int x, int z) { return chunks.getOrDefault(key(x, z), List.of()); }
|
||||
public List<Position> supportsInChunk(int x, int z) { return supportChunks.getOrDefault(key(x, z), List.of()); }
|
||||
}
|
||||
|
||||
public static Plan create(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan hydrology) {
|
||||
Plan pocket = new Search(seed, sampler, hydrology).find();
|
||||
List<CavernLavaFlows.Fall> falls = CavernLavaFlows.create(seed, sampler, hydrology, pocket);
|
||||
List<Cell> cells = new ArrayList<>(pocket.cells());
|
||||
Set<Position> supports = new HashSet<>(pocket.supports());
|
||||
for (var fall : falls) {
|
||||
Position source = fall.source();
|
||||
cells.add(new Cell(source.x(), source.y(), source.z(), true));
|
||||
supports.addAll(fall.supports());
|
||||
}
|
||||
return new Plan(cells, supports, pocket.access().orElse(null), falls);
|
||||
}
|
||||
|
||||
private record Candidate(int x, int z, int dx, int dz, double score) {}
|
||||
private record LowerCandidate(int x, int y, int z, int dx, int dz, double score) {}
|
||||
|
||||
private static final class Search {
|
||||
private final long seed;
|
||||
private final CavernHydrology.Sampler sampler;
|
||||
private final CavernHydrology.Plan hydrology;
|
||||
private final Map<Long, Integer> heights = new HashMap<>();
|
||||
|
||||
Search(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan hydrology) {
|
||||
this.seed = seed;
|
||||
this.sampler = sampler;
|
||||
this.hydrology = hydrology;
|
||||
}
|
||||
|
||||
Plan find() {
|
||||
Plan lower = findLowerPocket();
|
||||
if (lower != null) return lower;
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
for (int x = -184; x <= 184; x += 8) for (int z = -184; z <= 184; z += 8) {
|
||||
if (!inside(x, z, RADIUS - 8)) continue;
|
||||
int ledge = top(x, z);
|
||||
if (ledge < 64 || ledge > 300) continue;
|
||||
for (int[] direction : DIRECTIONS) {
|
||||
int cx = x - direction[0] * ENTRY_DISTANCE, cz = z - direction[1] * ENTRY_DISTANCE;
|
||||
if (!inside(cx, cz, RADIUS - 12) || !clearOfWater(cx, cz)) continue;
|
||||
int rise = top(cx, cz) - ledge;
|
||||
if (rise < 12) continue;
|
||||
candidates.add(new Candidate(x, z, direction[0], direction[1],
|
||||
random(seed, cx, cz) + Math.min(rise, 40) * 0.015));
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)
|
||||
.thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz));
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (attempts++ >= 192) break;
|
||||
// Refine the coarse edge, including a ledge lying between two eight-block samples.
|
||||
for (int along = 0; along < 8; along++) for (int tangent : new int[]{0, -3, 3}) {
|
||||
int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent;
|
||||
Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz());
|
||||
if (plan != null) return plan;
|
||||
}
|
||||
}
|
||||
return new Plan(List.of(), Set.of(), null);
|
||||
}
|
||||
|
||||
/** Prefer a real lower ledge below an overhang. The chamber uses exactly the same rock
|
||||
* shell as the outdoor fallback; only the entrance may now have a natural ceiling. */
|
||||
private Plan findLowerPocket() {
|
||||
List<LowerCandidate> candidates = new ArrayList<>();
|
||||
for (int x = -176; x <= 176; x += 16) for (int z = -176; z <= 176; z += 16) {
|
||||
if (!inside(x, z, RADIUS - 8)) continue;
|
||||
for (int coarseY = 64; coarseY <= 160; coarseY += 8) {
|
||||
if (!solid(x, coarseY, z) || solid(x, coarseY + 8, z)) continue;
|
||||
int level = coarseY;
|
||||
for (int y = coarseY + 7; y > coarseY; y--) {
|
||||
if (solid(x, y, z)) { level = y; break; }
|
||||
}
|
||||
if (level > 160 || top(x, z) < level + 12) continue;
|
||||
for (int[] direction : DIRECTIONS) {
|
||||
int inwardX = x - direction[0] * 16, inwardZ = z - direction[1] * 16;
|
||||
if (!inside(inwardX, inwardZ, RADIUS - 12)
|
||||
|| !solid(inwardX, level + 2, inwardZ) || !solid(inwardX, level + 10, inwardZ)) continue;
|
||||
candidates.add(new LowerCandidate(x, level, z, direction[0], direction[1],
|
||||
random(seed + level + 0x108E2L, x - direction[0], z - direction[1])
|
||||
+ (160 - level) * .002));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(LowerCandidate::score).reversed()
|
||||
.thenComparingInt(LowerCandidate::x).thenComparingInt(LowerCandidate::z)
|
||||
.thenComparingInt(LowerCandidate::y).thenComparingInt(LowerCandidate::dx).thenComparingInt(LowerCandidate::dz));
|
||||
int attempts = 0;
|
||||
for (LowerCandidate candidate : candidates) {
|
||||
if (attempts++ >= 128) break;
|
||||
for (int along = 0; along < 16; along++) for (int tangent : new int[]{0, -3, 3}) {
|
||||
int x = candidate.x() - candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() - candidate.dz() * along + candidate.dx() * tangent;
|
||||
for (int level = Math.min(160, candidate.y() + 5); level >= Math.max(64, candidate.y() - 5); level--) {
|
||||
// Reject solid cave walls and missing floors before expensive shell probes.
|
||||
if (!solid(x, level, z) || solid(x, level + 1, z)) continue;
|
||||
Plan plan = tryNiche(x, z, candidate.dx(), candidate.dz(), level, true);
|
||||
if (plan != null) return plan;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Plan tryNiche(int entryX, int entryZ, int dx, int dz) {
|
||||
return tryNiche(entryX, entryZ, dx, dz, top(entryX, entryZ), false);
|
||||
}
|
||||
|
||||
private Plan tryNiche(int entryX, int entryZ, int dx, int dz, int level, boolean underground) {
|
||||
int cx = entryX - dx * ENTRY_DISTANCE, cz = entryZ - dz * ENTRY_DISTANCE;
|
||||
if (!inside(cx, cz, RADIUS - 12) || level < 64 || level > (underground ? 160 : 300)
|
||||
|| top(cx, cz) < level + 12) return null;
|
||||
// A lower entrance has genuine overhead island rock, but does not need a sky view.
|
||||
if (underground && top(entryX, entryZ) < level + 12) return null;
|
||||
int airLimit = underground ? level + 3 : 383;
|
||||
for (int y = level + 1; y <= airLimit; y++) if (solid(entryX, y, entryZ)) return null;
|
||||
for (int y = level - 2; y <= level; y++) if (!solid(entryX, y, entryZ)) return null;
|
||||
if (underground ? !clearOfWater(cx, level, cz) : !clearOfWater(cx, cz)) return null;
|
||||
|
||||
List<Cell> cells = new ArrayList<>();
|
||||
Set<Position> changed = new HashSet<>();
|
||||
Set<Position> supports = new HashSet<>();
|
||||
for (int x = cx - 1; x <= cx + 1; x++) for (int z = cz - 1; z <= cz + 1; z++) {
|
||||
for (int y = level - 1; y <= level + 2; y++) {
|
||||
if (!solid(x, y, z)) return null;
|
||||
add(cells, changed, x, y, z, y <= level);
|
||||
}
|
||||
}
|
||||
// A dry lip separates the ledge from the pool. Only the air above it is opened.
|
||||
for (int distance = 2; distance < ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level + 1; y <= level + 2; y++) {
|
||||
if (solid(x, y, z)) add(cells, changed, x, y, z, false);
|
||||
}
|
||||
}
|
||||
// Every carved column must lie under eight continuous natural rock blocks. This
|
||||
// excludes soil-cap excavation at the entrance as well as exposed surface lava.
|
||||
Map<Long, Integer> highest = new HashMap<>();
|
||||
for (Cell cell : cells) highest.merge(key(cell.x(), cell.z()), cell.y(), Math::max);
|
||||
for (var column : highest.entrySet()) {
|
||||
int x = (int) (column.getKey() >> 32), z = (int) (long) column.getKey();
|
||||
for (int y = column.getValue() + 1; y <= column.getValue() + 8; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// Three intact blocks under the pool, two-block-thick side walls, and a solid chamber
|
||||
// surround prevent escape through an adjacent cave. No part of this shell is created.
|
||||
for (int x = cx - 3; x <= cx + 3; x++) for (int z = cz - 3; z <= cz + 3; z++) {
|
||||
for (int y = level - 4; y <= level + 2; y++) {
|
||||
if (changed.contains(new Position(x, y, z))) continue;
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
// The natural roof also covers a conservative envelope around local lava fire spread.
|
||||
// Its thickness keeps later surface vegetation well above the lava chamber.
|
||||
for (int x = cx - 4; x <= cx + 4; x++) for (int z = cz - 4; z <= cz + 4; z++) {
|
||||
for (int y = level + 3; y <= level + 10; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
for (int distance = 2; distance <= ENTRY_DISTANCE; distance++) {
|
||||
int x = cx + dx * distance, z = cz + dz * distance;
|
||||
for (int y = level - 2; y <= level; y++) {
|
||||
if (!requireRock(supports, x, y, z)) return null;
|
||||
}
|
||||
}
|
||||
supports.removeAll(changed);
|
||||
return new Plan(cells, supports, new Position(entryX, level + 1, entryZ));
|
||||
}
|
||||
|
||||
private boolean requireRock(Set<Position> supports, int x, int y, int z) {
|
||||
if (!solid(x, y, z)) return false;
|
||||
supports.add(new Position(x, y, z));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean clearOfWater(int x, int z) {
|
||||
// The extra ten blocks include both the entrance and the complete protected shell.
|
||||
int clearance = WATER_CLEARANCE + 10;
|
||||
for (var cell : hydrology.cells()) {
|
||||
long dx = x - cell.x(), dz = z - cell.z();
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
for (var spring : hydrology.springs()) {
|
||||
var bounds = spring.flowBounds();
|
||||
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
|
||||
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
for (var spill : hydrology.spills()) {
|
||||
var bounds = spill.flowBounds();
|
||||
long dx = Math.max(Math.max((long) bounds.minX() - x, 0), (long) x - bounds.maxX());
|
||||
long dz = Math.max(Math.max((long) bounds.minZ() - z, 0), (long) z - bounds.maxZ());
|
||||
if (dx * dx + dz * dz < (long) clearance * clearance) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean clearOfWater(int x, int level, int z) {
|
||||
// This box covers chamber, dry approach, intact shell and roof. A lake far above is
|
||||
// allowed; an actual waterfall crossing this level still excludes the entire pocket.
|
||||
var pocket = new CavernHydrology.FlowBounds(x - 10, level - 4, z - 10, x + 10, level + 10, z + 10);
|
||||
for (var cell : hydrology.cells()) {
|
||||
var ground = new CavernHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1,
|
||||
cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z());
|
||||
if (distanceSquared(pocket, ground) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spring : hydrology.springs()) {
|
||||
if (distanceSquared(pocket, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spill : hydrology.spills()) {
|
||||
if (distanceSquared(pocket, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static long distanceSquared(CavernHydrology.FlowBounds a, CavernHydrology.FlowBounds b) {
|
||||
long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX()));
|
||||
long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY()));
|
||||
long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ()));
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
private int top(int x, int z) {
|
||||
return heights.computeIfAbsent(key(x, z), ignored -> {
|
||||
for (int y = 383; y >= 32; y -= 4) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int exact = Math.min(383, y + 3); exact > y; exact--) if (solid(x, exact, z)) return exact;
|
||||
return y;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
}
|
||||
|
||||
private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; }
|
||||
}
|
||||
|
||||
private static void add(List<Cell> cells, Set<Position> changed, int x, int y, int z, boolean lava) {
|
||||
if (changed.add(new Position(x, y, z))) cells.add(new Cell(x, y, z, lava));
|
||||
}
|
||||
|
||||
private static boolean inside(int x, int z, int radius) { return (long) x * x + (long) z * z < (long) radius * radius; }
|
||||
private static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); }
|
||||
private static double random(long seed, int x, int z) {
|
||||
long value = seed ^ key(x, z) ^ 0x1A7A5EEDL;
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return ((value ^ (value >>> 31)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import fr.koka.sanctuary.worldgen.CavernLavaDeposit.Position;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Selects a few volcanic seeps in deep exposed rock. Sources replace rock; their outlets and
|
||||
* entire falling columns are left empty for vanilla lava ticks. No retaining wall is generated. */
|
||||
public final class CavernLavaFlows {
|
||||
public static final int MIN_SOURCE_Y = 40;
|
||||
public static final int MAX_SOURCE_Y = 160;
|
||||
public static final int MAX_FALLS = 2;
|
||||
public static final int HORIZONTAL_REACH = 3;
|
||||
public static final int FIRE_CLEARANCE = 8;
|
||||
public static final int WATER_CLEARANCE = 24;
|
||||
private static final int RADIUS = 224;
|
||||
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
private static final Comparator<Position> POSITION_ORDER = Comparator.comparingInt(Position::x)
|
||||
.thenComparingInt(Position::z).thenComparingInt(Position::y);
|
||||
|
||||
private CavernLavaFlows() {}
|
||||
|
||||
/** flowBounds predicts undecorated rock drainage; it does not override Minecraft physics.
|
||||
* supports contains only unchanged natural rock around the source, never the source itself. */
|
||||
public record Fall(long id, Position source, Position outlet, List<Position> flowPath,
|
||||
CavernHydrology.FlowBounds flowBounds, List<Position> supports) {
|
||||
public Fall { flowPath = List.copyOf(flowPath); supports = List.copyOf(supports); }
|
||||
}
|
||||
|
||||
public static List<Fall> create(long seed, CavernHydrology.Sampler sampler,
|
||||
CavernHydrology.Plan water, CavernLavaDeposit.Plan pocket) {
|
||||
return new Search(seed, sampler, water, pocket).find();
|
||||
}
|
||||
|
||||
private record Candidate(int x, int y, int z, int dx, int dz, double score) {}
|
||||
private record Node(Position at, int reach) {}
|
||||
private record Trace(List<Position> path, CavernHydrology.FlowBounds bounds) {}
|
||||
|
||||
private static final class Search {
|
||||
private final long seed;
|
||||
private final CavernHydrology.Sampler sampler;
|
||||
private final CavernHydrology.Plan water;
|
||||
private final CavernLavaDeposit.Plan pocket;
|
||||
private final List<Fall> falls = new ArrayList<>();
|
||||
|
||||
Search(long seed, CavernHydrology.Sampler sampler, CavernHydrology.Plan water, CavernLavaDeposit.Plan pocket) {
|
||||
this.seed = seed; this.sampler = sampler; this.water = water; this.pocket = pocket;
|
||||
}
|
||||
|
||||
List<Fall> find() {
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
// Coarse probes below the surface can see a lower overhang that a heightmap hides.
|
||||
for (int x = -216; x <= 216; x += 8) for (int z = -216; z <= 216; z += 8) {
|
||||
if ((long) x * x + (long) z * z >= (long) RADIUS * RADIUS) continue;
|
||||
for (int y = MIN_SOURCE_Y; y <= MAX_SOURCE_Y; y += 8) {
|
||||
if (!solid(x, y, z)) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
if (solid(x + d[0] * 4, y, z + d[1] * 4)) continue;
|
||||
double score = random(seed + y, x + d[0], z + d[1])
|
||||
+ (MAX_SOURCE_Y - y) * .002;
|
||||
candidates.add(new Candidate(x, y, z, d[0], d[1], score));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(Candidate::score).reversed()
|
||||
.thenComparingInt(Candidate::x).thenComparingInt(Candidate::z)
|
||||
.thenComparingInt(Candidate::y).thenComparingInt(Candidate::dx).thenComparingInt(Candidate::dz));
|
||||
int attempts = 0;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (falls.size() >= MAX_FALLS || attempts++ >= 160) break;
|
||||
boolean placed = false;
|
||||
for (int tangent : new int[]{0, -2, 2}) {
|
||||
for (int along = 0; along < 4; along++) {
|
||||
int x = candidate.x() + candidate.dx() * along - candidate.dz() * tangent;
|
||||
int z = candidate.z() + candidate.dz() * along + candidate.dx() * tangent;
|
||||
for (int dy : new int[]{0, 2, -2, 4, -4}) {
|
||||
Position source = new Position(x, candidate.y() + dy, z);
|
||||
if (source.y() < MIN_SOURCE_Y || source.y() > MAX_SOURCE_Y || !separated(source)) continue;
|
||||
List<Position> supports = niche(source, candidate.dx(), candidate.dz());
|
||||
if (supports == null) continue;
|
||||
Position outlet = new Position(x + candidate.dx(), source.y(), z + candidate.dz());
|
||||
Trace flow = trace(source, outlet);
|
||||
if (flow == null || flow.bounds().minY() > source.y() - 12 || !clearOfWater(flow.bounds())) continue;
|
||||
long id = mix(seed ^ (0x1A7AFA11L + falls.size() * 0x9E3779B97F4A7C15L));
|
||||
falls.add(new Fall(id, source, outlet, flow.path(), flow.bounds(), supports));
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
if (placed) break;
|
||||
}
|
||||
}
|
||||
return List.copyOf(falls);
|
||||
}
|
||||
|
||||
private boolean separated(Position source) {
|
||||
CavernHydrology.FlowBounds point = bounds(source);
|
||||
if (!clearOfWater(point)) return false;
|
||||
for (Fall fall : falls) {
|
||||
if (distanceSquared(source, fall.source()) < 80L * 80
|
||||
|| distanceSquared(point, fall.flowBounds()) < 40L * 40) return false;
|
||||
}
|
||||
for (var cell : pocket.cells()) {
|
||||
if (distanceSquared(source, new Position(cell.x(), cell.y(), cell.z())) < 32L * 32) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private List<Position> niche(Position source, int dx, int dz) {
|
||||
if (!solid(source)) return null;
|
||||
Set<Position> supports = new HashSet<>();
|
||||
// The source sits in rock, with a short intact roof, floor and three back/side faces.
|
||||
// The spill column needs no floor: its absence is precisely what makes a cascade.
|
||||
for (int dy = -3; dy <= 8; dy++) {
|
||||
if (dy == 0) continue;
|
||||
Position at = new Position(source.x(), source.y() + dy, source.z());
|
||||
if (!solid(at)) return null;
|
||||
supports.add(at);
|
||||
}
|
||||
for (int[] d : CARDINALS) {
|
||||
if (d[0] == dx && d[1] == dz) continue;
|
||||
for (int depth = 1; depth <= 2; depth++) {
|
||||
Position at = new Position(source.x() + d[0] * depth, source.y(), source.z() + d[1] * depth);
|
||||
if (!solid(at)) return null;
|
||||
supports.add(at);
|
||||
}
|
||||
}
|
||||
for (int dy = 0; dy >= -8; dy--) {
|
||||
if (solid(source.x() + dx, source.y() + dy, source.z() + dz)) return null;
|
||||
}
|
||||
return supports.stream().sorted(POSITION_ORDER).toList();
|
||||
}
|
||||
|
||||
private boolean clearOfWater(CavernHydrology.FlowBounds bounds) {
|
||||
for (var cell : water.cells()) {
|
||||
var column = new CavernHydrology.FlowBounds(cell.x(), cell.bedY() - cell.sedimentDepth() - 1,
|
||||
cell.z(), cell.x(), Math.max(cell.carveTop(), cell.waterY()), cell.z());
|
||||
if (distanceSquared(bounds, column) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spring : water.springs()) {
|
||||
if (distanceSquared(bounds, spring.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
for (var spill : water.spills()) {
|
||||
if (distanceSquared(bounds, spill.flowBounds()) < (long) WATER_CLEARANCE * WATER_CLEARANCE) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Descending lava resets its horizontal reach to three in this non-ultrawarm dimension.
|
||||
* Explore all downhill branches through rock, so selection does not depend on chunk order. */
|
||||
private Trace trace(Position source, Position outlet) {
|
||||
Map<Position, Integer> reach = new HashMap<>();
|
||||
Map<Position, Position> parent = new HashMap<>();
|
||||
ArrayDeque<Node> queue = new ArrayDeque<>();
|
||||
reach.put(source, HORIZONTAL_REACH);
|
||||
queue.addLast(new Node(source, HORIZONTAL_REACH));
|
||||
Position lowest = source;
|
||||
while (!queue.isEmpty()) {
|
||||
Node node = queue.removeFirst();
|
||||
Position at = node.at();
|
||||
if (node.reach() < reach.getOrDefault(at, -1)) continue;
|
||||
if (at.y() < lowest.y()) lowest = at;
|
||||
if (reach.size() > 12000 || Math.abs(at.x() - source.x()) > 64 || Math.abs(at.z() - source.z()) > 64) return null;
|
||||
if (at.y() <= 0) continue;
|
||||
Position below = new Position(at.x(), at.y() - 1, at.z());
|
||||
if (!flowSolid(below, source)) {
|
||||
enqueue(below, HORIZONTAL_REACH, at, reach, parent, queue);
|
||||
continue;
|
||||
}
|
||||
if (node.reach() <= 0) continue;
|
||||
for (int[] d : CARDINALS) {
|
||||
Position next = new Position(at.x() + d[0], at.y(), at.z() + d[1]);
|
||||
if (!flowSolid(next, source)) enqueue(next, node.reach() - 1, at, reach, parent, queue);
|
||||
}
|
||||
}
|
||||
if (!reach.containsKey(outlet)) return null;
|
||||
int minX = source.x(), maxX = minX, minY = source.y(), maxY = minY, minZ = source.z(), maxZ = minZ;
|
||||
for (Position at : reach.keySet()) {
|
||||
minX = Math.min(minX, at.x()); maxX = Math.max(maxX, at.x());
|
||||
minY = Math.min(minY, at.y()); maxY = Math.max(maxY, at.y());
|
||||
minZ = Math.min(minZ, at.z()); maxZ = Math.max(maxZ, at.z());
|
||||
}
|
||||
List<Position> path = new ArrayList<>();
|
||||
for (Position at = lowest; at != null; at = parent.get(at)) path.add(at);
|
||||
Collections.reverse(path);
|
||||
return new Trace(List.copyOf(path), new CavernHydrology.FlowBounds(minX, minY, minZ, maxX, maxY, maxZ));
|
||||
}
|
||||
|
||||
private boolean flowSolid(Position at, Position source) {
|
||||
if (at.equals(source)) return false;
|
||||
var cell = water.cellAt(at.x(), at.y(), at.z());
|
||||
if (cell != null && at.y() > cell.bedY() && at.y() <= cell.carveTop()) return false;
|
||||
return solid(at);
|
||||
}
|
||||
|
||||
private boolean solid(Position at) { return solid(at.x(), at.y(), at.z()); }
|
||||
private boolean solid(int x, int y, int z) { return sampler.sample(x, y, z) > 0; }
|
||||
}
|
||||
|
||||
private static void enqueue(Position next, int available, Position from, Map<Position, Integer> reach,
|
||||
Map<Position, Position> parent, ArrayDeque<Node> queue) {
|
||||
if (available <= reach.getOrDefault(next, -1)) return;
|
||||
if (!reach.containsKey(next)) parent.put(next, from);
|
||||
reach.put(next, available);
|
||||
queue.addLast(new Node(next, available));
|
||||
}
|
||||
|
||||
private static CavernHydrology.FlowBounds bounds(Position at) {
|
||||
return new CavernHydrology.FlowBounds(at.x(), at.y(), at.z(), at.x(), at.y(), at.z());
|
||||
}
|
||||
|
||||
private static long distanceSquared(Position a, Position b) {
|
||||
long dx = a.x() - b.x(), dy = a.y() - b.y(), dz = a.z() - b.z();
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
private static long distanceSquared(CavernHydrology.FlowBounds a, CavernHydrology.FlowBounds b) {
|
||||
long dx = Math.max(0, Math.max((long) a.minX() - b.maxX(), (long) b.minX() - a.maxX()));
|
||||
long dy = Math.max(0, Math.max((long) a.minY() - b.maxY(), (long) b.minY() - a.maxY()));
|
||||
long dz = Math.max(0, Math.max((long) a.minZ() - b.maxZ(), (long) b.minZ() - a.maxZ()));
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
private static double random(long seed, int x, int z) {
|
||||
return (mix(seed ^ ((long) x << 32) ^ (z & 0xffffffffL)) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L;
|
||||
value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL;
|
||||
return value ^ (value >>> 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Alpha.9 shore deposits: broad continuous patches through the existing sediment thickness. */
|
||||
public final class CavernMaterials {
|
||||
private CavernMaterials() {}
|
||||
|
||||
public static BlockState sedimentBlock(CavernHydrology.Cell cell, long seed, int depth) {
|
||||
if (cell.material() == CavernHydrology.ShoreMaterial.GRASS) {
|
||||
return (depth == 0 ? Blocks.GRASS_BLOCK : Blocks.DIRT).defaultBlockState();
|
||||
}
|
||||
if (cell.material() == CavernHydrology.ShoreMaterial.CLAY) return Blocks.CLAY.defaultBlockState();
|
||||
double patch = patch(seed, cell.x(), cell.z(), 32);
|
||||
Block material;
|
||||
if (cell.hasWater() && patch < 0.24) material = Blocks.CLAY;
|
||||
else if (patch < 0.53) material = Blocks.SAND;
|
||||
else if (patch < 0.68) material = Blocks.GRAVEL;
|
||||
else {
|
||||
double rock = patch(seed ^ 0x4c6179657273L, cell.x(), cell.z(), 40);
|
||||
material = rock < 0.28 ? Blocks.GRANITE : rock < 0.45 ? Blocks.DIORITE
|
||||
: rock < 0.65 ? Blocks.ANDESITE : Blocks.STONE;
|
||||
}
|
||||
return material.defaultBlockState();
|
||||
}
|
||||
|
||||
private static double patch(long seed, int x, int z, int scale) {
|
||||
int gx = Math.floorDiv(x, scale), gz = Math.floorDiv(z, scale);
|
||||
double tx = smooth(Math.floorMod(x, scale) / (double) scale);
|
||||
double tz = smooth(Math.floorMod(z, scale) / (double) scale);
|
||||
double near = lerp(value(seed, gx, gz), value(seed, gx + 1, gz), tx);
|
||||
double far = lerp(value(seed, gx, gz + 1), value(seed, gx + 1, gz + 1), tx);
|
||||
return lerp(near, far, tz);
|
||||
}
|
||||
|
||||
private static double value(long seed, int x, int z) {
|
||||
return (WoodlandGroveGeometry.seed(seed, x, 0, z, 0x53484f5245L) >>> 11) * 0x1.0p-53;
|
||||
}
|
||||
|
||||
private static double smooth(double t) { return t * t * (3 - 2 * t); }
|
||||
private static double lerp(double a, double b, double t) { return a + (b - a) * t; }
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||
|
||||
/** A few plants on existing suitable shores, after the forest has been decorated. */
|
||||
public final class CavernShoreSugarCaneFeature implements Feature {
|
||||
public static final MapCodec<CavernShoreSugarCaneFeature> CODEC = MapCodec.unit(CavernShoreSugarCaneFeature::new);
|
||||
|
||||
@Override
|
||||
public MapCodec<CavernShoreSugarCaneFeature> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !CavernHydrologyRuntime.enabled(noise)) return false;
|
||||
var plan = CavernHydrologyRuntime.plan(noise, level.getLevel().getChunkSource().randomState());
|
||||
var cane = Blocks.SUGAR_CANE.defaultBlockState();
|
||||
boolean placed = false;
|
||||
for (var cell : plan.cellsInChunk(origin.getX() >> 4, origin.getZ() >> 4)) {
|
||||
if (cell.hasWater() || random.nextInt(7) != 0) continue;
|
||||
BlockPos base = new BlockPos(cell.x(), cell.bedY() + 1, cell.z());
|
||||
if (!level.getBlockState(base).isAir() || !cane.canSurvive(level, base)) continue;
|
||||
int height = 1 + random.nextInt(3);
|
||||
for (int y = 0; y < height; y++) {
|
||||
BlockPos pos = base.above(y);
|
||||
if (!level.getBlockState(pos).isAir() || !cane.canSurvive(level, pos)) break;
|
||||
setBlock(level, pos, cane);
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package fr.koka.sanctuary.worldgen;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.VegetationBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
|
||||
/** One-time alpha.9 outlet finishing, using vanilla's persisted and consumed generation queue. */
|
||||
public final class CavernSpringOutlets {
|
||||
public record Pending(List<CavernHydrology.Position> sources, List<CavernHydrology.Position> outlets) {
|
||||
public static final Pending EMPTY = new Pending(List.of(), List.of());
|
||||
public Pending { sources = List.copyOf(sources); outlets = List.copyOf(outlets); }
|
||||
public boolean isEmpty() { return sources.isEmpty() && outlets.isEmpty(); }
|
||||
}
|
||||
|
||||
private CavernSpringOutlets() {}
|
||||
|
||||
/** 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)
|
||||
|| !CavernHydrologyRuntime.enabled(noise)) return Pending.EMPTY;
|
||||
boolean queued = false;
|
||||
for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) {
|
||||
queued = true;
|
||||
break;
|
||||
}
|
||||
if (!queued) return Pending.EMPTY;
|
||||
var plan = CavernHydrologyRuntime.plan(noise, level.getChunkSource().randomState());
|
||||
List<CavernHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
|
||||
for (var spring : plan.springs()) {
|
||||
if (markedHere(chunk, spring.source())) sources.add(spring.source());
|
||||
if (markedHere(chunk, spring.outlet())) outlets.add(spring.outlet());
|
||||
}
|
||||
for (var spill : plan.spills()) {
|
||||
if (markedHere(chunk, spill.source())) sources.add(spill.source());
|
||||
if (markedHere(chunk, spill.outlet())) outlets.add(spill.outlet());
|
||||
}
|
||||
for (var fall : CavernHydrologyRuntime.lavaPlan(noise, level.getChunkSource().randomState()).falls()) {
|
||||
var source = new CavernHydrology.Position(fall.source().x(), fall.source().y(), fall.source().z());
|
||||
var outlet = new CavernHydrology.Position(fall.outlet().x(), fall.outlet().y(), fall.outlet().z());
|
||||
if (markedHere(chunk, source)) sources.add(source);
|
||||
if (markedHere(chunk, outlet)) outlets.add(outlet);
|
||||
}
|
||||
return new Pending(sources, outlets);
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|| !CavernHydrologyRuntime.enabled(noise)) return;
|
||||
var random = level.getChunkSource().randomState();
|
||||
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);
|
||||
var modified = CavernHydrologyRuntime.plan(noise, random).cellAt(outlet.x(), outlet.y(), outlet.z());
|
||||
boolean carved = modified != null && outlet.y() > modified.waterY() && outlet.y() > modified.bedY()
|
||||
&& outlet.y() <= modified.carveTop();
|
||||
if (!carved && random.sampleBlockValueUncached(density, outlet.x(), outlet.y(), outlet.z()) > 0) {
|
||||
throw new IllegalStateException("A spring outlet must already be natural air: " + outlet);
|
||||
}
|
||||
BlockPos pos = blockPos(outlet);
|
||||
BlockState current = chunk.getBlockState(pos);
|
||||
if (removableDecoration(current)) {
|
||||
// Only this declared air cell is reopened. Neighbour notification wakes the
|
||||
// adjacent source even if its own chunk was post-processed first.
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
|
||||
}
|
||||
}
|
||||
for (var source : pending.sources()) {
|
||||
if (!owns(chunk, source)) throw new IllegalArgumentException("A source belongs to another chunk: " + source);
|
||||
BlockPos pos = blockPos(source);
|
||||
BlockState current = chunk.getBlockState(pos);
|
||||
if (current.getFluidState().isSource()) {
|
||||
if (current.is(Blocks.WATER)) level.scheduleTick(pos, Fluids.WATER, Fluids.WATER.getTickDelay(level));
|
||||
else if (current.is(Blocks.LAVA)) level.scheduleTick(pos, Fluids.LAVA, Fluids.LAVA.getTickDelay(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean markedHere(LevelChunk chunk, CavernHydrology.Position position) {
|
||||
if (!owns(chunk, position)) return false;
|
||||
var queue = chunk.getPostProcessing()[chunk.getSectionIndex(position.y())];
|
||||
return queue != null && queue.contains(ProtoChunk.packOffsetCoordinates(blockPos(position)));
|
||||
}
|
||||
|
||||
private static boolean owns(LevelChunk chunk, CavernHydrology.Position position) {
|
||||
return (position.x() >> 4) == chunk.getPos().x() && (position.z() >> 4) == chunk.getPos().z();
|
||||
}
|
||||
|
||||
private static BlockPos blockPos(CavernHydrology.Position position) {
|
||||
return new BlockPos(position.x(), position.y(), position.z());
|
||||
}
|
||||
|
||||
private static boolean removableDecoration(BlockState state) {
|
||||
if (state.hasBlockEntity()) return false;
|
||||
return state.is(Blocks.GLOW_LICHEN) || state.is(Blocks.VINE) || state.is(Blocks.MOSS_CARPET)
|
||||
|| state.getBlock() instanceof VegetationBlock || state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user