Compare commits

..
Author SHA1 Message Date
koka a633d4cac9 docs: record verified alpha22 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 11:54:17 +02:00
koka d6e0c144d0 feat: selective surface placement and independent underground core for alpha22
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 11:52:24 +02:00
koka d462f01e2b docs: record verified alpha21 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 10:08:01 +02:00
koka bb63fab388 worldgen: sober palettes and connected industrial galleries for alpha21
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 10:03:30 +02:00
koka ae4c6200db docs: record verified alpha20 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 01:21:33 +02:00
koka 0edac29dd8 feat: add detailed architecture for Sanctuary alpha20
Build Sanctuary / build (push) Canceled after 0s
2026-09-10 01:18:31 +02:00
koka 2facf6f722 docs: record verified alpha19 release and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 22:49:20 +02:00
koka c23e72b8e6 feat(worldgen): add reversible alpha19 heritage structures and routes
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 22:42:32 +02:00
koka f2da922bad Document structure ideas and abandoned island routes
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 20:03:42 +02:00
koka c049e0f2ce Record alpha.17 publication and preserved Prism instance
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 19:16:07 +02:00
koka 0969ed52da Add alpha.17 experimental forgotten structure networks
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 19:14:09 +02:00
koka 1a64fbe5b9 Record alpha16 release and existing Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 18:14:22 +02:00
koka 98fc13cf0a Add alpha16 inhabited ruins and deep city infrastructure
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 18:12:06 +02:00
koka 71d5cf7906 Record alpha.15 release and existing Prism synchronization
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 16:43:25 +02:00
koka dd9c795695 Add alpha.15 compact Lost City and detailed utility networks
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 16:40:26 +02:00
koka c576d655ae Record alpha.14 release and existing Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 14:02:47 +02:00
koka 1c5c2c2c7e Add alpha.14 Lost City and nearby certified expansions
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 14:00:20 +02:00
koka 3a6a895874 Record alpha.13.1 release and existing Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 12:40:27 +02:00
koka b19e93dd21 Fix Windows expansion journal persistence in alpha.13.1
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 12:37:36 +02:00
koka 3569cf1f5f docs(release): record alpha.13 publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 11:15:51 +02:00
koka 78b63be8e1 feat(worldgen): unify Sanctuary and native size options for alpha.13
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 11:13:24 +02:00
koka 818c6753f9 docs: record alpha.12.1 release and preserved Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 08:57:40 +02:00
koka 332d60be96 fix: search farther for expansion space and accept a minimum distance
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 08:55:29 +02:00
koka 40a86588b5 docs: record alpha.12 packwiz publication and Prism update
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 08:33:43 +02:00
koka cdfbba0089 feat: add alpha.12 branching expansion laboratory
Build Sanctuary / build (push) Canceled after 0s
2026-09-09 07:07:04 +02:00
260 changed files with 47740 additions and 184 deletions
+234
View File
@@ -1,5 +1,239 @@
# Changelog
## 0.1.0-alpha.20 — 2026-09-10
- Passe architecturale sur les six bâtiments urbains, les quatorze anciennes
infrastructures, les espaces publics et les réseaux souterrains. Palettes
propres aux usages : chêne, bouleau, granit, diorite, andésite et tuf ;
fondations maçonnées en briques de pierre conservées.
- Angles coupés, baies en saillie, portiques, retraits, loggias, toitures
nervurées et verrières avec passage lumineux. Sols et plafonds détaillés,
corniches, encadrements, murets et blocs ciselés ; bordures de routes en
briques de tuf et accents ciselés.
- Vitres, barreaux, clôtures, murs bas et angles descaliers raccordés depuis
la géométrie complète de chaque pièce avant son découpage entre chunks.
- Nouveau codec `sanctuary:island_v20`, journal et pièces distincts. Les mondes
19 et antérieurs gardent leurs rendus et futurs chunks. Les plans de terrain,
dhydrologie et dimplantation restent ceux de la génération précédente.
Personnaliser conserve les trois tailles et les structures expérimentales.
`check build assemblePack` passe, avec huit GameTests généraux. Création et
réouverture de la ville et des infrastructures passent ; les rapports 19 restent
identiques aux témoins publiés. Option désactivée et Petit/graine42 passent.
JAR, MRpack et ZIP Prism vérifiés ; release et canal packwiz publiés. Deux
synchronisations isolées puis deux dans la même instance Prism passent, avec
925 fichiers personnels et réglages suivis conservés.
Voir le [catalogue illustré](docs/architecture-alpha20.md) et les
[vérifications et limites](docs/testing-alpha20.md). Les fonctions de la bêta
restent à construire ; aucun nouveau système économique nest activé.
## 0.1.0-alpha.19 — 2026-09-09
- Quatorze nouvelles infrastructures abandonnées sur l’île initiale, avec pièces,
accès, mobilier, secrets et outils vanilla associés. La présence varie selon
la graine et le relief ; les quatorze types ont été rencontrés en tests natifs.
- Voies de fret aux rails incomplets et chemins entre destinations réelles.
Le témoin Moyen, graine 0, contient treize lieux et sept liaisons, dont un
fret de 499 blocs entre dépôt et pompage. Tracés enregistrés par chunk.
- Option FR/EN « Structures expérimentales » dans le bouton natif Personnaliser,
trois tailles conservées. Codec `sanctuary:island_v19` et journal 19 ; les
anciens mondes gardent leurs plans et futurs chunks. Un nouveau monde avec
loption désactivée conserve le socle urbain sans les expériences 17/19.
- Commande opérateur `/sanctuary structures` pour relever les entrées.
La ville et ses sous-sols sont conservés ; les systèmes de la bêta restent
à construire.
`check build assemblePack` passe, dont les huit GameTests généraux. Les trois
formats sont vérifiés nativement ; le témoin moyen se rouvre avec ses 112 pièces,
13 coffres, 68 cibles accessibles et la modification témoin. Les identités 17
restent identiques à la release publiée. JAR, MRpack et ZIP Prism vérifiés ;
release et canal packwiz publiés. Deux synchronisations isolées puis deux dans
la même instance Prism passent, avec 767 fichiers personnels et réglages suivis
conservés. Voir [les preuves et limites](docs/testing-alpha19.md).
## 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.
Loption ne supprime ni najoute 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, release et canal packwiz publiés. Même instance Prism synchronisée
deux fois, 767 fichiers personnels et réglages suivis conservés. Voir
[le protocole et les coordonnées](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 lexté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 dessai 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 nest 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 lidentifiant 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 dun nouveau monde 14, certificat lié à un jeton immuable du
journal, puis réouverture et nouvelle vérification. Une présence de terrain,
dentité, 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 larrêt des expansions sous Windows : le journal ne tente plus
douvrir son dossier comme un fichier pour le synchroniser. Cette opération
reste exigée sur les systèmes de fichiers utilisant le séparateur Unix.
- Écriture du fichier synchronisée, remplacement atomique et relecture stricte
conservés. Les véritables erreurs de stockage arrêtent toujours les expansions.
- Après mise à jour et réouverture du même monde, une réservation valide déjà
enregistrée avant lerreur est reprise automatiquement. Aucun changement de
schéma du journal, de génération, de paramètres ou de chunks existants.
Le smoke du journal passe sur macOS, avec politique Windows simulée, erreurs
après remplacement et récupération au rechargement. Aucun essai Windows natif
na encore été réalisé. Voir le [dépannage](docs/alpha13-worldgen.md#correctif-alpha131--expansions-sous-windows)
et les [contrôles ciblés](docs/testing.md#correctif-alpha131--journal-dexpansion-sous-windows).
## 0.1.0-alpha.13 — 2026-09-09
- Un seul choix public **Sanctuary**, avec le bouton natif **Personnaliser** :
Petit (512 blocs), Moyen (724, par défaut), Grand (1 024). Le diamètre est
enregistré dans le générateur ; confirmer ou annuler agit sur le monde à créer.
- Île initiale issue de la densité et de lhydrologie complète de Population,
avec sa forêt de surface et les poches de biomes souterrains natives du
laboratoire. Les eaux et les ressources restent dépendantes du terrain.
- Reprise des outils dexpansion alpha.12.1 dans ce même monde : parent,
direction, climat, taille, relief, distance facultative et recherche de sites
libres, sans remplacer les chunks existants. Le déblocage collectif reste futur.
- Nouveau codec `sanctuary:island_v13`, racine de génération 13 et journal
`data/sanctuary-world-v13/expansions.json`. Les extensions restent de génération
12. Anciens codecs et presets conservés mais cachés de la création ; aucun
monde existant converti, agrandi ou régénéré.
- La suite GameTest unifiée devient le défaut de `check` et `runGameTest` ; les
essais historiques demandent `-PsanctuaryUnifiedTests=false`.
`check build assemblePack`, interface client, trois tailles et rechargement des
expansions vérifiés. Moyen : 63,66 s pour créer puis charger 289 chunks FULL
autour du spawn sur la graine 0, 7,82 s pour les 80 suivants ; mesure serveur,
sans garantie de FPS ni charge multijoueur. Release, MRpack et canal packwiz
publiés ; même instance Prism synchronisée deux fois, avec 601 fichiers
personnels et réglages suivis conservés.
Voir le [guide alpha.13](docs/alpha13-worldgen.md) et les [preuves](docs/testing.md).
## 0.1.0-alpha.12.1 — 2026-09-09 (historique)
- Corrige le blocage de toutes les directions lorsque du vide a déjà été
généré : la recherche continue plus loin sur le même rayon, sans remplacer
les chunks existants.
- Paramètre final `distance` facultatif pour `quick`, `create`, `preview` :
distance minimale entre centres en blocs, de 0 à 100 000 ; 0 ou omis = auto.
- Recherche limitée à 32 positions et un budget global de lecture de 30 secondes.
Les erreurs disque et timeouts interrompent la demande ; seul un emplacement
occupé permet de poursuivre. La graine et le profil sont conservés.
- Compatibilité des commandes précédentes et des mondes laboratoire alpha.12 :
journal v1 et génération 12 inchangés, sans migration ni régénération.
Les contrôles moteur de création et de rechargement passent sur la graine 42,
avec huit premiers sites occupés et distance explicite. `check build assemblePack`
passe, avec les douze tests historiques. Release, MRpack et canal publiés ;
la même instance Prism est synchronisée deux fois et ses 466 fichiers personnels
et réglages suivis sont conservés. Voir [WG-14](docs/backlog.md).
## 0.1.0-alpha.12 — 2026-09-09 (historique)
- Nouveau preset Sanctuary — Laboratoire, distinct de Petit/Grand. Outils
opérateur `quick`, `create`, `preview`, `list`, `visit`, `resume` pour tester
les expansions sans blocs de commande ni économie préalable.
- Îles de 64, 128, 256 ou 512 blocs de diamètre nominal ; parent et direction
enregistrés, climat libre parmi douze profils. La boussole climatique reste
un raccourci facultatif ; les branches peuvent composer plusieurs couronnes.
- Nouveaux reliefs hauts : naturel, pics, canyon, volcan. Le relief naturel est
aussi testé sur l’île initiale du laboratoire. Palette volcanique et petites
poches facultatives de fluides, sans ajout de fondations artificielles.
- 34 biomes vanilla déclarés dès le chargement, avec surface et poches souterraines.
Sélection de structures vanilla selon biomes, emprise et fondations : villages,
temples de désert/jungle, cabanes de marais et camps abandonnés.
- Journal serveur v1 atomique, paramètres immuables, reprise des réservations,
refus des chunks existants ou en cours de chargement/déchargement, vide compris.
Aucun changement des anciens générateurs ni migration dune sauvegarde.
Validation locale : `check build assemblePack`, douze tests moteur Grand de
graine 0, smokes de journal/relief, expansions et second processus de rechargement
sur la graine 42 avec camp et butin natifs. Les essais restent sans client ni
joueurs connectés. Release, MRpack et canal packwiz publiés ; la même instance
Prism est synchronisée deux fois avec 466 fichiers personnels et réglages
suivis conservés. Recherche collective,
boîtes postales, Void, hydrologie étendue et autres structures restent à construire.
Voir [le contrat du laboratoire](docs/alpha12-laboratory.md).
## 0.1.0-alpha.11 — 2026-09-09
- Deux choix à la création : **Petit** (512 blocs de diamètre nominal)
+323 -9
View File
@@ -13,9 +13,262 @@ 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. **Lalpha.22 est
publiée** : le placement des structures redevient sélectif. Les palettes,
intérieurs et filons de lalpha.21 sont conservés. Voir [les essais22](docs/testing-alpha22.md)
et [la distribution](docs/packwiz.md).
## Alpha.22 — trouver le bon terrain
La nouvelle génération préfère refuser une ville ou un bâtiment facultatif
qui ne trouve pas un emplacement adapté. Plus de ville forcée sur une réserve
artificielle ; moins de terrassement, des fondations proches de la roche et des
chemins qui suivent le relief. La grande traversée et la salle dexpansion sont
réservées en priorité et peuvent exister sans ville au-dessus. Les autres lieux
nont pas de quota garanti.
Créer un **nouveau monde Sanctuary** pour utiliser ces règles. Les tailles
512/724/1024 et le bouton natif **Personnaliser** restent inchangés ; les anciens
mondes conservent leurs générateurs. La vision retient désormais **Minecraft
comme archéologie dun autre Minecraft** : chaque ruine témoigne dune ancienne
solution, et de ceux qui lont construite puis ont disparu.
Les trois tailles natives, la réouverture, le cas expérimental OFF et la
compatibilité21 passent, ainsi que `check build assemblePack` et ses huit
GameTests généraux. Le [MRpack alpha.22](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.22/Sanctuary-0.1.0-alpha.22.mrpack)
est disponible, le canal packwiz et la même instance Prism sont synchronisés.
Les925 fichiers personnels et réglages suivis sont conservés. Voir
[la distribution](docs/packwiz.md).
## Alpha.21 — matériaux, quartiers et traversées
La ville revient à la pierre lisse, pierre, briques de pierre, andésite et chêne.
Le tuf accompagne surtout les chemins et les sous-sols. Les toits et ornements
sont raccordés à leurs appuis ; les pièces gardent leurs aménagements. La ville
recherche des petits quartiers séparés par du terrain naturel, avec des chemins
vers le cœur. Les lieux patrimoniaux recherchent davantage de raccords.
Loption expérimentale ajoute une recherche de galeries industrielles : chaussée
cinq blocs, deux trottoirs, salles et escaliers de surface. Les placements sur
pente sont assouplis et les destinations isolées sont prioritaires pour les chemins.
Créer un **nouveau monde Sanctuary** pour ce générateur21. Les anciens mondes
conservent leur génération. Les vérifications et coordonnées du
témoin sont consignées dans [les essais](docs/testing-alpha21.md).
Le [MRpack alpha.21](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.21/Sanctuary-0.1.0-alpha.21.mrpack)
est disponible. Le canal packwiz et la même instance Prism sont synchronisés ;
les 925 fichiers personnels et réglages suivis sont conservés. Les tests de
création/réouverture, le cas OFF et `check build assemblePack` passent.
## Alpha.20 — identité des bâtiments
La passe reprend les six bâtiments de ville, les quatorze infrastructures,
les parcs et les sous-sols. Bois de chêne et de bouleau, granit, diorite et tuf
identifient les usages ; biseaux, baies, corniches, portiques et verrières
donnent du relief aux volumes. Les routes reçoivent des bordures de briques
de tuf et des blocs ciselés. Les vitres et grilles se raccordent entre chunks.
Créer un **nouveau monde Sanctuary, Moyen, graine 0**, avec **Structures
expérimentales** activées pour retrouver le témoin. Les anciens mondes gardent
leur génération. Le bouton natif **Personnaliser** et ses trois tailles restent
identiques. Voir le [catalogue illustré](docs/architecture-alpha20.md) et les
[essais alpha.20](docs/testing-alpha20.md). `check build assemblePack`, les essais natifs et les artefacts sont vérifiés ;
la [release](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.20)
et le canal packwiz sont publiés. La même instance Prism est synchronisée
deux fois, avec 925 fichiers personnels et réglages suivis conservés.
## Alpha.19 — anciennes infrastructures
Le chantier ajoute une première passe des quatorze lieux du
[catalogue](docs/structures-audit.md), avec leurs accès, des petits butins vanilla
et des voies de fret ou chemins abandonnés entre destinations réelles.
Loption **Structures expérimentales** reste enregistrée à la création du monde.
La nouvelle génération garde les anciennes sauvegardes intactes. Aucun système
de progression, mailbox, caméra ou réacteur nest activé.
Les trois tailles sont vérifiées en génération native ; création et réouverture
conservent les structures et les coffres. `check build assemblePack` passe.
Pour voir le grand fret : **Moyen, graine 0**, option activée ;
`/sanctuary structures` liste les entrées. Voir [les preuves et la carte](docs/testing-alpha19.md).
La [release alpha.19](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.19)
et le canal packwiz sont publiés. La même instance Prism est synchronisée deux
fois, avec 767 fichiers personnels et réglages suivis conservés.
## 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**, loption **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 douvrir un monde 17 : les
mondes dessai 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.
La [release alpha.17](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.17)
et le canal packwiz sont publiés ; la même instance Prism est synchronisée deux
fois, avec 767 fichiers personnels et réglages suivis conservés. Pour voir les
trois réseaux, créer un monde **Moyen, graine 0**, option activée ;
`/locate structure sanctuary:forgotten_networks` indique une entrée de surface.
## 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 souvrent vers lexté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’à lalpha.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 deau.
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 lemprise urbaine.
Une grande salle souterraine prépare la future machine dexpansion.
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 dessai anticipé15. La machine dexpansion 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 dexpansion `lost_city` permet den rechercher dans les
nouveaux continents. Le relief et ses fondations décident des emplacements :
un quartier ou un type de bâtiment nest 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 dappartements, 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 descaliers et
les pièces damé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
nest 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é nest
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
na été ouvert ou régénéré. La validation native Windows reste à effectuer.
## Alpha.13.1 — correctif des expansions sous Windows
Le journal dexpansion nessaie plus douvrir son dossier comme un fichier sous
Windows, ce qui provoquait `AccessDeniedException` et bloquait les expansions.
La synchronisation du fichier, son remplacement atomique et sa relecture restent
obligatoires. Le format du journal et la génération alpha.13 restent identiques.
Après installation du correctif, **rouvrir le même monde** : une réservation déjà
enregistrée est reprise après vérification du journal. Il ne faut pas supprimer
`sanctuary-world-v13` ni recréer le monde pour ce correctif. Voir le
[dépannage Windows](docs/alpha13-worldgen.md) et la [validation](docs/testing.md).
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 linstance Mac existante passent ; les 713 fichiers personnels
et réglages suivis restent identiques. Aucun accès à linstance Windows na eu lieu.
## Alpha.13 — un seul monde Sanctuary
À la création dun **nouveau monde**, sélectionner **Sanctuary**, puis utiliser
le bouton natif **Personnaliser** pour choisir la taille de l’île :
| Taille | Diamètre nominal |
| --- | --- |
| Petit | 512 blocs |
| Moyen, par défaut | 724 blocs |
| Grand | 1 024 blocs |
**Terminé** conserve le choix ; **Annuler** revient sans lappliquer. La hauteur
reste de 384 blocs. Ces tailles décrivent une emprise, sans promettre un nombre
de joueurs, une aire constructible ou un stock de minerais.
L’île reprend les forêts, le relief flottant et les plans deau complets du
générateur précédent : bassins, rivière, terrasses et cascades restent liés
aux possibilités naturelles du terrain. Les poches de cavités natives de Minecraft
du laboratoire rejoignent cette île. Les commandes opérateur
dexpansion, avec leur distance facultative et leur recherche demplacements
libres, sont accessibles dans ce même nouveau monde. Les déblocages par la
production et lXP restent à construire.
Les anciens choix Petit/Grand et Laboratoire sont cachés à la création, tout
en restant chargeables avec leurs paramètres enregistrés. **Aucun monde
existant nest converti ni régénéré.** Le [guide alpha.13](docs/alpha13-worldgen.md)
détaille la création, les expansions et la séparation des sauvegardes.
`check build assemblePack`, les tests client, les trois tailles et les scénarios
dexpansion/rechargement passent. Moyen atteint 63,66 secondes pour la création
puis 289 chunks FULL dans le test serveur de référence. Les preuves et leurs
limites sont dans [Validation](docs/testing.md).
À 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’à lalpha.8
@@ -62,7 +315,7 @@ sur les trois graines de référence, avec 1 800 ticks de fluides. Ces résultat
ne valident pas par avance lincrément suivant. Les observations et leurs
limites figurent dans [Validation](docs/testing.md).
## Alpha.9 : cavités vivantes et terrasses deau
## Alpha.9, historique : cavités vivantes et terrasses deau
Lincrément ajoute des cavités luxuriantes et lumineuses sous l’île, des
secteurs à spéléothèmes, des géodes daméthyste et des poches de soufre moins
@@ -128,7 +381,7 @@ en jeu reste à construire. Voir [Expansion et ressources](docs/expansion.md).
Les détails du terrain et de ses limites sont dans [Génération](docs/worldgen.md),
et la provenance dans [Audit 26.2](docs/migration-26.2.md).
## Alpha.11 : Petit, Grand et génération plus rapide
## Alpha.11, historique : Petit, Grand et génération plus rapide
Les anciens formats sont trop grands et éloignent les constructions. Le
nouveau choix de départ se limite à **Petit** et **Grand**, avec **Grand par
@@ -159,6 +412,64 @@ de la même instance sont consignées dans [Distribution](docs/packwiz.md).
Lalpha.11 est publiée ; la même instance Prism a été synchronisée deux fois,
avec les 364 fichiers personnels et réglages suivis conservés.
## Alpha.12, historique — laboratoire dexpansion
Un troisième preset **Sanctuary — Laboratoire** permet de tester les expansions
dans un **nouveau monde**, sur la même version Minecraft. Les choix Petit et
Grand gardent leur génération alpha.11. Le laboratoire possède sa propre île
initiale de 512 blocs, avec un relief supérieur légèrement déformé et des biomes
vanilla. Les nouvelles îles choisissent séparément leur parent, direction,
climat, diamètre nominal (64/128/256/512) et relief (naturel, pics, canyon, volcan).
Avec les commandes autorisées ou le statut opérateur :
```text
/sanctuary expansion quick north autumn 128 peaks
/sanctuary expansion list
/sanctuary expansion visit island_1
/sanctuary expansion create oasis island_1 east arid 64 natural
```
Le premier exemple ouvre une forêt automnale au nord ; le second continent
part vers lest depuis cette île. `quick north` seul emploie la convention froide,
avec 128 blocs et un relief naturel. `create` permet de choisir explicitement un
identifiant et un parent. `preview` propose lemplacement sans lactiver ;
`resume <id>` reprend une réservation interrompue. Lautocomplétion liste les
directions, douze profils climatiques et quatre reliefs.
Les anciens chunks, même vides, sont refusés dans lemprise dune nouvelle île.
Le registre persiste côté serveur ; la préparation est progressive et la visite
attend un terrain prêt. Villages, temples, cabanes de marais et camps abandonnés
vanilla peuvent être retenus selon leur biome et leurs fondations. Les autres
structures, la grande hydrologie des continents, les recherches, les boîtes de
dépôt et le refuge Void restent des étapes suivantes. Un biome humide ne promet
pas encore un lagon ou un océan. Les règles, limites et essais figurent dans le
[guide du laboratoire](docs/alpha12-laboratory.md).
`check build assemblePack` et les essais dexpansion/rechargement de graine 42
passent. Lalpha.12, son MRpack et le canal packwiz sont publiés ; la même
instance Prism a été synchronisée deux fois, avec les 466 fichiers personnels
et réglages suivis conservés. Voir [Distribution](docs/packwiz.md).
## Alpha.12.1, historique — distance et emplacements libres
Le correctif ajoute une distance minimale facultative à la fin des commandes :
```text
/sanctuary expansion quick north autumn 128 peaks 2048
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
```
La distance se mesure entre les centres depuis le parent choisi. Sans distance,
le placement reste automatique. Les emplacements déjà générés sont maintenant
écartés en cherchant plus loin dans la même direction, sans changer le climat
ni réécrire les chunks. Les mondes laboratoire alpha.12 peuvent être conservés :
le journal et la génération restent compatibles. Voir le [guide](docs/alpha12-laboratory.md).
`check build assemblePack`, les scénarios moteur et les tests de distance passent.
Lalpha.12.1 et son MRpack sont publiés ; la même instance Prism est synchronisée
deux fois, avec 466 fichiers personnels et réglages suivis conservés.
## Versions et prérequis
Au 8 septembre 2026, la cible disponible est **26.3-pre-2**. Le dépôt ne prétend
@@ -173,7 +484,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.11 |
| Sanctuary / pack | 0.1.0-alpha.21 |
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
@@ -194,13 +505,15 @@ cd sanctuary-beta
```
`check` inclut les tests de forme et le serveur de test automatisé de Fabric,
qui charge le vrai preset Sanctuary dans un monde de développement neuf.
qui charge par défaut le nouveau générateur unifié Sanctuary dans un monde
de développement neuf. Les suites historiques utilisent explicitement
`-PsanctuaryUnifiedTests=false`.
Les scénarios couverts et les essais multijoueurs restant à effectuer sont
décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.10.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.21.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).
@@ -218,7 +531,8 @@ Lancer le client de développement :
./gradlew :sanctuary:runClient
```
Créer un **nouveau monde**, puis sélectionner le type de monde **Sanctuary**.
Créer un **nouveau monde**, sélectionner le type **Sanctuary**, puis
**Personnaliser** pour changer la taille Moyen retenue par défaut.
L'installation du mod seule ne transforme pas un monde Minecraft ordinaire.
Pour un serveur de développement, lancer `./gradlew :sanctuary:runServer`, puis
+190
View File
@@ -0,0 +1,190 @@
# Alpha.12 — laboratoire dexpansion
Ticket local **WG-13**, branche `codex/expansion-lab`. Contrat du laboratoire et guide
dessai. Cette livraison reste une alpha ; le canal distribué est suivi séparément
dans [Distribution](packwiz.md).
## Décisions de conception
La direction géographique et le climat deviennent deux choix distincts. La
boussole nord froid / sud chaud / ouest sec / est humide reste un raccourci
facultatif. Une île peut prolonger le climat de son parent ou ouvrir une branche
vers un autre climat. Les parents et les coordonnées forment un graphe persistant,
permettant plusieurs couronnes autour de Sanctuary. Les recherches, leurs coûts,
les boîtes aux lettres et les boîtes de dépôt sont planifiés pour plus tard.
Cette alpha fournit des commandes opérateur : créer, suivre, lister et visiter
les expansions. Les tailles nominales des nouvelles îles sont 64, 128, 256 et
512 blocs. Les reliefs de laboratoire sappliquent aussi à l’île initiale, dans
un nouveau preset expérimental. Les structures vanilla compatibles sont admises
après contrôle de leur emprise et de leur support. Une absence reste possible.
Lalpha sert à éprouver les outils et les paysages. Le passage en bêta se fera
après définition du périmètre de la vision et de la progression jouable ; ce
numéro ne désigne pas une progression collective déjà livrée.
## Contrat de sauvegarde et douverture v1
- Activation exclusivement dans un **nouveau monde du laboratoire alpha.12**.
Les anciens presets, leurs paramètres, leurs chunks et leurs sauvegardes ne
sont pas convertis. Il nexiste pas de commande de migration ou de régénération.
- Un journal serveur propre au monde enregistre graine, version, origine,
identifiant, parent, direction, climat, relief, diamètre, centre et statut.
Les paramètres dune île sont immuables après réservation.
- Toute la réservation inclut terrain, décorations et halo moteur. Les régions
déjà présentes sur disque, chargées ou en cours de déchargement sont refusées,
même si leurs blocs sont tous vides. Une erreur de lecture interdit louverture.
- Le journal est écrit atomiquement et relu avant de publier le nouveau plan au
générateur. Un journal existant absent ou corrompu nest jamais remis à zéro.
- La génération travaille uniquement dans la région nouvelle. Les chunks voisins
nécessaires au moteur restent dans son halo. Aucun bloc dune ancienne région
nest remplacé. Les références et le placement des structures utilisent le moteur.
- Une réservation interrompue conserve son identité et sa graine. La préparation
peut reprendre, sans double île ni nouveau tirage, jusquau statut prêt.
- Les commandes exigent la permission opérateur. Être en créatif seul ne confère
aucun droit sur le serveur. Une opération ne téléporte personne implicitement.
- Le refuge Void et le déplacement collectif des joueurs restent un système
distinct à concevoir. Ils ne servent pas de mécanisme de protection des chunks.
Le halo impose une distance supplémentaire dans cette première version. Le placer
au plus proche ne permet pas de réutiliser silencieusement du vide déjà généré.
Les points dorigine enregistrés ne suivent pas les changements de lit ou de spawn.
## Commandes et palette
Créer un **nouveau monde**, type **Sanctuary — Laboratoire**, avec les commandes
activées. Pour un serveur neuf, utiliser `level-type=sanctuary:expansion_lab`
et un nouveau `level-name`. La graine `42` fournit un cas reproductible observé
avec un camp abandonné ; aucune structure nest garantie sur chaque graine.
```text
/sanctuary expansion quick north autumn 128 peaks
/sanctuary expansion list
/sanctuary expansion visit island_1
/sanctuary expansion create oasis island_1 east arid 64 natural
/sanctuary expansion preview glacier sanctuary south glacial 256 canyon
```
`quick` part toujours de Sanctuary et attribue un identifiant `island_N`.
`create <id> <parent> <direction> <climate> <diameter> <relief> [distance]` ouvre une branche
nommée depuis nimporte quelle île prête. `preview` emploie les mêmes paramètres ;
il propose des coordonnées mais ne valide ni loccupation des chunks ni le sol.
Répéter un `create` identique rend la même réservation ; un autre profil sous
le même identifiant est refusé. `resume <id>` reprend une préparation interrompue.
Après une erreur du journal, il faut rétablir le stockage puis recharger le monde.
Depuis l**alpha.12.1**, `quick`, `create` et `preview` acceptent une distance
minimale facultative à la fin, de 0 à 100 000 blocs. Elle mesure la distance
entre les centres du parent et de lenfant, y compris en diagonale. Lalignement
des chunks et les marges du terrain peuvent éloigner légèrement le résultat.
Une valeur de 0, ou son omission, laisse le placement automatique.
```text
/sanctuary expansion quick north autumn 128 peaks 2048
/sanctuary expansion create desert sanctuary east arid 128 natural 4096
/sanctuary expansion preview glacier sanctuary northeast glacial 256 canyon 2048
```
Si une zone est déjà générée, `create` et `quick` essaient plus loin dans la
même direction, sur 32 positions maximum avec un budget total de lecture de
30 secondes. Le climat, le relief et la graine restent ceux de la demande.
`preview` propose seulement le premier emplacement géométrique ; le résultat
réel peut être plus loin. Les erreurs de lecture interrompent la recherche.
Les anciens appels restent valides. Un identifiant déjà créé garde sa position ;
demander sous le même nom une distance supérieure à sa distance réelle est refusé.
Cette correction utilise le même journal v1 et les mêmes descripteurs de
génération 12 : le monde laboratoire alpha.12 actuel peut être conservé. Aucun
ancien chunk nest régénéré ; la recherche réserve uniquement une nouvelle zone.
| Profil de commande | Surface recherchée |
| --- | --- |
| `temperate` | Chênes, bouleaux, clairières et fleurs |
| `boreal` | Taïga, bosquets et neige en hauteur |
| `glacial` | Plaines enneigées, pics gelés et pointes de glace |
| `cold_humid` | Vieilles taïgas et bosquets froids |
| `humid` | Forêt sombre, marécages et Pale Garden ponctuel |
| `tropical` | Jungle, bambous et mangrove basse |
| `warm` | Savane et plateaux |
| `arid` | Désert, badlands et cheminées érodées |
| `dry` | Prairies exposées et reliefs pierreux |
| `cold_dry` | Neige, gravier et sommets gelés |
| `autumn` | Dappled Forest, prairies et bouleaux ponctuels |
| `cherry` | Cerisiers, prairies et bosquets en hauteur |
La sélection dépend de la graine, de laltitude et de variations locales ; une
petite île naffiche pas nécessairement tous les biomes de son profil. Des poches
luxuriantes, à spéléothèmes ou soufrées diversifient les étages inférieurs.
Les reliefs `natural`, `peaks`, `canyon` et `volcano` sont indépendants du climat.
Le volcan déforme et creuse la matière existante ; il ne garantit pas un cône
plein ni un lac de lave lorsque la géométrie centrale est ajourée.
Les valeurs par défaut de `quick` suivent la boussole décrite dans
[Expansion](expansion.md), avec diamètre 128 et relief naturel. Les directions
acceptées sont `north`, `northeast`, `east`, `southeast`, `south`, `southwest`,
`west`, `northwest`. Lautocomplétion fournit les paramètres disponibles.
## Portée technique et limites
Le codec `sanctuary:expansion_lab` enregistre le générateur ; le journal conserve
les descripteurs de génération **12**. Chaque île emploie sa propre graine dérivée
de la graine du monde et de son identifiant. Changer didentifiant change donc
le tirage. Les instances de bruit sont propres aux îles, y compris après reload.
Les 34 biomes vanilla sont déclarés avant la première expansion.
La densité flottante existante sert de base. Le laboratoire ajoute les
transformations des reliefs hauts et utilise les matériaux et décorations natifs.
Ses carvers vanilla sont désactivés pour conserver les supports prospectés.
Les grandes rivières et terrasses hydrologiques alpha.11 ne sont pas transférées
dans ce prototype : les anciens presets les conservent. Le laboratoire essaie
seulement de petites sources facultatives sur support rocheux fermé.
Les structures autorisées sont les villages (diamètre au moins 256), pyramides
du désert et de jungle, cabanes de marais et camps abandonnés. Les starts natifs
sont acceptés après vérification de leur emprise et de leurs fondations ; le
moteur place les pièces, références et butins. Leur grille de sélection diffère
de celle de `/locate` vanilla : cette commande nest pas un outil fiable pour
les retrouver dans le laboratoire. La preuve actuelle porte sur un camp ; elle
ne valide pas visuellement chaque type de village ou de temple.
La réservation ajoute 32 blocs de décoration et 176 blocs de halo moteur autour
de lenveloppe de terrain. Les petites îles sont donc encore assez éloignées :
le test de diamètre 64 place une première île à 800 blocs du centre de Sanctuary,
puis une branche à 544 blocs de son parent. Un emplacement déjà exploré est
écarté ; depuis lalpha.12.1, la recherche continue plus loin sans réécrire ce vide.
Il existe une limite de 64 régions, origine comprise, et de ±100 000 blocs pour
leurs centres. La vérification initiale doccupation est synchrone et peut
suspendre brièvement le serveur pendant les lectures disque ; la préparation
suivante avance chunk par chunk.
Les bateaux volants, montgolfières, panthéons, îles uniques en béton coloré,
océans coralliens, recherches, coûts et boîtes de dépôt restent dans la vision.
Aucun command block, transfert collectif au Void ou reset global nest nécessaire
pour utiliser ces commandes.
## Observations reproductibles
Sur la graine **42**, le scénario moteur a ouvert une île tropicale de 64 blocs
au nord, puis une île aride de 64 blocs à lest de celle-ci. Chaque préparation
a atteint 100 chunks FULL. Les biomes sauvegardés, les points darrivée sur sol
réel, les permissions du dispatcher, le refus des doublons incohérents, la
conservation dun bloc témoin sur Sanctuary et le refus du vide déjà chargé
ont été vérifiés. Un camp abandonné possède un tonneau placé avec sa table de
butin vanilla. Sur la graine **0**, ces expansions fonctionnent mais aucun des
11 sites de structure candidats na passé les conditions de terrain ; le test
exigeant une preuve de butin a donc échoué sur cette graine.
Un second processus Java a rouvert le monde de développement de graine 42 et
retrouvé les trois régions prêtes, le bloc dor écrit avant larrêt, les colonnes
de blocs, biomes, densités et le tonneau. Le codec sauvegardé a aussi été décodé.
Le framework GameTest reconstruit ses réglages depuis le preset : ce contrôle
ne remplace pas un essai du parcours normal de chargement dans le client.
La reprise dun journal réservé est contrôlée séparément par le smoke ; un
crash forcé en pleine génération, les téléportations de vrais joueurs et la
charge multijoueur ne sont pas simulés.
Les smokes parcourent cinq graines, les quatre tailles et les quatre reliefs :
transformation verticale monotone, dessous de lorigine conservé, canyon retirant
de la matière et couronne volcanique. Ce sont des contrôles géométriques ; les
profils 128/256/512 et les quatre reliefs demandent encore une appréciation
visuelle en jeu. Voir [Validation](testing.md) pour les commandes et preuves.
+212
View File
@@ -0,0 +1,212 @@
# Alpha.13 — un monde Sanctuary unifié
**Publiée et synchronisée dans la même instance Prism.** Cette version réunit l’île de départ complète et les outils
dexpansion du laboratoire.
[Validation](testing.md) décrit les preuves disponibles et
[Distribution](packwiz.md) indique la version effectivement publiée.
La cible reste **Minecraft 26.3-pre-2 / Fabric**. Ce guide concerne un nouveau
monde alpha.13. Les anciens mondes conservent leur génération.
## Créer son monde
1. Créer un **nouveau monde**, puis sélectionner le type **Sanctuary**.
2. Ouvrir le bouton natif **Personnaliser**.
3. Choisir la taille, puis cliquer sur **Terminé** avant de créer le monde.
| Taille | Diamètre nominal de l’île initiale |
| --- | --- |
| Petit | 512 blocs |
| Moyen, choix par défaut | 724 blocs |
| Grand | 1 024 blocs |
**Annuler** revient sans appliquer le changement. La hauteur reste de 384 blocs,
de `Y=0` à `Y=383`. Le diamètre est une référence pour la forme : trous, surplombs,
fragments et contour irrégulier dépendent de la graine. Il ne mesure pas une
surface entièrement constructible et ne fixe pas un nombre de joueurs.
La liste des types de monde contient un seul choix Sanctuary ; les types
Minecraft habituels restent disponibles. Petit, Moyen et Grand sont des
réglages de ce même choix. Lancien Grand alpha.11, de 724 blocs, correspond
au diamètre nominal du nouveau Moyen.
Sur un serveur neuf, le preset suivant crée un monde de taille Moyen :
```properties
level-type=sanctuary:sanctuary
level-name=sanctuary-dev
level-seed=42
```
Choisir un dossier de monde neuf pour un essai. Le bouton **Personnaliser**
configure la création en solo ; il ne modifie pas les fichiers dun serveur
existant. Installer le mod ou changer `level-type` ne transforme pas une partie.
## L’île de départ
Le nouveau monde reprend le générateur complet de l’île initiale : forêts de
chênes et de bouleaux, clairières, affleurements, strates rocheuses et corniches
intérieures. Son relief flottant conserve les trous et les surplombs, sans
ajouter de noyau cylindrique pour garantir un terrain à lorigine.
Les plans hydrologiques de Population sont repris : étangs et lacs, recherche
de rivière, bassins étagés et leurs débordements, sources et cascades, poche et
coulées de lave. Le placement dépend des supports réellement présents dans la
roche. Un plan qui ne trouve pas de site admissible reste absent ; aucune
rivière, terrasse ou ressource nest garantie sur chaque graine. Les eaux en
terrasses permettent la nage verticale sans ajouter de culture de riz.
Les poches souterraines natives du laboratoire rejoignent cette île : cavités
luxuriantes, à spéléothèmes ou soufrées, avec leurs décorations Minecraft.
Il sagit toujours des profondeurs de lOverworld, pas de la dimension des
cavernes prévue dans la vision. L’île garde sa palette tempérée en surface ;
Dappled Forest reste disponible dans les expansions au climat automnal.
Lintégration protège les volumes déclarés par les plans deau et de lave
contre les écritures de décoration suivantes. Cette protection concerne les
plans de génération, sans mettre les blocs à labri des actions des joueurs.
Les preuves moteur et leurs limites sont suivies dans [Validation](testing.md).
## Ajouter une île
Les commandes demandent les commandes autorisées en solo ou la permission
opérateur sur le serveur. Elles reprennent les outils alpha.12.1 dans le nouveau
monde Sanctuary. L’économie, les recherches et les contributions collectives
ne conditionnent pas encore leur utilisation.
```text
/sanctuary expansion quick north autumn 128 peaks
/sanctuary expansion list
/sanctuary expansion visit island_1
/sanctuary expansion create oasis island_1 east arid 64 natural 2048
```
`quick` ouvre une île depuis Sanctuary et lui attribue un identifiant `island_N`.
Lexemple de visite suppose quil sagit de la première réservation ; `list`
indique lidentifiant et l’état réels. La visite attend que l’île soit prête.
`create` choisit un nom et un parent déjà prêt pour prolonger une branche.
La création ne téléporte personne implicitement.
Les nouveaux continents choisissent leur propre direction, climat, diamètre
**64/128/256/512** et relief **natural/peaks/canyon/volcano**. La direction ne
contraint pas le climat. Lautocomplétion fournit les huit directions et les
douze profils : `temperate`, `boreal`, `glacial`, `cold_humid`, `humid`, `tropical`,
`warm`, `arid`, `dry`, `cold_dry`, `autumn`, `cherry`.
Le dernier nombre facultatif est une **distance minimale entre les centres**
du parent et de lenfant, entre 0 et 100 000 blocs. Une valeur de 0 ou son
omission laisse le placement automatique. Les marges et lalignement sur les
chunks peuvent éloigner le centre retenu.
Si le premier site est occupé, la recherche continue plus loin dans la même
direction : au plus 32 positions et 30 secondes de lecture au total. Même du
vide déjà généré compte comme occupé. Une erreur disque arrête la demande ;
elle nautorise pas à remplacer les chunks concernés.
```text
/sanctuary expansion preview glacier sanctuary northeast glacial 256 canyon 2048
/sanctuary expansion resume island_1
```
`preview` calcule une proposition géométrique sans activer l’île et sans vérifier
loccupation ou les fondations. `resume` reprend une réservation interrompue,
avec sa graine et ses paramètres conservés. Le système ne fournit aucune commande
de régénération des régions déjà créées.
Les îles ajoutées gardent la génération dexpansion alpha.12 : leurs climats,
reliefs, décorations et structures compatibles. Elles ne reçoivent pas
systématiquement la grande rivière ou les terrasses de l’île initiale. Villages,
temples, cabanes de marais et camps abandonnés dépendent du biome, du diamètre
et des fondations ; aucune présence nest garantie. Les règles de progression
futures restent décrites dans [Expansion](expansion.md).
## Correctif alpha.13.1 — expansions sous Windows
Lerreur `AccessDeniedException` visant le **dossier**
`data/sanctuary-world-v13`, depuis `ExpansionJournal.persist`, vient de la
synchronisation du répertoire après l’écriture du journal. Java sous Windows
ne permet pas cette ouverture avec `FileChannel` ; ce message ne signifie pas
à lui seul que le joueur manque de droits sur sa sauvegarde.
1. Fermer Minecraft pour arrêter la session dont les expansions sont bloquées.
2. Mettre linstance Prism existante à jour vers **0.1.0-alpha.13.1** via son
canal packwiz, puis relancer le jeu.
3. Rouvrir **le même monde** et consulter `/sanctuary expansion list`.
Si linstance Windows a été importée depuis le MRpack sans auto-update, fermer
Minecraft puis remplacer seulement `sanctuary-0.1.0-alpha.13.jar` par le JAR
`sanctuary-0.1.0-alpha.13.1.jar` de la release dans son dossier `mods`.
Conserver un seul JAR Sanctuary actif ; les autres mods et les sauvegardes
restent dans la même instance. Le raccordement au canal stable est décrit dans
[Distribution](packwiz.md#raccorder-linstance-prism-existante).
Le remplacement du journal ayant précédé cette erreur, lexpansion demandée
peut déjà être enregistrée comme `reserved`. Au chargement, les réservations
valides reprennent automatiquement avec leur identifiant, leur graine et leur
position. Il nest pas nécessaire de recréer le monde ou de supprimer le dossier
du journal. La commande `resume` seule ne débloque pas une session dont le
journal a été déclaré indisponible : sa relecture exige de rouvrir le monde.
Le correctif conserve le format et le contenu du journal existant ; il ne
migre ni ne régénère les chunks. La [validation ciblée](testing.md#correctif-alpha131--journal-dexpansion-sous-windows)
distingue les contrôles locaux de lessai Windows natif restant à effectuer.
## Paramètres et sauvegardes
Le preset public `sanctuary:sanctuary` utilise le nouveau codec de générateur
`sanctuary:island_v13`. Sa source de biomes est également enregistrée sous
`sanctuary:island_v13`. Le générateur sérialise `initial_diameter`, limité à
512, 724 ou 1 024 ; une valeur absente prend 724. Ce champ appartient à la
sauvegarde créée, et ne suit pas les changements ultérieurs de défaut du pack.
Pour l’île initiale, les réglages internes `sanctuary:unified_5`,
`sanctuary:unified_10` et `sanctuary:unified_20` correspondent respectivement
aux diamètres 512, 724 et 1 024. Les suffixes sont des références de calcul,
pas des capacités affichées. Ils reprennent la densité Population et son
hydrologie avec le générateur et le `RandomState` propres à lorigine.
La source combine la palette Population de départ et le sélecteur de cavités
natives du laboratoire, avec les mêmes conditions de sélection de ces poches.
Les îles enfants conservent le sélecteur climatique du laboratoire. Les 34
biomes natifs de cette palette et les dix biomes Population sont déclarés
avant la première expansion ; TerraMix nest pas une dépendance téléchargée.
Le nouveau journal est enregistré dans le dossier du monde :
```text
data/sanctuary-world-v13/expansions.json
```
Son schéma reste **1**, avec une racine de **génération 13** et des extensions
de **génération 12**. Les identifiants, parents, graines, paramètres, positions
et états douverture sont conservés côté serveur. La racine et le diamètre
initial ne sont pas modifiables par une commande dexpansion.
Les mondes du laboratoire alpha.12/12.1 gardent leur codec et leur journal
`data/sanctuary-expansion-v1/expansions.json`. Les générations Population et
les autres générations antérieures gardent leurs paramètres enregistrés.
Les presets historiques sont conservés mais cachés dans la création publique.
**Aucun monde nest converti, agrandi, réduit ou régénéré par lalpha.13.**
Le [guide alpha.12](alpha12-laboratory.md) est une référence historique, pas une
instruction de sélectionner à nouveau le laboratoire pour cette version.
## Contrôles de développement
`./gradlew check build assemblePack` et `:sanctuary:runGameTest` sélectionnent
par défaut la suite unifiée. Son diamètre de test se choisit avec
`-PsanctuaryTestDiameter=512`, `724` ou `1024` ; il ne change pas le défaut public.
Les suites Population historiques demandent `-PsanctuaryUnifiedTests=false`,
avec leur profil `-PsanctuaryTestPlayers=5`, `10`, `20` ou `100`. Les essais du
laboratoire historique utilisent aussi ce drapeau, avec
`-PsanctuaryExpansionTests=true`. Ces processus travaillent dans les dossiers
de développement ignorés, jamais dans les sauvegardes personnelles.
Les validations et mesures des alpha.11, .12 et .12.1 restent historiques.
Elles ne prouvent ni les temps de chargement ni le rendu de cette intégration.
Les trois tailles, linterface native et les expansions/rechargements passent
les contrôles alpha.13. Moyen atteint 63,66 s pour créer le monde puis charger
289 chunks FULL sur la graine 0 ; voir [Validation](testing.md) pour le protocole
et ses limites. La release, le MRpack et le canal packwiz sont publiés ; deux
synchronisations dans la même instance Prism conservent les 601 fichiers
personnels et réglages suivis. Voir [Distribution](packwiz.md).
+115
View File
@@ -0,0 +1,115 @@
# Architecture de lalpha.20
Cette passe donne une identité visuelle aux lieux déjà planifiés : volumes,
façades, sols, plafonds et mobilier. Elle réutilise les emprises et les accès
certifiés des générations précédentes. Les bâtiments de ville et les sites
expérimentaux restent inscrits dans leurs parcelles de 17 × 17 blocs ; leurs
corniches et leurs retraits restent à lintérieur de cette limite.
Les nouveaux rendus demandent un **nouveau monde de génération 20**. Les mondes
19 et antérieurs conservent leurs bâtiments, y compris dans les futurs chunks.
Le bouton **Personnaliser**, les trois tailles et loption **Structures
expérimentales** restent disponibles. Le catalogue nimpose pas que chaque lieu
apparaisse sur chaque île.
![Sélection darchitectures produites par les rendus de lalpha.20](images/architecture20-overview.png)
*Vues des blocs réellement émis par les générateurs, affichés avec les modèles
Minecraft, sans terrain. Ce montage est une prévisualisation géométrique, pas
une capture du client en jeu ; il montre une sélection du catalogue.*
## Les six bâtiments de Lost City
Les étages conservent leurs plans alternés, leurs pièces et leurs escaliers.
Les sanitaires sont clos, les lits adossés à de vrais murs, et les lampes
reposent sur des blocs complets. Les aménagements de toit décrits ci-dessous
possèdent des points daccès vérifiés par le smoke du renderer.
| Fonction | Forme et façade | Espaces et détails construits |
| --- | --- | --- |
| Immeuble | Angles avant coupés, baies en saillie, montants de chêne écorcé et bandeaux de tuf. | Parquets alternant chêne et bouleau, logements avec cuisine et chambre ; jardinières, portique de bois et table sur le toit. |
| Hôtel | Portique et travées encadrées de diorite polie, garde-corps et bandeaux en bouleau ; pavillon de toit en retrait. | Réception, chambres, salons, sanitaires et buanderie ; petit local de linge et bancs sur le toit. |
| Bibliothèque | Grandes baies, angles coupés et lames verticales en bouleau écorcé ; lanterneau vitré avec ouverture à travers le toit et le plafond du dernier étage. | Rayonnages, archives, tables et lutrins ; sols délimitant les espaces de lecture et terrasse de lecture sur le toit. |
| Restaurant | Encadrements de granit poli, vitrines et auvents associant chêne et bouleau. | Salles à manger et cuisines réparties par étage, garde-manger et sanitaires ; tables sur le toit et sorties dextraction maçonnées. |
| Caserne | Piles épaisses de briques de tuf, soubassements ciselés et fenêtres grillagées. | Dortoirs, bureau de service, réserves d’équipement et sanitaires ; cage descalier coiffée dun pavillon accessible. |
| Hall | Travées monumentales, piliers ciselés, corniches de diorite et fronton de toit en gradins. | Billetterie, bureaux, salle avec scène sur deux niveaux, galerie protégée, salles de réunion et archives. |
## Les quatorze anciennes infrastructures
Chaque site conserve ses seuils, son accès de service et son coffre de découverte.
Les fonctions ci-dessous sont exprimées par les pièces et les blocs vanilla
présents ; elles ne constituent pas de nouveaux systèmes de progression.
| Lieu | Architecture et usage visibles |
| --- | --- |
| Gare de fret | Halle aux angles coupés, lanterneau vitré et long auvent de quai en chêne ; voie, salle dattente et petit bureau de signalisation. |
| Poste et centre de tri | Façade de granit, accents de diorite et toiture en retrait ; guichet, rangées de tonneaux, passage de tri et bureau. |
| Dépôt communal | Grand entrepôt nervuré de pierre, toiture industrielle à trois travées vitrées en dents de scie ; rails, chargement, caisses et mezzanine dinventaire. |
| Cartothèque dexpédition | Corps en bouleau, petites ailes différenciées et balcon arrière abrité ; table de cartographie, lutrin, archives et réserve. |
| Observatoire et météo | Tour à pans coupés, contreforts de diorite et couronne ouverte portée par des nervures ; deux volées descalier, salle dinstruments et plateforme dobservation. |
| Atelier ferroviaire | Large couverture à nervures et vitrages, piles de granit ; deux voies, fosse dentretien avec escalier, établi, fourneau, meule et petite réserve. |
| Station de pompage et château deau | Bâtiment bas, conduites figurées par des grilles et murets, cuve haute sur quatre piles ciselées ; galerie dentretien et réserve sèche. Leau reste dans la cuve. |
| Sous-station | Cour clôturée, supports et grilles autour des équipements, locaux bas à toiture nervurée ; commandes vanilla et tranchée dentretien accessible. |
| Serre et graineterie | Verrière haute rythmée par des nervures de bouleau ; cultures, deux points dirrigation contenus, allée de tuf et remise de rempotage. |
| Quai sur le vide | Platelage en chêne, garde-corps à piliers sculptés, abris bas et grue de bois à chaîne ; attente, débarcadère et réserve. |
| Relais radio et maison du gardien | Petite maison de chêne avec toiture à deux matières et cheminée ; antenne haute à traverses, plateforme accessible et chambre du gardien. |
| Club photo et chambre noire | Façade claire de diorite, corniche sculptée et toiture arrière en retrait ; salle du club, chambre noire, bacs et espace de séchage à chaînes. |
| Vestiaires et salles étranges | Entrée basse et étroite suivie dune salle haute à colonnes répétées ; casiers, sols carrelés, alcôve et petite pièce de service. |
| Refuge de chantier | Cabane surélevée sur piles ciselées, toiture inclinée et ossature inachevée ; couchage, établi, escalier extérieur et trappe avec échelle vers la réserve inférieure. |
Les voies de fret gardent leur ballast, leurs traverses, leurs courbes et leurs
rails manquants. Les chemins reçoivent des bordures de briques de tuf et des
accents ciselés ; leurs pentes et leurs destinations restent celles du plan
sauvegardé.
## Espaces publics et équipements extérieurs
| Espace | Détails présents en génération 20 |
| --- | --- |
| Rues et ponts | Chaussée dandésite polie, bordures de tuf poli, bandes de briques de tuf ponctuées de blocs ciselés ; marches sur les pentes, piles de garde-corps et grilles sur les ponts. Les tabliers najoutent pas de colonnes vers le vide. |
| Parc arboré | Allées en croix, bancs de chêne à accoudoirs minéraux, lampadaires et petite couronne de bouleau à côté des arbres existants. |
| Jardin | Quatre parterres bordés de diorite, angles de tuf sculpté, portique de bois et promenade centrale. |
| Aire de jeux | Bac à sable bordé de bouleau, portique de balançoire, plateforme de jeu avec marches et garde-corps, bancs et éclairage. |
| Place | Sol organisé en couronnes de diorite, granit et tuf ; fontaine sèche à coupe basse et piédestal sculpté. |
| Mémorial | Sol minéral à lignes de tuf, marches autour du socle, colonne mêlant tuf et briques de pierre sculptées. |
| Marché | Croisement piéton, quatre étals avec pieds ciselés, poteaux de chêne et couvertures alternant dalles de chêne et de bouleau. |
| Parking | Rampe et stationnements conservés ; piliers dandésite, assises ciselées, bandeau en tuf, grilles de ventilation et lampes de plafond. Il reste enterré seulement lorsque le sol est certifié. |
| Piscine extérieure | Bassin et marches immergées conservés, margelles de diorite, chaises longues et auvent de bouleau près des bacs de douche. |
| Excavation | Escalier central vers la fosse, barrières, piles sculptées, poutre sur appuis, établi et meule ; le chantier reste limité à la roche certifiée. |
## Sous la ville et réseaux oubliés
| Ensemble | Traitement architectural |
| --- | --- |
| Grande piscine couverte | Bassin, escaliers et colonnade conservés ; parois de calcite, bandeaux de diorite, chapiteaux ciselés et nervures hautes répétées. |
| Parkings souterrains | Plateformes et rampes conservées ; fondations maçonnées et bandeaux de tuf ponctués de motifs ciselés. |
| Grande salle sèche | Boiseries de chêne, assises de tuf, murs clairs, corniches et bancs de chêne ; le socle central reste sans machine de progression. |
| Égouts et sorties deau | Briques de tuf, motifs ciselés et corniches hautes ; les canaux, passerelles et écoulements existants sont conservés. |
| Passages et accès | Liserés de granit ou de tuf, seuils et accès verticaux conservés. |
| Mines, ateliers et réserves oubliés | Poutres de chêne, tuf, sols bordés de granit, établi et outillage vanilla ; rails, réserves et plateforme de travaux interrompus. |
| Archives et salles étranges | Parquets de bouleau, boiseries, rayonnages ; ailleurs, carrelages de calcite et diorite, colonnes et niches répétées. |
| Salles techniques oubliées | Nervures minérales, corniches et encadrements ciselés autour des seuils ; pompes figurées, grilles et postes de commande vanilla. |
## Matériaux et raccords
Les fondations des nouveaux bâtiments et chemins sont en **briques de pierre**.
La stone et la smooth stone restent présentes ; landésite, la diorite et le
granit différencient les sols et les usages. Le tuf existe brut, poli, en
briques et sous ses deux formes ciselées. Les dalles, escaliers inversés,
murets, grilles et trappes donnent de l’épaisseur aux corniches, aux appuis de
fenêtre et aux équipements. Le chêne et le bouleau apparaissent comme poteaux,
boiseries, parquets, charpentes et couvertures selon les lieux.
Le rendu 20 calcule les raccords des vitres, barreaux, clôtures, murets et
angles descaliers à partir de tous les blocs de la pièce, après sa rotation
et avant son découpage entre chunks. Une fenêtre peut ainsi rejoindre son
véritable encadrement, même lorsque le chargement découpe le bâtiment.
Les smokes des bâtiments de ville et des espaces extérieurs passent, notamment
pour les accès, les sanitaires, les lits, les lampes, les rampes et les bassins.
Les essais natifs de création et de réouverture de la ville et des infrastructures
passent ; leurs résultats et les contrôles de compatibilité sont suivis dans [les essais alpha.20](testing-alpha20.md).
Aucune caméra, mailbox, machine dexpansion ou production électrique dédiée
nest ajoutée par cette passe. Les meubles utilisables restent les blocs
vanilla, et les butins de découverte restent partagés et non renouvelés.
+24
View File
@@ -0,0 +1,24 @@
# Alpha.21 — palettes et couvertures
La ville revient à une base commune : pierre lisse, pierre, briques de pierre,
andésite et chêne. Les façades, seuils, corniches et verrières gardent leurs
formes ; les pièces conservent leurs fonctions et leur circulation. La serre
emploie le bouleau comme bois propre. Le tuf accompagne les routes et les
parties souterraines.
![Modèles des bâtiments alpha.21](images/architecture-alpha21-overview.png)
Ces vues sont calculées depuis les blocs produits par les renderers21 et les
modèles/textures de Minecraft 26.3-pre-2. Elles montrent des variantes choisies,
sans terrain, éclairage ni végétation de la partie. Ce ne sont pas des captures
du client et elles ne prouvent pas les raccords avec le relief.
Les couvertures de latelier, du dépôt, de la serre, du relais radio et du refuge
reposent sur des murs ou charpentes. Lancien plafond opaque sous leur nouvelle
couverture est retiré. Les vérifications contrôlent aussi les petits appuis des
excavations, piscines, rampes de garage et ornements.
![Coupe des couvertures](images/architecture-alpha21-roofs.png)
La seconde planche coupe volontairement les murs pour voir les dessous des toits.
Les détails de validation et la graine dessai sont dans [le protocole](testing-alpha21.md).
+418
View File
@@ -419,6 +419,424 @@ valide pas le rendu ni un temps de chargement universel. Voir
[Distribution](packwiz.md) pour le reçu de publication et de synchronisation.
Ce ticket local nest pas une issue distante publiée.
### WG-13 — Laboratoire dexpansion à branches — alpha.12
**Branche :** `codex/expansion-lab`.
**But :** tester en opérateur de nouvelles îles, des climats alternatifs, les
reliefs hauts et les structures vanilla avant de développer leur progression.
**Critères dacceptation :**
- Nouveau preset laboratoire ; Petit/Grand et anciennes parties conservés.
- Commandes pour préparer, créer, suivre, reprendre et visiter une île ;
opérateurs uniquement, raccourcis et retours FR/EN.
- Parent et direction indépendants du climat, avec tailles nominales bornées
64/128/256/512 et reliefs versionnés. Une branche peut changer de climat.
- Application du relief haut aussi à l’île initiale expérimentale ; dessous
historique et sources anciennes préservés.
- Structures vanilla sélectionnées par biome avec contrôles demprise/support ;
preuve moteur dune structure réellement placée et de son butin natif.
- Journal atomique et repris après interruption, demandes répétées sans doublon,
refus des chunks existants y compris vides, conservation dun bloc témoin.
- Contrat de réservation explicite avant toute activation. Aucun ancien monde
converti ; aucune suppression de chunks ni commande de régénération.
- `check build assemblePack` et tests moteur sur monde jetable, avec limites
documentées. Les recherches, prix, boîtes postales et le Void restent planifiés.
**État :** implémenté et vérifié localement : build/pack, douze tests historiques,
expansions et rechargement moteur de graine 42. Release et MRpack publiés,
canal packwiz avancé et même instance Prism synchronisée deux fois ; 466 fichiers
personnels et réglages suivis conservés. Validation visuelle et multijoueur
encore ouverte. Contrat et portée des preuves
dans [Laboratoire alpha.12](alpha12-laboratory.md).
Ce ticket ne clôture pas par avance les océans, toute la palette de structures
ou la progression collective prévus par WG-04 à WG-07 et la vision.
### WG-14 — Distance et recherche dun emplacement libre — alpha.12.1
**Branche :** `codex/expansion-free-space`.
**Problème :** les huit premiers emplacements peuvent contenir du vide déjà
sauvegardé. Lalpha.12 refuse alors toutes les directions au lieu de chercher
plus loin, et ne propose aucun paramètre de distance.
**Contrat et résultat attendu :**
- Distance minimale facultative en fin de `quick`, `create` et `preview`, en
blocs entre les centres du parent et de lenfant ; 0 ou omission = automatique.
- Rechercher au plus 32 positions sur le même rayon en dépassant les zones
occupées ; conserver le budget global de lecture de 30 secondes.
- Seule une occupation certaine autorise un nouvel essai. Une erreur disque,
un timeout ou un terrain sans sol ne doivent pas devenir un nouveau tirage.
- Préserver les anciennes commandes, les identifiants, la graine de chaque île,
les descripteurs de génération 12 et le journal v1. La correction sapplique
aux mondes laboratoire alpha.12 sans conversion. Les régions existantes
sont inchangées ; seules de nouvelles réservations peuvent être ajoutées.
- Vérifier les huit premières directions occupées, la création plus loin,
la distance demandée depuis une île fille et les chunks sauvegardés après
redémarrage. Fournir les retours FR/EN et la distribution packwiz/MRpack.
**État :** correction implémentée, smokes et scénarios moteur de création/rechargement
réussis. `check build assemblePack` et douze tests historiques réussis.
Release, MRpack et canal packwiz publiés ; même instance Prism synchronisée
deux fois, avec 466 fichiers personnels et réglages suivis conservés.
### WG-15 — Unifier Sanctuary et ses options — alpha.13
**Branche :** `codex/unified-worldgen`.
- Un seul type public Sanctuary, bouton Personnaliser avec Petit (512), Moyen
(724, défaut), Grand (1 024). Choix enregistré dans le générateur ; Annuler
conserve les réglages. Les types vanilla restent disponibles.
- Réunir le relief, les forêts, minerais, rivières, bassins, terrasses et lave
de Sanctuary avec les cavités vanilla luxuriantes, à spéléothèmes et soufrées
du laboratoire, en protégeant les volumes hydrologiques pendant la décoration.
- Les outils dexpansion alpha.12.1 fonctionnent dans les nouveaux mondes unifiés.
Même contrat opérateur, réservation de zones nouvelles et refus du vide exploré.
- Nouveau codec et origine génération 13 ; les anciens codecs et journaux restent
chargeables. Aucun monde personnel nest ouvert ou converti, aucun chunk régénéré.
- Vérifier les trois tailles, cavités réellement décorées et fluides après ticks,
persistance/rechargement, UI client, build et distribution dans la même instance.
**État :** implémenté et vérifié : bouton natif, paramètres sur disque, huit tests
moteur par taille sur la graine 42, eau après 1 800 ticks, trois cavités natives,
compatibilité historique et rechargement des expansions. Moyen : 63,66 s pour
création et 289 chunks FULL dans le protocole serveur de référence.
`check build assemblePack` passe. Release, MRpack et canal packwiz publiés ;
même instance Prism synchronisée deux fois, avec 601 fichiers personnels et
réglages suivis conservés.
Les limites, dont labsence de preuve de camp dans lorigine unifiée des graines
inspectées, sont documentées dans [Validation](testing.md).
### WG-16 — Journal dexpansion sous Windows — alpha.13.1
**Branche :** `codex/windows-expansion-journal`.
**Problème :** après le remplacement atomique du journal, louverture de son
répertoire par `FileChannel` échoue sous Windows avec `AccessDeniedException`.
La session arrête alors toute expansion, même si la réservation a déjà été écrite.
- Écarter uniquement la synchronisation de répertoire non prise en charge par
le système de fichiers Windows ; conserver force du fichier, remplacement
atomique, relecture stricte et arrêt sur les véritables erreurs de stockage.
- Conserver le schéma, les identifiants, la géométrie et les journaux existants.
Après mise à jour et rechargement, reprendre les réservations déjà enregistrées
avec le mécanisme existant, sans suppression ni reconstruction de lhistorique.
- Tester le chemin Windows par injection, une panne après remplacement et la
reprise, puis le build et les scénarios moteur dans le développement local.
- Publier le correctif sur le canal stable. La machine Windows signalée nest
pas accessible depuis cet environnement Mac : ne pas prétendre lavoir testée.
**État :** correctif et tests vérifiés en alpha.13.1. `check build assemblePack`
passe, ainsi que le smoke du journal et les scénarios de création/rechargement
dans deux processus Minecraft distincts. Validation Windows par injection sur
macOS ; aucun essai Windows natif. 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 dexpansion `lost_city` pour tester des territoires urbains plus vastes.
Bâtiments, circulations et ponts sappuient 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 lemprise ;
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 sapplique
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 nest 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é. Labsence
dune preuve complète, des entités, POI, ticks ou événements bloc empêchent
ladmission. 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 lemprise urbaine certifiée. Les ponts et viaducs
prolongent ce réseau. Aucun équipement ne doit flotter sans appui.
- Retour de lessai 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 dexpansion, sans lactiver 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 lalpha.15 publiée et corriger les ruines pour que
Sanctuary Island possède un quartier identifiable et des infrastructures
profondes qui donnent limpression dune 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
nest 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 laccè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 lalpha.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 loption 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 linstance
existante selon le protocole packwiz. Donner graines et coordonnées dessai.
**État : livré en alpha.17.** Création/réouverture, option désactivée,
compatibilité 16 et `check build assemblePack` passent. Release et canal packwiz
publiés, même instance Prism synchronisée deux fois ; les 767 fichiers personnels
et réglages suivis sont conservés. Voir [le reçu](packwiz.md) et
[les coordonnées dessai](testing-alpha17.md).
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
nest déclaré compatible ; les mondes dessai restent séparés.
### WG-21 — Audit des structures et des anciennes circulations
**Branche :** `codex/structures-audit`.
**Demande :** avant de poursuivre limplémentation, proposer des structures
contemporaines, des axes abandonnés à l’échelle de l’île et leurs liens possibles
avec lexpansion, la mailbox et les objets utiles cachés dans des lieux secrets.
**État : audit documenté, propositions à discuter.** Le
[catalogue et schéma de relations](structures-audit.md) distinguent les trois
petits ensembles actuels des futurs tracés entre destinations, proposent des
voies de fret dégradées et des usages pour les bâtiments. Les outils vanilla
servent didées de butin ; la caméra et les systèmes restent pour la bêta.
Aucune structure, règle de butin, version binaire ou instance Prism modifiée.
La sélection des lieux et la réalisation des axes feront lobjet du chantier
suivant ; ce catalogue nest pas une liste dajouts tous validés.
### WG-22 — Anciennes infrastructures et liaisons — alpha.19
**Branche :** `codex/heritage-worldgen-alpha19`.
**Demande validée :** première passe de toutes les nouvelles structures du
catalogue WG-21 et de leurs liaisons avant la bêta, en conservant la possibilité
de revenir à une génération sans ce chantier.
- Ajouter les quatorze lieux avec architectures, entrées, pièces, mobilier et
petits butins vanilla associés à leur usage. Présence selon le terrain ;
relever les lieux réellement générés et donner les coordonnées des témoins.
- Planifier des voies de fret et chemins entre destinations réelles, avec
sections abandonnées lisibles, appuis et passages vérifiés. Rechercher les
raccords aux anciennes installations dans un budget borné.
- Garder une majorité naturelle à l’île ; conserver le terrain, lhydrologie,
la ville et les réseaux existants comme base. Première île uniquement.
- Nouveau codec 19, option expérimentale sauvegardée à la création du monde.
Option désactivée : socle urbain seul. Les mondes 17 et antérieurs gardent
leur génération, leurs pièces et leurs futurs chunks. Aucune conversion,
suppression de structures ou rétrogradation dune sauvegarde 19.
- Les bâtiments préparent les futurs services ; mailbox, deposit boxes,
caméra, réacteur, progression et récompenses personnelles restent pour la
bêta. Les coffres de cette passe sont partagés et utilisent du butin vanilla.
- Vérifier créations/réouvertures, option désactivée, anciens mondes, supports,
circulations et vrais placements. `check build assemblePack`, artefacts,
publication packwiz et mise à jour de la même instance Prism.
**État : livré en alpha.19 ; code, artefacts et synchronisation Prism vérifiés.** Voir
[les essais natifs et la carte des lieux](testing-alpha19.md). Version 19 demandée
explicitement ; aucune version 18 publiée
dans ce dépôt à louverture du chantier. Le catalogue est maintenant accepté
pour une première passe, sans imposer sa présence exhaustive sur chaque graine.
### WG-23 — Identité architecturale et blocs détaillés — alpha.20
**Branche :** `codex/architecture-alpha20`.
**Demande :** donner aux bâtiments une identité contemporaine lisible selon
leur fonction, corriger les vitres isolées et détailler formes, façades et
circulations. Employer du tuf, ses briques et variantes ciselées, du chêne,
du bouleau, du granit, de la diorite et de landésite ; conserver les fondations
en briques de pierre. Ajouter des blocs sculptés et du relief architectural.
- Reprendre les quatorze lieux expérimentaux, les bâtiments de Lost City,
leurs espaces extérieurs et leurs sous-sols ; palettes par usage, volumes
en retrait, biseaux, baies, corniches, liserés et mobilier adapté.
- Résoudre les connexions des vitres, grilles, clôtures et murs ainsi que les
angles descaliers depuis la géométrie complète, avant le découpage par chunk.
- Dessiner les bordures de routes avec les briques de tuf et le tuf ciselé,
en conservant la circulation et les tracés sauvegardés.
- Garder les plans de placement et les emprises certifiées ; génération 20
et types de pièces distincts. Les mondes 19 et antérieurs gardent leurs
rendus, y compris dans les futurs chunks. Aucune rénovation rétroactive.
- Garder le bouton natif Personnaliser, les trois tailles et loption des
structures expérimentales pour les nouveaux mondes.
- Vérifier les états réels des blocs, les accès, les supports, les coffres,
la création/réouverture et la compatibilité avec la version 19 publiée.
Livrer le pack 20 par le canal stable et synchroniser la même instance Prism.
**État : livré en alpha.20 ; code, artefacts et même instance Prism vérifiés.** Voir
[le contrat et les essais alpha.20](testing-alpha20.md).
### WG-24 — Palettes sobres, toits portés et galeries — alpha.21
**Branche :** `codex/palettes-reseaux-alpha21`.
Reprendre les extérieurs sans changer les plans intérieurs approuvés : smooth
stone, stone brick, stone, andésite et chêne dominants en ville ; un bois cohérent
par bâtiment unique et tuf surtout au sol et sous terre. Corriger les toits
suspendus et les anciens plafonds superposés, avec appuis vérifiables.
Étendre la ville par au plus deux petits quartiers séparés de terrain naturel,
reliés par des chemins praticables ; rapprocher les destinations patrimoniales
par un graphe de chemins. Ajouter des galeries industrielles abandonnées :
chaussée de cinq blocs, deux trottoirs, quelques grandes salles et deux accès
par escaliers depuis la surface, en respectant les eaux et structures existantes.
Ajouter des fronts rocheux et de petits filons intacts dans les galeries et
leurs salles de maintenance : ressources vanilla réellement minables, sans
régénération de butin ni remplacement des blocs déjà extraits. Fournir un MRpack
pour lessai séparé demandé.
Assouplir les refus de placement : admettre les pentes, des volumes partiellement
enfouis et des appuis adaptés ; rechercher des détours et prioriser les lieux
isolés pour les chemins. Garder une ville fragmentée sans socle géant.
Nouvelle génération21 réservée aux nouveaux mondes ; tous les codecs et rendus
20 et antérieurs restent disponibles. Tailles512/724/1024, Moyen par défaut ;
option expérimentale enregistrée. Aucun monde personnel modifié. Vérifier les
pièces natives, accès, connexions, supports, création/réouverture, ancien monde20,
puis livrer le pack stable21 et synchroniser la même instance Prism.
**État : livré en alpha.21 ; MRpack, release et canal packwiz publiés, même instance Prism synchronisée deux fois. Tests, sauvegardes et limites documentés dans `docs/testing-alpha21.md`.**
### WG-25 — Placement sélectif et archéologie — alpha.22
**Branche :** `codex/placement-selectif-alpha22`.
Annule pour les nouveaux mondes lassouplissement de WG-24. Chercher un terrain
vraiment adapté et accepter labsence dune structure plutôt que de forcer son
implantation. La ville ne reçoit plus de réservation artificielle de secours.
Bâtiments et chemins limitent terrassement et remblai. Exception explicite :
la grande traversée et la salle dexpansion sont des éléments de base, à placer
en priorité indépendamment de la ville ; les égouts peuvent être ailleurs.
Les galeries gardent leur génie civil et la salle reçoit un accès autonome.
Les recherches restent bornées.
Conserver les palettes, les intérieurs, les filons et le rendu21. Nouveau codec22
pour les nouveaux mondes, sans migration ni retouche des mondes précédents.
Vérifier ladmission sur les bons terrains, les refus sur les mauvais, la création
native, la réouverture et la compatibilité21. Livrer le MRpack et mettre à jour
le canal et la même instance Prism en conservant les données personnelles.
Inscrire dans la vision « Minecraft comme archéologie dun autre Minecraft » :
chaque ruine garde la trace dune solution ancienne. Les neuf personnages sont
ceux nommés dans la vision ; la découverte et les rôles restent à définir.
Ce cadre narratif ne constitue pas une nouvelle mécanique implémentée.
**État : livré en alpha.22 ; MRpack, release et canal packwiz publiés, même instance Prism synchronisée deux fois.** Voir [les essais22](testing-alpha22.md).
## Réserve de thèmes futurs
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
+314
View File
@@ -0,0 +1,314 @@
{
"version": "0.1.0-alpha.12",
"date": "2026-09-09",
"minecraft": "26.3-pre-2",
"java": 25,
"generation": 12,
"source_base_commit": "f816ffc6402fe31789f9677c2ee94328b3181329",
"scope": "Local development tests only; no personal worlds, client rendering, live player teleport or multiplayer load.",
"normalization": "world_directory fields replaced with <development_game_test_world>; all other diagnostic fields retained.",
"artifact": {
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.jar",
"sha256": "f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9",
"staged_pack_jar_identical": true,
"contains_gametest_classes": false
},
"build": {
"command": "./gradlew check build assemblePack",
"passed": true,
"legacy_profile": 10,
"legacy_seed": 0,
"required_engine_tests": 12,
"fluid_ticks": 1800,
"log_sha256": "dfedc75558596ce4413bd0466ec03645b2fa6853165510e9b8db0406d0958eb8"
},
"first_process_diagnostic_sha256": "90872c801cb04e3f0c78b47e0d38a0b45dd5d3e6d1e33ccc53c4359e4b407238",
"reload_diagnostic_sha256": "1efe055e1179ce60741a0d8546618807fc136c0de77f9aff6a979cf88b2b1cdd",
"fresh_world": {
"seed": 42,
"process_id": 2892,
"generator_version": 12,
"islands": [
{
"upper_biome": "minecraft:jungle",
"climate": "tropical",
"landing": [
0,
202,
-800
],
"landing_biome": "minecraft:jungle",
"parent": "sanctuary",
"id": "alpha",
"centre": [
0,
-800
],
"diameter": 64
},
{
"upper_biome": "minecraft:desert",
"climate": "arid",
"landing": [
544,
198,
-800
],
"landing_biome": "minecraft:desert",
"parent": "alpha",
"id": "beta",
"centre": [
544,
-800
],
"diameter": 64
}
],
"scope": "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.",
"registered_command_permission_checks": true,
"parent_witness": [
-16,
248,
-16
],
"root_landing": [
-16,
242,
-16
],
"journal_reloaded_regions": 3,
"visited_void_refusal": {
"chunk": [
-50,
50
],
"reason": "Expansion intersects a loaded or unloading chunk: [-61, 39]"
},
"native_structure": {
"full_chunks": 12,
"placed_block": "Block{minecraft:barrel}[facing=south,open=false]",
"structure": "minecraft:abandoned_camp_forest",
"mode": "natural candidate accepted by production ExpansionStructures, native decoration",
"container": [
28,
247,
73
],
"loot_table": "minecraft:barrels/abandoned_camp_barrel"
},
"restart_snapshot": {
"world_directory": "<development_game_test_world>",
"gold_witness": [
544,
204,
-800
],
"journal_sha256": "d7de79838744624e98ce26997ddc1daecb7bd3334bd9bf0e1ad31aa5eb7e27c3",
"islands": [
{
"id": "sanctuary",
"parent": "sanctuary",
"direction": "north",
"climate": "temperate",
"diameter": 512,
"center_x": 0,
"center_z": 0,
"seed": 42,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "alpha",
"parent": "sanctuary",
"direction": "north",
"climate": "tropical",
"diameter": 64,
"center_x": 0,
"center_z": -800,
"seed": 390175152143173586,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "beta",
"parent": "alpha",
"direction": "east",
"climate": "arid",
"diameter": 64,
"center_x": 544,
"center_z": -800,
"seed": 6281303726624369227,
"relief": "natural",
"status": "ready",
"generation": 12
}
],
"generator_codec": {
"biome_source": {
"biomes": [
"minecraft:the_void",
"minecraft:forest",
"minecraft:birch_forest",
"minecraft:flower_forest",
"minecraft:plains",
"minecraft:meadow",
"minecraft:taiga",
"minecraft:snowy_taiga",
"minecraft:snowy_plains",
"minecraft:grove",
"minecraft:ice_spikes",
"minecraft:frozen_peaks",
"minecraft:old_growth_spruce_taiga",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:pale_garden",
"minecraft:jungle",
"minecraft:sparse_jungle",
"minecraft:bamboo_jungle",
"minecraft:mangrove_swamp",
"minecraft:savanna",
"minecraft:savanna_plateau",
"minecraft:desert",
"minecraft:badlands",
"minecraft:eroded_badlands",
"minecraft:wooded_badlands",
"minecraft:windswept_hills",
"minecraft:windswept_gravelly_hills",
"minecraft:stony_peaks",
"minecraft:dappled_forest",
"minecraft:cherry_grove",
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:sulfur_caves"
]
},
"type": "sanctuary:expansion_lab"
},
"samples": [
{
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242
},
{
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"point": [
0,
202,
-800
],
"id": "alpha",
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
"base_height": 202
},
{
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"point": [
544,
198,
-800
],
"id": "beta",
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198
}
],
"world_flushed_before_shutdown": true
},
"native_structure_starts_seen": 1,
"structure_start_chunks_inspected": 106,
"ticks": 1390,
"elapsed_ms": 43123.278916,
"passed": true
},
"reloaded_world": {
"seed": 42,
"process_id": 2975,
"scope": "A second Java process opens the same saved development chunks and expansion journal, and verifies a gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client world-selection test or a multiplayer/teleportation test.",
"previous_process_id": 2892,
"world_directory": "<development_game_test_world>",
"gold_witness": [
544,
204,
-800
],
"samples": [
{
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242,
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485"
},
{
"point": [
0,
202,
-800
],
"id": "alpha",
"stored_column_sha256": "0debd009e711898fe64573f31a77f03f03d680be7398918b126004fcee3cfb1c",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "9269110205d64e1f72d9c44591a825d66fff3a4278fb835e5b4f2627b01a1db5",
"base_height": 202,
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b"
},
{
"point": [
544,
198,
-800
],
"id": "beta",
"stored_column_sha256": "73f7b445475a80b951f05d0c5fb6fd2eca2cf7519cd6ac5d64c7fe9dac53fa23",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198,
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60"
}
],
"regions": 3,
"native_container": [
28,
247,
73
],
"saved_generator_codec_decoded": true,
"passed": true
},
"earlier_seed_0_attempt": {
"scope": "Earlier development run before final cache and error-message fixes. Expansion phases completed; positive loot witness failed.",
"native_candidate_sites": 11,
"no_surface": 9,
"insufficient_foundation": 2,
"accepted_structures": 0,
"structure_proof_passed": false
},
"unverified": [
"Forced crash during chunk preparation",
"Normal client world selection and rendering",
"Real player teleports and multiplayer",
"Visual quality of every climate/relief and 128/256/512 formats",
"Every village and temple variant"
]
}
+408
View File
@@ -0,0 +1,408 @@
{
"version": "0.1.0-alpha.12.1",
"date": "2026-09-09",
"minecraft": "26.3-pre-2",
"java": 25,
"generation": 12,
"journal_schema": 1,
"base_commit": "40a86588b5f83fbc7bd2728eb9b439baa1fc9ba5",
"scope": "Development-world engine tests; no personal world, live player teleportation or client rendering. Process timings are not performance benchmarks.",
"normalization": "world_directory fields replaced with <development_game_test_world>; other diagnostic fields unchanged.",
"build": {
"command": "./gradlew check build assemblePack",
"passed": true,
"required_historical_engine_tests": 12,
"seed": 0,
"profile": 10,
"log_sha256": "72f2173023e5407391d7e0e528012fcda9a314c981acfea462918c36ffa7bf90"
},
"artifact": {
"path": "mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.12.1.jar",
"sha256": "c9df4da956cfa54581b3fde21de006bf8ac3ec025b8dd0daa5d060bcb70aabd4"
},
"first_process_diagnostic_sha256": "3ef35faf09130409135879ad3a4be4ceba4f5644a57ae9cdcec4cd4d6e84c06a",
"reload_diagnostic_sha256": "401f2e6739de9efd3668aa27c5ef45a6e81e1acfde44442bf101e5ac6397074a",
"fresh_world": {
"seed": 42,
"process_id": 5743,
"generator_version": 12,
"islands": [
{
"centre": [
0,
-1600
],
"diameter": 64,
"upper_biome": "minecraft:jungle",
"climate": "tropical",
"landing": [
-4,
171,
-1604
],
"landing_biome": "minecraft:jungle",
"parent": "sanctuary",
"id": "alpha"
},
{
"centre": [
2048,
-1600
],
"diameter": 64,
"upper_biome": "minecraft:desert",
"climate": "arid",
"landing": [
2048,
198,
-1600
],
"landing_biome": "minecraft:desert",
"parent": "alpha",
"id": "beta"
}
],
"scope": "Fresh laboratory world; real FULL chunks and native structure loot, asynchronous activation, branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated.",
"registered_command_permission_checks": true,
"parent_witness": [
-16,
248,
-16
],
"root_landing": [
-16,
242,
-16
],
"initial_occupied_directions": 8,
"journal_reloaded_regions": 3,
"native_structure": {
"container": [
28,
247,
73
],
"loot_table": "minecraft:barrels/abandoned_camp_barrel",
"full_chunks": 12,
"placed_block": "Block{minecraft:barrel}[facing=south,open=false]",
"structure": "minecraft:abandoned_camp_forest",
"mode": "natural candidate accepted by production ExpansionStructures, native decoration"
},
"restart_snapshot": {
"world_directory": "<development_game_test_world>",
"gold_witness": [
2048,
204,
-1600
],
"journal_sha256": "21136a5e8f1857b10ef5673281c8a3a4568ae032641995c206ed2031e112aa5b",
"islands": [
{
"id": "sanctuary",
"parent": "sanctuary",
"direction": "north",
"climate": "temperate",
"diameter": 512,
"center_x": 0,
"center_z": 0,
"seed": 42,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "alpha",
"parent": "sanctuary",
"direction": "north",
"climate": "tropical",
"diameter": 64,
"center_x": 0,
"center_z": -1600,
"seed": 390175152143173586,
"relief": "natural",
"status": "ready",
"generation": 12
},
{
"id": "beta",
"parent": "alpha",
"direction": "east",
"climate": "arid",
"diameter": 64,
"center_x": 2048,
"center_z": -1600,
"seed": 6281303726624369227,
"relief": "natural",
"status": "ready",
"generation": 12
}
],
"generator_codec": {
"biome_source": {
"biomes": [
"minecraft:the_void",
"minecraft:forest",
"minecraft:birch_forest",
"minecraft:flower_forest",
"minecraft:plains",
"minecraft:meadow",
"minecraft:taiga",
"minecraft:snowy_taiga",
"minecraft:snowy_plains",
"minecraft:grove",
"minecraft:ice_spikes",
"minecraft:frozen_peaks",
"minecraft:old_growth_spruce_taiga",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:pale_garden",
"minecraft:jungle",
"minecraft:sparse_jungle",
"minecraft:bamboo_jungle",
"minecraft:mangrove_swamp",
"minecraft:savanna",
"minecraft:savanna_plateau",
"minecraft:desert",
"minecraft:badlands",
"minecraft:eroded_badlands",
"minecraft:wooded_badlands",
"minecraft:windswept_hills",
"minecraft:windswept_gravelly_hills",
"minecraft:stony_peaks",
"minecraft:dappled_forest",
"minecraft:cherry_grove",
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:sulfur_caves"
]
},
"type": "sanctuary:expansion_lab"
},
"blocked_sites": [
{
"id": "blocked_north",
"parent": "sanctuary",
"direction": "north",
"climate": "boreal",
"diameter": 64,
"center_x": 0,
"center_z": -800,
"seed": 6170446579001265223,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_northeast",
"parent": "sanctuary",
"direction": "northeast",
"climate": "boreal",
"diameter": 64,
"center_x": 800,
"center_z": -800,
"seed": 2312608842580142629,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_east",
"parent": "sanctuary",
"direction": "east",
"climate": "boreal",
"diameter": 64,
"center_x": 800,
"center_z": 0,
"seed": -5785143794938635053,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_southeast",
"parent": "sanctuary",
"direction": "southeast",
"climate": "boreal",
"diameter": 64,
"center_x": 800,
"center_z": 800,
"seed": 6543995692374257629,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_south",
"parent": "sanctuary",
"direction": "south",
"climate": "boreal",
"diameter": 64,
"center_x": 0,
"center_z": 800,
"seed": 9109857537375437182,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_southwest",
"parent": "sanctuary",
"direction": "southwest",
"climate": "boreal",
"diameter": 64,
"center_x": -800,
"center_z": 800,
"seed": 7977138842745944844,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_west",
"parent": "sanctuary",
"direction": "west",
"climate": "boreal",
"diameter": 64,
"center_x": -800,
"center_z": 0,
"seed": 8731833603567083481,
"relief": "natural",
"status": "reserved",
"generation": 12
},
{
"id": "blocked_northwest",
"parent": "sanctuary",
"direction": "northwest",
"climate": "boreal",
"diameter": 64,
"center_x": -800,
"center_z": -800,
"seed": 6470159394492078415,
"relief": "natural",
"status": "reserved",
"generation": 12
}
],
"samples": [
{
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242,
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4"
},
{
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
"base_height": 204,
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"point": [
-4,
171,
-1604
],
"id": "alpha",
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1"
},
{
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198,
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"point": [
2048,
198,
-1600
],
"id": "beta",
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093"
}
],
"world_flushed_before_shutdown": true
},
"native_structure_starts_seen": 1,
"structure_start_chunks_inspected": 106,
"ticks": 1470,
"elapsed_ms": 41563.165541,
"passed": true
},
"reloaded_world": {
"seed": 42,
"process_id": 5830,
"scope": "A second Java process opens the same saved development chunks and expansion journal, and verifies a gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client world-selection test or a multiplayer/teleportation test.",
"saved_only_collision_refusals": 8,
"previous_process_id": 5743,
"world_directory": "<development_game_test_world>",
"gold_witness": [
2048,
204,
-1600
],
"samples": [
{
"stored_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"point": [
-16,
242,
-16
],
"id": "sanctuary",
"stored_column_sha256": "a291039b2ed502c828b4f1ec97fe111f49f62e69b5589e05116d9b1931f461a4",
"resolved_biomes_sha256": "65db6d2cde63dee7b60bc74aec702aae61f558c3878b8c8efaf0cc75d6066485",
"base_column_sha256": "646af3da3a327ab15d229b7fe2c328cc10eae7fce31d6b24c60f3be643bc1095",
"base_height": 242
},
{
"stored_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"point": [
-4,
171,
-1604
],
"id": "alpha",
"stored_column_sha256": "9866d275b53719e434d20996e2c061f4342e431c41d671216781d1f00c204ff1",
"resolved_biomes_sha256": "dfda92ebd5677b8ea4fcd3b02ba029a898b22a8f640a71b520bab9783695eb1b",
"base_column_sha256": "4ec4238d357426e34a1decfd817fc96c6b596758e98ffc1d98906a1db8d5b707",
"base_height": 204
},
{
"stored_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"point": [
2048,
198,
-1600
],
"id": "beta",
"stored_column_sha256": "246bc9b1827effb58159dbc540c655f2d218079a49263d19ac786cf116557093",
"resolved_biomes_sha256": "7233e9fb21f196866122679eec5cabf39d383b8a26fff0848ff6700d1c9fab60",
"base_column_sha256": "4df3dd476f41a8c6b0e0273b9ebe5cbba4dec6cb22001c553c15d4f6b255c555",
"base_height": 198
}
],
"regions": 3,
"native_container": [
28,
247,
73
],
"saved_generator_codec_decoded": true,
"after_reload_expansion": [
0,
1600
],
"after_reload_landing": [
-4,
153,
1596
],
"final_regions": 4,
"passed": true
}
}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
{
"version": "0.1.0-alpha.13.1",
"date": "2026-09-09",
"minecraft": "26.3-pre-2",
"java": 25,
"host_os": "macOS",
"windows_native_tested": false,
"windows_policy_tested_by_injection": true,
"journal_contracts": [
"Windows policy skips only directory channel",
"file force and atomic replacement and strict read-back remain required",
"supported directory AccessDenied propagates after replacement",
"failed session refuses further writes",
"verified reload recovers persisted reservation",
"post-replacement corruption fails closed"
],
"jar_sha256": "b340dfc58f3d2d1a2a375dc527ac6721c61430600622046db26228e0da46061b",
"logs": {
"journal-smoke": {
"path": "build/alpha131-journal-smoke.log",
"sha256": "c02e94713cf3f9a3c76674700cc5c800aecd84b213b2be9ed53629f8eb9cd3d0",
"passed": true,
"required_game_tests": null
},
"check": {
"path": "build/alpha131-check.log",
"sha256": "68f6cb97e2f80669d39e37623076229e030a0d9826c13e9420f51f6ba23885bd",
"passed": true,
"required_game_tests": 8
},
"expansion-create": {
"path": "build/alpha131-expansion-create.log",
"sha256": "61fa4513aef71290a1419c3425a329047c8fe623a561544c6a8d916fe50e226c",
"passed": true,
"required_game_tests": 2
},
"expansion-reload": {
"path": "build/alpha131-expansion-reload.log",
"sha256": "44fafb4636ff90707a068f325a245dbf9820ccb1bdf785c912f7e636fe7cfa43",
"passed": true,
"required_game_tests": 2
}
},
"binary_comparison": {
"base_version": "0.1.0-alpha.13",
"version": "0.1.0-alpha.13.1",
"added": [
"fr/koka/sanctuary/expansion/ExpansionJournal$DirectorySync.class"
],
"removed": [],
"changed": [
"fabric.mod.json",
"fr/koka/sanctuary/expansion/ExpansionJournal$1.class",
"fr/koka/sanctuary/expansion/ExpansionJournal.class"
],
"synthetic_switch_instructions_identical": true,
"all_other_entries_identical": true
},
"expansion": {
"seed": 42,
"initial_diameter": 724,
"generation": 13,
"creation_process": 13148,
"reload_process": 13279,
"creation_passed": true,
"reload_passed": true,
"creation_islands": [
{
"landing_biome": "minecraft:jungle",
"landing": [
0,
202,
-1792
],
"climate": "tropical",
"upper_biome": "minecraft:jungle",
"diameter": 64,
"centre": [
0,
-1792
],
"id": "alpha",
"parent": "sanctuary"
},
{
"landing_biome": "minecraft:desert",
"landing": [
2048,
198,
-1792
],
"climate": "arid",
"upper_biome": "minecraft:desert",
"diameter": 64,
"centre": [
2048,
-1792
],
"id": "beta",
"parent": "alpha"
}
],
"reloaded_regions": 3,
"saved_generator_codec_decoded": true,
"after_reload_expansion": [
0,
1792
],
"final_regions": 4,
"native_structure_status": "not_observed_after_complete_scan",
"native_structure_witness_required": false,
"limitation": "Optional native camp witness absent after complete search on seed 42, as in alpha.13; no new structures claim."
},
"scope": "No generation or journal schema change. Test worlds only; no personal world opened or regenerated. Windows native runtime and power-loss durability not tested."
}
+66 -35
View File
@@ -1,29 +1,53 @@
# Climats, expansion et ressources
Ce document fixe les décisions de conception pour la suite de Sanctuary. Le
registre persistant des continents, leur déblocage et leur génération dans les
huit directions restent à implémenter. Le relevé de ressources décrit plus bas
est un outil de développement séparé, exécuté dans le monde jetable des GameTests.
Ce document distingue les outils de génération et la progression collective
encore à construire. Lalpha.13 réunit l’île de départ et
les commandes dexpansion dans le choix public **Sanctuary**. Le
[guide alpha.13](alpha13-worldgen.md) décrit leur utilisation. Les versions
alpha.12/12.1 et leur [laboratoire](alpha12-laboratory.md) restent historiques.
Le déblocage par recherches, XP et contributions nest pas encore implémenté.
## Taille de l’île initiale — alpha.11
## Taille initiale et extensions — alpha.13
Deux choix de départ sont proposés : **Petit** et **Grand**, Grand par défaut.
Petit garde un diamètre nominal de 512 blocs ; Grand vise environ 724 blocs,
soit deux fois laire nominale de Petit. La hauteur reste de 384 blocs. Les
repères internes 5/10 servent au calcul de surface, sans nom de capacité affiché
ni promesse sur le nombre de joueurs, les minerais ou laire constructible.
Le bouton natif **Personnaliser** propose **Petit** (512 blocs de diamètre
nominal), **Moyen** (724, par défaut) et **Grand** (1 024). La hauteur reste de
384 blocs. Ce choix configure un nouveau monde ; il ne promet ni nombre de
joueurs, ni quantité de ressources, ni aire entièrement constructible.
Cette taille se choisit pour un nouveau monde et ne remplace pas le futur
déblocage collectif de continents. Grand utilise `population_10` ; les anciennes
sauvegardes 5/20/100 conservent leurs réglages et leurs dimensions. Les formats
20/100 sont cachés de la création. Les mesures de lalpha.10 conservées plus bas
restent historiques ; les contrôles alpha.11 sont dans [Validation](testing.md).
L’île initiale reprend le relief, les forêts et lhydrologie de Population,
avec les poches de cavités natives issues du laboratoire. Les îles ajoutées
conservent les tailles **64/128/256/512**, les douze profils climatiques et les
quatre reliefs dexpansion. Leur génération ne reprend pas automatiquement
la grande rivière et les terrasses de l’île initiale.
## Les huit directions climatiques
Les commandes opérateur `quick`, `create`, `preview`, `list`, `visit` et
`resume` reprennent le fonctionnement alpha.12.1. Le paramètre final de distance
est facultatif ; les sites occupés sont écartés en cherchant plus loin dans la
même direction. Même un chunk de vide déjà généré est protégé. Les recherches
collectives et les objets de déblocage ne sont pas nécessaires à ces outils.
La racine alpha.13 est de génération 13 ; les extensions restent de génération
12, dans le journal `data/sanctuary-world-v13/expansions.json` du nouveau monde.
Les anciens mondes gardent leurs codecs, leurs paramètres et leur journal.
Les choix séparés Petit/Grand et Laboratoire sont désormais cachés à la
création. Aucun monde existant nest converti ou redimensionné.
## Tailles et mesures historiques
Lalpha.11 proposait Petit (512) et Grand (724), avec Grand par défaut ; ses
repères internes 5/10 ne correspondaient pas à une capacité multijoueur promise.
Les références `population_5/10/20/100` restent celles des sauvegardes historiques.
Les mesures alpha.3 à alpha.11 conservées ci-dessous décrivent uniquement les
versions et les emprises observées à leur date. Les contrôles de lalpha.13,
les rechargements et le benchmark Moyen sont consignés dans [Validation](testing.md).
## Les huit directions et les branches climatiques
Sanctuary Island constitue le point de départ **tempéré, légèrement humide**.
Le nord conduit vers le froid, le sud vers le chaud, l'ouest vers le sec et
l'est vers l'humide. Les diagonales combinent ces tendances.
La boussole climatique est une convention facultative : nord froid, sud chaud,
ouest sec et est humide. Les diagonales combinent ces tendances. **La direction
géographique ne contraint plus le climat.** Les joueurs pourront choisir de
prolonger une voie climatique ou de bifurquer vers dautres découvertes.
| Direction | Axe Minecraft depuis Sanctuary | Tendance climatique |
| --- | --- | --- |
@@ -36,7 +60,11 @@ l'est vers l'humide. Les diagonales combinent ces tendances.
| Ouest | `X` | Sec |
| Nord-ouest | `X, Z` | Froid et sec |
Ces directions définissent des familles de climats, pas huit biomes uniformes.
Ces associations sont les raccourcis par défaut, pas une loi du monde.
Une branche conserve son parent, sa direction et son climat explicite. Plusieurs
couronnes peuvent donc proposer des biomes nouveaux au nord comme au sud.
Le point dorigine reste enregistré même si un joueur déplace son lit ou le spawn.
Les familles de climats ne sont pas des biomes uniformes.
Le relief, l'altitude, les variations locales et la graine doivent conserver
des géographies distinctes à l'intérieur de chaque continent. Les amplitudes
de température et d'humidité, leur évolution avec la distance et les seuils
@@ -123,12 +151,15 @@ Une graine peut donc manquer dune ressource ; la présence de tout le parcour
Minecraft nest pas garantie. Les structures donnant accès à lEnd et leur lien
avec lexpansion restent à concevoir.
## Relevé de développement disponible
## Relevé de développement — générateurs historiques
`ResourceSurveyDiagnostics`, dans `src/gametest`, lit les chunks au statut
`FULL`, après le terrain, les matériaux et les décorations. Il ne figure pas
dans le JAR Sanctuary distribué et n'ajoute aucune commande joueur ou admin.
La tâche repart d'un monde GameTest neuf, jamais d'une sauvegarde personnelle.
Depuis lalpha.13, `runGameTest` et `check` sélectionnent la suite unifiée par
défaut. Les commandes ci-dessous demandent explicitement une suite historique ;
elles ne constituent pas un relevé du nouveau monde unifié.
Le relevé standard inspecte **neuf chunks**, soit un carré de 48×48 blocs autour
du chunk du spawn naturel, sur toute la hauteur de la dimension. Il prépare
@@ -139,13 +170,13 @@ n'est inclus dans ces comptes de blocs.
```sh
# Contrôle standard : neuf chunks inspectés, total local uniquement.
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=sample --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryResourceSurvey=sample --console=plain
# Relevé de toute l'enveloppe déclarée de Sanctuary, sur un monde jetable neuf.
./gradlew :sanctuary:runGameTest -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=island --console=plain
# Désactiver seulement le relevé pour un autre diagnostic.
./gradlew :sanctuary:runGameTest -PsanctuaryResourceSurvey=off --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryResourceSurvey=off --console=plain
```
Les modes sont transmis à la propriété JVM de test
@@ -155,7 +186,7 @@ ce dernier par défaut ; `20` et `100` servent à la compatibilité historique.
Le mode `island` calcule lenveloppe du profil actif, avec 16 blocs de marge de
décoration et un halo de préparation dun chunk. Les valeurs ci-dessous sont
les bornes calculées lors de lalpha.10, pas des relevés exécutés ni un nombre
de chunks déjà validé pour le nouveau Grand :
de chunks déjà validé pour lancien Grand alpha.11 :
| Profil | Chunks comptés par `island` | Chunks FULL préparés avec halo |
| --- | --- | --- |
@@ -250,16 +281,16 @@ essai client concurrent pendant environ 23 secondes. Elle décrit une machine
chargée et cette exécution, pas une performance garantie sur d'autres machines.
Le temps de préparation est distinct du coût du comptage par palettes.
## Contrat du prochain ticket d'expansion
## Contrat de progression collective restant à construire
Avant toute activation de continent, enregistrer côté serveur son identifiant,
sa direction parmi les huit, son centre, son emprise, sa graine dérivée, sa
version de génération, son profil climatique et son état de déblocage.
Le relevé des ressources intervient après la génération effective de sa région,
avec une complétude explicite, indépendamment des contributions qui l'ont ouverte.
Le registre et la réservation des alpha.12/12.1 sont repris dans lalpha.13 :
identifiant, parent, direction, centre, emprise, graine, génération, climat et
statut sont enregistrés côté serveur. Les chunks existants, y compris le vide,
sont protégés pendant la recherche dune nouvelle région.
Les zones déjà explorées dans le vide peuvent contenir des chunks sauvegardés
ou des constructions. L'activation exige donc un contrat de réservation ou de
matérialisation qui les protège ; elle ne peut pas régénérer silencieusement
les chunks existants. Les règles de contribution, distances, tailles,
réservations, migrations et prix ne sont pas décidées par ce diagnostic.
Le prochain travail de progression doit relier ces ouvertures aux recherches,
aux contributions et à leurs coûts. Un éventuel relevé de ressources intervient
après la génération réelle, avec une complétude explicite ; il ne choisit pas
une graine pour remplir un stock attendu. Les prix, les objets de déblocage et
les règles de contribution restent à concevoir. Aucun contrat de migration
ou de régénération danciennes parties nest fourni par ce diagnostic.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

+411 -4
View File
@@ -13,13 +13,420 @@ 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.22 publiée — 10 septembre 2026
[Sanctuary 0.1.0-alpha.22](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.22)
est publiée depuis le commit source `d6e0c14`. Le canal stable est au commit
`0019178`. Le JAR, le
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.22/Sanctuary-0.1.0-alpha.22.mrpack)
et le ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
```text
10d7ef32ac2e81a977ea4854503bafa29e0bc10929984b76df01d6f5f61f9056
```
Les bâtiments de surface recherchent des terrains adaptés ; la grande traversée
et la salle dexpansion restent des éléments de base, indépendants dune ville.
Les galeries peuvent chercher un détour et de nouveaux accès. Le hall peut
accueillir des égouts ailleurs dans l’île. Les trois tailles natives, le cas OFF,
la réouverture et la comparaison21 passent. `check build assemblePack` passe
avec75 tâches et les huit GameTests généraux. Voir [les essais22](testing-alpha22.md).
Deux synchronisations isolées puis deux dans la même instance Prism réussissent.
Un seul JAR Sanctuary alpha.22 est actif ; les **925 fichiers personnels et
réglages suivis** gardent leurs hashes. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR21 et le suivi
packwiz sont sauvegardés sous `sanctuary-backups/before-0.1.0-alpha.22/`.
Reçus locaux : `build/alpha22-isolated-validation.json` et
`build/alpha22-prism-validation.json`.
Pour tester : **nouveau monde Sanctuary, Moyen, graine0**. Les portiques de la
traversée sont `-170 253 -98` et `170 254 -98` ; la trappe du hall indépendant
est `2 252 0`. `/sanctuary structures` liste les lieux. Les villes et bâtiments
facultatifs peuvent être refusés. Les graines vérifiées ne prouvent pas la
présence des infrastructures sur toutes les graines ; les recherches restent
bornées. Aucun monde personnel na été ouvert, converti ou régénéré. Aucun
essai client visuel ni essai Windows natif nest revendiqué.
## Alpha.21 publiée — 10 septembre 2026
[Sanctuary 0.1.0-alpha.21](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.21)
est publiée depuis le commit source `bb63fab`. Le canal stable utilisait cette version au commit
`8002fce`. Le JAR, le
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.21/Sanctuary-0.1.0-alpha.21.mrpack)
et le ZIP damorçage Prism ont été vérifiés. SHA-256 du JAR :
```text
e528e42d99f9e1ea221f780ea089b49b42a1a4e9e24e4ddd001d045183202c21
```
Palettes sobres, toitures portées, quartiers reliés, placements patrimoniaux
assouplis et premières galeries industrielles avec fronts rocheux et filons.
Voir le [catalogue](architecture-alpha21.md) et les [essais](testing-alpha21.md).
`check build assemblePack`, les huit GameTests généraux, les créations et
réouvertures natives passent. Le cas expérimental OFF passe. Les fichiers
historiques et les témoins natifs de la génération20 sont conservés.
Deux synchronisations isolées puis deux dans la même instance Prism réussissent.
Un seul JAR Sanctuary alpha.21 est actif ; les **925 fichiers personnels et
réglages suivis** gardent leurs hashes. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR20 et le suivi
packwiz sont sauvegardés sous `sanctuary-backups/before-0.1.0-alpha.21/`.
Reçus locaux : `build/alpha21-isolated-validation.json` et
`build/alpha21-prism-validation.json`.
Pour tester : **nouveau monde Sanctuary, Moyen, graine0**, **Structures
expérimentales** activées dans **Personnaliser**. Entrées des galeries :
`-135 253 -178` et `-135 249 162`. `/sanctuary structures` liste les lieux.
Les raccords dépendent du relief ; certains sites restent isolés et les réseaux
souterrains ne sont pas tous reliés. Aucun monde personnel na été ouvert,
converti ou régénéré. Aucun essai Windows natif ni essai visuel client nest
revendiqué. Une sauvegarde21 nécessite le binaire21 ou un futur binaire compatible.
## Alpha.20 publiée — 10 septembre 2026
[Sanctuary 0.1.0-alpha.20](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.20)
est publiée depuis le commit source `0edac29`. Le canal stable utilisait cette
version au commit `4e0967d`. Le JAR, le
[MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.20/Sanctuary-0.1.0-alpha.20.mrpack)
et le ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
```text
9ea10576e641d83c13e1277960a375448a22d416a98a6b327c4d6d2a204c423a
```
Passe de détails sur six bâtiments de ville, quatorze infrastructures,
espaces publics et réseaux souterrains : palettes propres aux usages,
boiseries, tuf ciselé, corniches, biseaux, verrières et raccords des vitres.
Voir le [catalogue illustré](architecture-alpha20.md).
`check build assemblePack` passe, avec huit GameTests généraux. Les créations
et réouvertures natives conservent les structures, accès, supports et coffres.
Le bassin et les sorties d’égout restent fonctionnels. Les rapports de la
version19 restent identiques à ceux de la release publiée. Le mode désactivé
et une petite île passent ; les quatorze infrastructures sont rencontrées sur
les deux témoins. Les limites des raccords au terrain sont détaillées dans
[les preuves alpha.20](testing-alpha20.md).
Deux synchronisations isolées puis deux dans la même instance Prism Mac
réussissent. Un seul JAR Sanctuary alpha.20 est actif ; les **925 fichiers
personnels et réglages suivis** conservent leurs hashes. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ». Le JAR19 et le suivi
packwiz sont sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.20/`.
Reçus locaux : `build/alpha20-isolated-validation.json` et
`build/alpha20-prism-validation.json`.
Pour voir cette architecture, créer un **nouveau monde Sanctuary, Moyen,
graine 0**, avec **Structures expérimentales** activées dans **Personnaliser**.
`/sanctuary structures` liste les entrées. Petit, graine42, possède la
cartothèque à `-101 251 -25`. Loption désactivée dun autre nouveau monde
conserve la ville sans les réseaux expérimentaux. Les anciens mondes gardent
leur génération ; aucune sauvegarde personnelle na été ouverte, convertie ou
régénérée. Une sauvegarde20 ne se rétrograde pas vers le binaire19.
Les vues du catalogue viennent des modèles et ne sont pas des captures de
partie. Aucun essai client visuel ni essai Windows natif na été effectué.
Les nouvelles fonctions de la bêta restent à construire.
## Alpha.19 publiée — 9 septembre 2026
[Sanctuary 0.1.0-alpha.19](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.19)
est publiée depuis le commit source `c23e72b`. Le canal stable utilisait alors
cette version, au commit `ea883b7`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.19/Sanctuary-0.1.0-alpha.19.mrpack)
et ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
```text
a159b00a2778020800e81381e0608495a4d7909f1f853f0b8812cd4017865b0b
```
Première passe de quatorze architectures et de leurs voies de fret ou chemins
abandonnés sur l’île initiale. Les trois tailles sont vérifiées nativement ;
les quatorze types ont chacun été rencontrés et parcourus sur ces témoins.
Créer un **nouveau monde Sanctuary, Moyen, graine 0**, puis garder
**Structures expérimentales** activées dans **Personnaliser** : ce témoin
contient treize lieux et sept liaisons, dont un fret de 499 blocs.
`/sanctuary structures` relève les entrées. La présence des bâtiments et les
raccords dépendent du relief ; voir [les preuves et la carte](testing-alpha19.md).
`check build assemblePack` et les huit GameTests généraux passent.
Création et réouverture natives conservent 112 pièces et 13 coffres ; 68 cibles
sont accessibles. Le mode désactivé passe. Les empreintes de la génération 17
restent identiques aux preuves publiées, y compris après réouverture.
Deux synchronisations isolées puis deux dans la même instance Prism Mac
réussissent. Un seul JAR Sanctuary alpha.19 est actif ; les **767 fichiers
personnels et réglages suivis** conservent leurs hashes. Aucune sauvegarde
personnelle na été ouverte, convertie ou régénérée. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
Le JAR 17 et le suivi packwiz sont sauvegardés dans
`sanctuary-backups/before-0.1.0-alpha.19/`. Reçus locaux ignorés :
`build/alpha19-isolated-validation.json` et `build/alpha19-prism-validation.json`.
Pour comparer, créer un autre nouveau monde avec loption désactivée : le socle
urbain demeure, sans les expériences 17/19. Cette option ne supprime pas de
structures dans une sauvegarde existante. Les anciens mondes conservent leur
génération ; une sauvegarde 19 ne doit pas être ouverte avec lancien binaire 17.
Les bâtiments préparent les futurs usages, mais mailbox, caméra, réacteur et
progression restent pour la bêta. Aucun essai visuel dans le client ni essai
Windows natif na été effectué.
## Alpha.17 publiée — 9 septembre 2026
[Sanctuary 0.1.0-alpha.17](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.17)
est publiée depuis le commit source `0969ed5`. Le canal stable utilisait alors cette
version, au commit `6e0c297`. JAR, [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.17/Sanctuary-0.1.0-alpha.17.mrpack)
et ZIP damorçage Prism ont été vérifiés par téléchargement public.
SHA-256 du JAR :
```text
9ab7a9f03a46bc940d6bc014de85c6935eb5ba21f11ceea9b1222a9dae1faaec
```
Loption **Structures expérimentales** de **Personnaliser** ajoute trois familles
de petits réseaux sur les nouvelles îles initiales : mines et ateliers, salles
étranges, installations abandonnées. Le choix est enregistré avec la taille.
Le témoin **Moyen, graine 0**, option activée, possède trois réseaux accessibles
depuis la surface. Voir [les preuves et coordonnées](testing-alpha17.md).
`check build assemblePack`, les huit GameTests et les trois smokes dédiés passent.
Création et réouverture natives conservent 15 pièces, 3 coffres, 12 appuis et le
bloc témoin ; 49 cibles sont accessibles. Le cas désactivé passe. La génération 16
conserve les empreintes de ses structures et de son journal publiés.
Deux synchronisations isolées puis deux dans la même instance Prism Mac
réussissent. Un seul JAR Sanctuary alpha.17 est actif ; les **767 fichiers
personnels et réglages suivis** conservent leurs hashes. Aucune sauvegarde
personnelle na été ouverte, convertie ou régénérée. Le dossier reste
`Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
Le JAR 16 et le suivi packwiz sont sauvegardés dans
`sanctuary-backups/before-0.1.0-alpha.17/`. Reçus locaux ignorés :
`build/alpha17-isolated-validation.json` et `build/alpha17-prism-validation.json`.
Créer un **nouveau monde Sanctuary** pour cet essai. Loption désactivée permet
un autre nouveau monde sans les réseaux supplémentaires ; elle ne retire pas
de blocs dun monde existant. Les anciennes sauvegardes gardent leur génération.
Ne pas ouvrir une sauvegarde 17 avec le binaire 16. Les connexions entre réseaux,
le réacteur, les reliques et la progression restent à construire. Aucun essai
Windows natif ni revue visuelle dans le client na été effectué.
## 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 utilisait alors 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 damorç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 na é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 linstance.
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
dexpansion, le réacteur et les nouvelles familles de donjons restent prévus.
Aucun accès à linstance Windows na 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 damorç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 na été ouverte, convertie ou régénérée.
Le dossier reste `Sanctuary-0.1.0-alpha.1`, affiché « Sanctuary Beta ».
Le JAR dessai15 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 linstance.
Créer un **nouveau monde Sanctuary** pour la ville compacte et ses sous-sols,
y compris après lessai anticipé15. Les mondes publiés12/13/14 gardent leurs
générateurs. La salle dexpansion 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 à linstance Windows na 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 damorç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 à lidentique ; un seul JAR
Sanctuary alpha.14 est actif. Les **713 fichiers personnels et réglages suivis**
conservent leurs hashes. Aucune sauvegarde personnelle na é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 na 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
linstallateur packwiz et nautorise 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 damorç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 à lidentique ; un seul
JAR Sanctuary alpha.13.1 est actif. Les **713 fichiers personnels et réglages
suivis** conservent leurs hashes. Aucune sauvegarde personnelle na été ouverte,
convertie ou régénérée. Dossier de linstance 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 linstance
à jour puis rouvrir le même monde. Les réservations enregistrées reprennent
après vérification. La machine Windows na 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 damorç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 na été ouverte, convertie ou régénérée.
Linstance conserve son dossier `Sanctuary-0.1.0-alpha.1`, sans réimportation.
Sauvegarde ciblée : `sanctuary-backups/before-0.1.0-alpha.13/` dans cette instance.
Reçus locaux : `build/alpha13-isolated-validation.json` et
`build/alpha13-prism-validation.json`.
Pour tester : créer un **nouveau monde**, choisir **Sanctuary**, puis le bouton
natif **Personnaliser** : Petit 512, Moyen 724 par défaut, Grand 1 024.
Le laboratoire nest plus un choix public ; ses cavités et outils dexpansion
rejoignent le nouveau monde. Les anciennes générations restent chargeables.
Voir [le guide alpha.13](alpha13-worldgen.md) et [les preuves](testing.md).
## Correctif alpha.12.1 publié — 9 septembre 2026
[Sanctuary 0.1.0-alpha.12.1](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.12.1)
est publiée depuis le commit source `332d60b`. Le canal packwiz utilisait cette
version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12.1/Sanctuary-0.1.0-alpha.12.1.mrpack)
et le ZIP damorçage ont été vérifiés par téléchargement public. SHA-256 du JAR :
```text
c9df4da956cfa54581b3fde21de006bf8ac3ec025b8dd0daa5d060bcb70aabd4
```
Deux synchronisations isolées puis deux passages dans la même instance Prism
ont réussi ; le second laisse les fichiers gérés identiques. Un seul JAR
Sanctuary alpha.12.1 est actif, avec **466 fichiers personnels et réglages suivis
conservés**, y compris les mondes et les mods tiers. Aucun monde na été ouvert
ou régénéré. La sauvegarde ciblée est dans
`sanctuary-backups/before-0.1.0-alpha.12.1/` de linstance existante ; reçus locaux
`build/alpha121-isolated-validation.json` et `build/alpha121-prism-validation.json`.
WG-14 ajoute la distance et le contournement automatique des zones occupées.
Les mondes laboratoire alpha.12 restent utilisables sans conversion : le journal
v1 et les descripteurs de génération 12 sont conservés. Voir le [guide](alpha12-laboratory.md).
## Alpha.12 publiée — 9 septembre 2026
[Sanctuary 0.1.0-alpha.12](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.12)
est publiée depuis le commit source `cdfbba0`. Le canal stable utilisait alors
cette version. Le JAR, le [MRpack](https://git.botsu.net/koka/sanctuary-beta/releases/download/v0.1.0-alpha.12/Sanctuary-0.1.0-alpha.12.mrpack)
et le ZIP damorçage Prism ont été téléchargés publiquement et vérifiés contre
les artefacts locaux. SHA-256 du JAR :
```text
f2ac2462a03a39ef330cc52143f9753ac5908b606cfe1dd1388ad5caae15e6e9
```
Le véritable installateur packwiz a réussi deux passages dans un dossier de
développement neuf, puis deux dans la même instance Sanctuary Beta. Le second
passage conserve les hashes des fichiers gérés. Un seul JAR Sanctuary alpha.12
est actif ; les **466 fichiers personnels et réglages suivis** conservent
leurs hashes, y compris les sauvegardes, mods tiers et configurations. Aucun
monde na été ouvert, converti ou régénéré par cette livraison.
La sauvegarde ciblée est dans `sanctuary-backups/before-0.1.0-alpha.12/` de
linstance existante. Les reçus locaux ignorés sont
`build/alpha12-isolated-validation.json` et `build/alpha12-prism-validation.json`.
Le [guide](alpha12-laboratory.md) décrit le nouveau preset **Sanctuary — Laboratoire** :
créer un nouveau monde de ce type pour essayer les expansions. Les anciennes
parties et les presets Petit/Grand gardent leur génération.
## Livrer une mise à jour
Dernière livraison vérifiée le 9 septembre 2026 :
Livraison précédente, vérifiée le 9 septembre 2026 :
[Sanctuary 0.1.0-alpha.11](https://git.botsu.net/koka/sanctuary-beta/releases/tag/v0.1.0-alpha.11),
depuis le commit source `f30a350`. Le JAR, le `.mrpack` et le ZIP damorçage
Prism sont publiés et leurs téléchargements publics vérifiés. Le canal stable
pointe sur cette version. SHA-256 du JAR :
pointait alors sur cette version. SHA-256 du JAR :
```text
f075ad8a0b97cd53e4891273adbee5f79f04ab3984442950d5d85f58d2ded589
@@ -42,7 +449,7 @@ Après les vérifications, le commit et le push de la branche du ticket, le scri
de publication réalise les étapes de release et de canal ci-dessous :
```sh
python3 scripts/publish_pack.py --notes-file chemin/vers/notes.md --asset build/Sanctuary-0.1.0-alpha.11.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
@@ -61,7 +468,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.11/sanctuary-0.1.0-alpha.11.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
+191
View File
@@ -0,0 +1,191 @@
# Structures contemporaines et anciennes circulations de Sanctuary
Audit de conception du 9 septembre 2026, après lessai de lalpha.17.
Ticket WG-21 ; branche `codex/structures-audit`.
**Propositions à discuter : aucune nouvelle génération ni règle de progression
nest implémentée par ce document.** Le pack reste en alpha.17.
## Ce que produit actuellement le mod
Lalpha.17 recherche au maximum trois petits ensembles, un par famille. Chaque
ensemble rassemble trois à cinq pièces autour dun accès de surface, avec des
salles généralement de 9 × 9 blocs. Leur position est cherchée indépendamment.
Les rails sont des tronçons intérieurs de la galerie minière. Les rues et ponts
de Lost City desservent son quartier compact. Aucun itinéraire ne relie ces
ensembles à l’échelle de l’île : le manque daxes perçu en jeu correspond donc
au fonctionnement actuel.
Les ateliers possèdent des outils et équipements vanilla utilisables. Les
coffres emploient les tables de maison des plaines et de cartographe ; le mod
na pas encore de distribution de trésors conçue pour chaque lieu secret.
La mailbox, les deposit boxes, la caméra, la machine dexpansion et les
récompenses personnelles restent des systèmes futurs.
Sources locales : [planificateur17](../mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsPlanner17.java),
[rendu17](../mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/ruins/RuinsRenderer17.java),
[preuves alpha.17](testing-alpha17.md), [vision](vision.md).
## Ligne de conception retenue après cet audit
**Minecraft comme archéologie dun autre Minecraft.** Steve, Ari, Sunny, Kai,
Zuri, Alex, Mefe, Makena, Noor sont les personnages disparus de la partie. Ils
ont découvert quelque chose de révolutionnaire et ont continué parce que ça
marchait, jusquau moment où ça na plus marché.
> Ils nont pas ouvert quelque chose quils nauraient pas dû ouvrir. Ils ont
> construit quelque chose quils ne savaient plus refermer.
Chaque ruine doit conserver la trace dune ancienne solution : une infrastructure
dont lauteur a progressivement disparu derrière son fonctionnement. Ce canon
ne définit pas la découverte, les rôles individuels ou une catastrophe confirmée.
Il guide les prochaines structures ; il najoute aucun personnage, récit jouable
ou système de progression aux versions déjà livrées. L’état alpha.17 décrit
ci-dessus reste le constat historique de cet audit.
Pour valider la conception dun lieu, relever concrètement :
- **Un usage ancien lisible** dans les pièces, les outils et les circulations.
- **Une implantation fonctionnelle** expliquée par le terrain, une ressource
ou une destination réellement présente.
- **Des traces dentretien, dextension ou de chantier** qui montrent comment
linstallation a été utilisée et adaptée.
- **Un lien futur à la progression**, explicitement décrit comme une intention
tant que son système et ses interactions ne sont pas implémentés.
## Faire comprendre les usages avant dintroduire leurs systèmes
Chaque lieu devrait répondre à trois questions : pourquoi était-il construit
ici, par où arrivaient ses occupants ou ses marchandises, et que peut en faire
un joueur aujourdhui ? Une plateforme de chargement, un vestiaire, des rayonnages
et une sortie de service racontent davantage son usage quune pièce portant un nom.
La chaîne proposée pour lexpansion est : **observer → préparer une expédition
→ rassembler des ressources → ouvrir un continent → construire ses liaisons**.
Les lieux rendent ces activités compréhensibles. Ils nimposent pas encore une
campagne, un coût ou un ordre obligatoire de déblocage.
La poste rend lisible la différence entre recevoir personnellement un colis et
contribuer à un stock collectif dans le dépôt. La salle dexpansion déjà présente
peut devenir le point où ces efforts collectifs prennent sens. Une future
mailbox ne doit pas être confondue avec un coffre ordinaire décoré en alpha.
Ces bâtiments pourraient présenter les services en bêta sans obliger les joueurs
à revenir au même guichet pour chaque action ; les règles dusage restent ouvertes.
## Catalogue proposé
Les objets cités sont des **idées de butin**, pas des ajouts de cette livraison.
Les variantes dune même famille peuvent se remplacer selon la graine et le
relief ; ce catalogue ne demande pas de tout placer sur chaque île.
| Lieu | Forme, pièces et implantation | Découverte en alpha à concevoir | Lien possible avec la bêta |
| --- | --- | --- | --- |
| **Gare de marchandises** | Quai bas, petite halle, balance, guichet, poste daiguillage et voie de garage ; à la jonction ville/industrie. | Comprendre la ligne abandonnée, trouver un wagonnet et quelques rails dans la réserve. | Transport des productions vers le dépôt collectif ; réparations faites par les joueurs. |
| **Centre postal et atelier de tri** | Hall public, comptoir, casiers individuels, salle de tri, quai arrière, bureau du facteur. | Suivre physiquement le passage dun colis de lentrée à son casier ; boussole dans le bureau fermé. | Découverte de la mailbox personnelle et de la livraison des achats. |
| **Halle du dépôt communal** | Grande porte, travées numérotées, aire de déchargement, mezzanine dinventaire et atelier annexe. | Stock modeste, outils de manutention suggérés par le décor, chantier de rayonnages interrompu. | Contributions via les deposit boxes, stocks collectifs et préparation dexpansions. |
| **Bureau des expéditions et cartothèque** | Salle de préparation, table de cartographie, vestiaire, réserves de voyage, balcon orienté vers le vide. | Boussole, papier et cartes vierges ; comparer les directions depuis le balcon. | Relevés de ressources, choix de climat et préparation des départs. Les cartes préremplies restent un travail distinct. |
| **Observatoire et station météo** | Petite tour sur une crête, plateforme dégagée, salle des instruments, bureau abrité. | Une longue-vue vanilla dans une armoire repérable ; observation du ciel et du relief. | Constellations, signatures climatiques et futur instrument dobservation éventuel. |
| **Dépôt de maintenance ferroviaire** | Hangar court avec fosse dentretien, établis, pièces détachées, vestiaire et établi personnel caché. | Réparer des tronçons, récupérer un outil utile ; retrouver le travail interrompu dun ancien occupant. | Production, maintenance et futures machines. |
| **Station de pompage et château deau** | Prise deau, bassin, galerie de vannes, passerelles, réservoir et exutoire ; implantation dictée par leau et les altitudes. | Traverser les bassins, rejoindre un local sec par une coursive ; seau ou canne à pêche. | Eau pour les ateliers et cultures. Aucun système de pompage actif nest supposé déjà disponible. |
| **Sous-station et galerie de maintenance** | Cour technique, salle de distribution, chemins de câbles, tranchée laissée ouverte et pièce de service. | Horloge dans le poste de garde, redstone modeste dans latelier ; repérer les départs de galeries. | Préparer visuellement les infrastructures à réactiver. Ni réacteur ni alimentation obligatoire de lexpansion décidés ici. |
| **Serre municipale et graineterie** | Serre partiellement effondrée, tables de rempotage, irrigation, remise et chambre de conservation. | Graines vanilla adaptées au lieu, pot de fleurs, outils de culture. | Récoltes localisées et cuisine d**Its Alive !** ; pas de plantes futures distribuées en alpha. |
| **Quai dexpédition au bord du vide** | Plateforme ancrée, hangar, rambarde, grue inachevée et petite salle dattente. | Point de vue et lieu de départ évident ; accès depuis la ville ou le dépôt. | Bateaux volants, Happy Ghast, biplans, ziplines ou ponts construits par les joueurs. Aucune île future présumée en face. |
| **Relais radio et cabane du gardien** | Antenne sobre sur un promontoire, escalier dentretien, logement minuscule et réserve sous le plancher. | Repère visible de loin ; boussole ou horloge, passage discret vers un atelier. | Réseau de services et rendez-vous collectifs, si ce rôle est retenu. |
| **Club photo et laboratoire caché** | Petite boutique ou salle associative, réserve, chambre noire et séchoir ; accès secondaire depuis une cour ou un hôtel. | Un atelier secret identifiable, avec une récompense vanilla provisoire. | Lieu naturel pour découvrir une **caméra en bêta** ; aucune caméra factice ou promesse de butin inexistant en alpha. |
| **Vestiaires et salles de service impossibles** | Derrière les grandes piscines : vestiaires, laverie, couloir de livraison, puis vaste salle carrelée hors proportion. | Étrangeté progressive, passages secondaires, récompense au bout dun détour lisible. | Donjons physiques et secrets ; distincts de la dimension Backrooms des objets perdus. |
| **Refuge de chantier dans un ouvrage inachevé** | Cabane dans une pile de viaduc, atelier suspendu avec ancrages, escalier arrêté à une plateforme et réserves laissées sur place. | Un lit, un outil et une installation récupérable ; sentir quun autre joueur s’était établi ici. | Réappropriation par les joueurs, bases individuelles et chemins à terminer. |
## Des axes à l’échelle de l’île
Une **ancienne ligne de fret**, quelques embranchements et un chemin de service
peuvent suffire à donner une organisation au territoire. Son tracé suit la pente,
entre brièvement dans la roche et franchit les ravins avec des ouvrages ponctuels.
La majorité de l’île reste naturelle. Une longue voie ne signifie pas une grande
gare ni une grande ville supplémentaire.
```mermaid
flowchart LR
M[Mine] --- A[Atelier et maintenance]
A --- G[Gare de marchandises]
G --- D[Dépôt communal]
G --- P[Centre postal]
D --- Q[Quai d'expédition]
P --- C[Cartothèque]
C --- O[Observatoire]
D -. passage technique .- E[Salle d'expansion existante]
E -. galerie de service .- H[Pompage et égouts]
H -. accès secondaire .- S[Salles étranges]
```
Ce schéma décrit les relations proposées, pas un plan rigide à reproduire ni
des liaisons toutes ferroviaires. Mine, atelier, gare et dépôt partagent la ligne
de fret ; les autres raccords peuvent être des rues, sentiers, escaliers ou
galeries. Les réseaux techniques et les lieux étranges gardent leurs itinéraires
propres, avec seulement quelques intersections utiles.
Pour une rupture crédible, il faut concevoir **le trajet complet entre deux
destinations présentes**, puis son état dabandon :
- **Rails arrachés** : le ballast et les traverses montrent le chemin à réparer.
- **Petit pont effondré** : les culées et une pile conservée indiquent la portée
manquante ; un détour permet de rejoindre lautre côté.
- **Tunnel éboulé** : les deux portails suivent le même axe ; une galerie de
maintenance donne un autre parcours lorsque le relief le permet.
- **Travaux jamais terminés** : terrassement, échafaudage, matériaux et butoir
forment une fin intentionnelle. Un chantier peut réellement sarrêter là.
- **Voie envahie** : végétation et débris alternent avec des sections utilisables.
Les coupures ne doivent pas reproduire laspect dun raccord de chunks cassé.
Les rails qui subsistent ont un support ; les tronçons réparables suivent des
pentes et courbes praticables en wagonnet. Un souterrain conserve des appuis et
un passage pour le joueur. Les raccords impossibles sont abandonnés proprement,
sans publier une porte donnant dans la roche ni annoncer une gare inexistante.
Une liaison vers un nouveau continent reste à construire après son ouverture.
Le quai exprime la possibilité de partir ; il ne prédit pas un terrain encore
absent, sa position exacte ou la route que choisiront les joueurs.
## Récompenser la découverte dun lieu secret
Un secret devrait offrir **un objet principal cohérent avec son ancien usage** :
longue-vue dans lobservatoire, boussole dans la cartothèque, horloge dans le poste
daiguillage, outil dans latelier. Quelques fournitures accompagnent cet objet.
Le plaisir vient de trouver un équipement utile tôt et de comprendre son origine ;
un objet vanilla fabriquable ne devient pas artificiellement une relique unique.
Laccès se lit dans le décor : fenêtre donnant sur une pièce sans porte visible,
escalier derrière une étagère déplacée, trappe de maintenance, conduit débouchant
sur un établi éclairé. La pièce peut être cachée sans exiger de casser tous les
murs au hasard. Des variations portent sur le plan, laccès et lusage, pas
seulement sur la couleur dune salle identique.
Le bâti est lui-même une récompense récupérable. Les rails, équipements datelier,
blocs de fer et autres matériaux du décor doivent compter dans linventaire
des ressources réellement générées ; une infrastructure trop généreuse pourrait
remplacer tout le minage du début de partie. Cela se mesure après génération.
Pour un premier essai de butin en alpha, les coffres pourraient rester des
conteneurs vanilla partagés, sans renouvellement automatique. La coexistence
des **reliques collectives uniques** et des **récompenses personnelles** nécessite
le contrat de bêta. Laccès à la mailbox ou à lexpansion ne devrait pas dépendre
dun objet ordinaire que le premier explorateur peut emporter et perdre.
## Ordre de travail proposé
1. Relier une destination existante et une nouvelle destination avec un axe
visible à l’échelle de l’île : atelier/mines, gare et dépôt. Vérifier les
trajets, les supports et quelques ruptures réparables en jeu.
2. Donner à la ville une poste et une cartothèque ; placer un observatoire dans
le relief, avec des accès logiques et un premier atelier secret.
3. Raccorder ponctuellement les galeries techniques aux égouts, piscines et
salles étranges existantes. Garder des secteurs indépendants et des accès
secondaires plutôt que transformer tous les sous-sols en un unique donjon.
4. Définir puis essayer les petites tables de butin par usage avant dintroduire
les objets et règles de la bêta.
Avant d’écrire ce générateur, valider la place de la ligne de fret, les premiers
lieux retenus et le rôle des ruptures : réparations libres, détours ou chantiers
à prolonger. Les essais doivent distinguer un axe planifié, ses tronçons réellement
générés, ceux qui sont praticables et ceux qui sont volontairement interrompus.
La recherche des raccords doit rester bornée, sans explorer ou charger toute
l’île pour chercher un passage. Les mondes publiés gardent leur génération ;
aucune transformation dune sauvegarde existante nest autorisée par cet audit.
+362
View File
@@ -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 limmuabilité 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 lancien 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 dexécution ; aucun journal séparé na é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 dun 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 dune 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 deau, marches immergées et plage minérale |
| Immeuble dappartements | É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. Lusure 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 sappliquer ; une ruine nimplique pas la présence immédiate
dun groupe de zombies. Les classes de zombies spécifiques restent pour la bêta.
Le placement vérifie la roche sous toute lemprise. 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 jusquau vide. Les volumes protégés de
lhydrologie et les sites insuffisamment supportés sont écartés.
## Commandes dessai et placement des expansions
Les commandes restent des outils opérateur, accessibles avec les permissions
adéquates. Elles nutilisent 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 lexemple du premier identifiant libre créé par `quick` ; utiliser
lidentifiant réellement indiqué par la commande. `visit` attend une île prête.
`preview` examine un emplacement sans lactiver. `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 dexpansion 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 lidentifiant :
| 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 demplacement 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
dabord 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 nest 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
dune île, dun terrain occupé ou dune construction.
1. Examiner toute lemprise d’écriture et produire, pour chaque chunk existant,
une preuve stricte quil est FULL, entièrement vide et vierge. Les positions
encore absentes sont également certifiées, y compris leurs stockages
dentité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 à lidentité exacte de lexpansion 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**.
Lexpansion 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 lemprise 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 dair 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`. Lancien 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 lempreinte sémantique du vide.
Il ne faut supprimer ni certificat, ni journal, ni dossier de région pour
débloquer une commande. Labsence de preuve est un refus, pas une invitation à
reconstruire lhistorique. 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 dun **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 lextension | Quartiers admissibles |
| --- | --- | --- |
| `city_test` | `7890860792428309400` | 0 |
| `city_harbor` | `-1594320136210322067` | 0 |
| `city_gardens` | `6666275329838981856` | 1 |
Seule `city_gardens` a ensuite été activée, à lest, centre **(2032, 624)**.
Sa préparation a terminé **418 chunks FULL**. Linspection 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.
Linspection native des bâtiments contrôle également quaucun 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 deau**, ses marches et son confinement après lexécution dun
tick deau. La réouverture conserve les mêmes 189 cellules et les marches ;
elle nexécute pas un second tick témoin.
Dans le second processus, les **38 pièces natives**, les **5 coffres non
ouverts**, le bloc dor témoin à **(-61, 250, 11)** et le journal sont conservés.
Lempreinte 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 dun **bloc**, dun **coffre avec son entité de bloc**, dune **entité
vivante enregistrée** et dun **tick de bloc programmé**. Une métadonnée NBT
étrangère ajoutée après certification fait également échouer la vérification
globale : lempreinte 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 dor 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
quune 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`** à lintérieur des
extensions. Le bloc dor 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 deau 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, lindex packwiz,
le JAR de production sans classes de test, son inclusion dans le MRpack et le
ZIP damorç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 linstance 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 dor conservé |
| Lumière et biomes après reprise | Lumière native terminée et remplacement du biome vide à lintérieur des extensions | Réussi à 13 h 48 : 154 chunks éclairés correctement, 32 échantillons hors `the_void`, bloc dor 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 deau, 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 nest 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
linstance existante, ses sauvegardes, ses réglages et les mods tiers.
+182
View File
@@ -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 lhydrologie naturelle ne sont
pas remplacées par cette passe urbaine.
Le JAR dessai anticipé du 9 septembre, avant publication de lalpha.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 nest exécutée. Le binaire dessai est
conservé séparément avant synchronisation de linstance.
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 nest 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 ny 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
descalier et des pièces séparées par de vraies portes. Sols, plafonds,
chambres, salles deau, cuisines, réserves, réception, rayonnages, dortoirs,
auditorium et galerie suivent le type du bâtiment. Les détails dusure 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 deau
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 dexpansion ; la machine et ses cartes ne font pas
partie de cette livraison.
Le retour de lessai 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 nest 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 dessai anticipé
Les smokes exécutés le 9 septembre passent avec les classes Minecraft exactes :
les six familles dintérieurs dans quatre orientations contrôlent la collision
des portes ouvertes, le côté des dossiers des sièges et lappui 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 dun continent ; ce nest 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.
Limmeuble 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 lexé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 deau 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 nest 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. Lancien JAR dessai
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).
+144
View File
@@ -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 lalpha.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 nest 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, dune réservation
architecturale locale 49 × 49 : rues et bâtiments reliés au-dessus dun socle
creux, avec appuis de maçonnerie certifiés. Cette réservation peut dégager le
terrain et remplacer leau dans sa seule emprise, avant le placement des pièces.
Elle ne modifie pas la densité de toute l’île et ne sapplique 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 dorientation et dappui.
## 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 dor 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 deau** ; les deux égouts observés déversent de leau au-delà
de leur bouche. Dix-neuf témoins temporaires indépendants prouvent lactivité
effective du moteur des fluides, puis sont supprimés avant sauvegarde. Les
cascades atteignent des corniches : elles ne sont pas toutes libres jusquau
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 lalpha.15
publiée. Les fichiers de génération 15 restent inchangés. Le preset caché
`sanctuary_v15.json` est identique à lancien 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 damorç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 sarrête alors en conservant les équipements déjà certifiés.
Une aile absente ne prouve donc pas labsence 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 lessai 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 dun monde précédent.
| Lieu | Repère X / Y / Z |
| --- | --- |
| Entrée de limmeuble | 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. Lexpansion 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 na é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
loccupation passée par dautres 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 dattribution restent à définir ; aucun réacteur,
nouveau spawner de donjon ou système de progression nest ajouté ici.
+99
View File
@@ -0,0 +1,99 @@
# Alpha.17 — réseaux oubliés, prototype réversible
**État : publié et installé dans la même instance Prism.** Ticket WG-20, branche
`codex/structures-experiment-alpha17`. Minecraft 26.3-pre-2, mêmes dépendances
que lalpha.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
loption 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é à lidentique et caché à la création. Le 17
possède son propre journal ; aucune conversion ni rétrogradation de sauvegarde
nest 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 nest 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 dor témoin sont
conservés après réouverture dans une seconde JVM. Les trappes sont ouvertes pour
vérifier le parcours réel dun 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 laccè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 loption 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`.
Lessai 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 na é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 dor
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 damorçage Prism
sont vérifiés. Journal final : `build/alpha17-final-check-build-pack.log`.
La release et le canal packwiz sont publiés. Deux synchronisations isolées puis
deux dans la même instance Prism passent ; les 767 fichiers personnels et
réglages suivis gardent leurs hashes. Le JAR 16 et le suivi packwiz précédent
sont sauvegardés hors de `mods/`. Voir [le reçu de livraison](packwiz.md).
Aucun essai Windows natif ni revue visuelle dans le client nest revendiqué ;
le prochain essai de Koka validera lambiance.
+173
View File
@@ -0,0 +1,173 @@
# Alpha.19 — anciennes infrastructures de Sanctuary
**État : alpha.19 publiée, même instance Prism synchronisée et vérifiée.** Ticket WG-22, branche
`codex/heritage-worldgen-alpha19`. Minecraft 26.3-pre-2, mêmes dépendances que
lalpha.17. La numérotation 19 est demandée explicitement ; aucune 18 nest
intercalée dans ce dépôt.
## Périmètre et réversibilité
Première passe des quatorze lieux du [catalogue](structures-audit.md) : gare de
marchandises, centre postal, dépôt communal, cartothèque, observatoire,
maintenance ferroviaire, pompage, sous-station, serre, quai dexpédition,
relais radio, club photo, vestiaires/salles étranges et refuge de chantier.
Les plans recherchent des sites admissibles et des voies de fret ou chemins
entre destinations réelles ; ils ne garantissent pas tous les lieux sur chaque
graine. Les mesures et coordonnées des essais figurent ci-dessous.
Créer un **nouveau monde Sanctuary** et utiliser **Personnaliser** :
Petit 512, Moyen 724 par défaut ou Grand 1 024, avec **Structures expérimentales**.
Loption est activée par défaut et enregistrée avec le générateur 19. La désactiver
pour un autre nouveau monde conserve le socle urbain sans les réseaux
expérimentaux 17 et les infrastructures 19. Elle ne retire aucun bloc ni ne change
les règles dune sauvegarde existante.
Les mondes 17 et antérieurs gardent leurs codecs, plans, pièces et futurs chunks.
Le preset 17 public est conservé octet par octet sous un identifiant historique
caché à la création. Le générateur 19 et son journal sont distincts. Il nexiste
aucune conversion ou rétrogradation de sauvegarde 19 vers un ancien binaire.
Lancienne release reste disponible pour ses propres mondes, et loption dun
nouveau monde permet de comparer les deux configurations du nouveau binaire.
Les nouveaux lieux sont réservés à l’île initiale. La ville et les anciens
réseaux servent de base ; leurs protections et celles de lhydrologie restent
respectées. Les liaisons vers de futurs continents seront construites après
leur apparition, sans présumer leurs emplacements dans cette génération.
## Objets et systèmes
Les bâtiments suggèrent leurs usages futurs par leurs pièces et aménagements.
Mailbox, deposit boxes, caméra, réacteur, progression et récompenses
personnelles ne sont pas activés. Chaque nouveau lieu dispose dune table
de coffre propre à la génération 19, avec un outil vanilla lié à son usage et
quelques fournitures. Les coffres sont partagés et ne se renouvellent pas.
La valeur des rails, équipements et blocs récupérables du décor sajoute au
butin. Leur distribution doit être constatée sur les témoins, sans transformer
cette première passe en une garantie d’équilibrage économique pour la bêta.
## Vérifications effectuées
- Placement des quatorze architectures, ouvertures, circulation, orientation
et appuis du mobilier ; accès réel au coffre et retour possible.
- Plans bornés sur les trois tailles, terrain non occupé, supports, continuité
des tracés et distinction entre ballast praticable et rails retirés.
- Pièces natives et références locales, y compris pour les longues voies ;
géométrie, orientation et rails sauvegardés dans un schéma strict.
- Création et réouverture dun monde 19 avec modification témoin ; conservation
des starts, butins différés, accès, routes et supports.
- Option désactivée, paramètres sauvegardés, génération 17 comparée aux preuves
de la version publiée, refus des migrations implicites de journaux.
- `./gradlew check build assemblePack`, artefacts et index, publication immuable
puis canal packwiz et deux synchronisations de la même instance Prism.
Les smokes des quatorze architectures, de leurs quatre orientations, des
plans sur les trois tailles et des pièces natives passent. Le mode désactivé
est vérifié dans un monde 19 neuf : aucun site ni liaison expérimental.
`./gradlew check build assemblePack` passe, avec les huit GameTests généraux,
125 chunks FULL, 5 923 blocs deau conservés et 1 800 ticks de fluides.
Le JAR, le MRpack, les hashes du pack et le ZIP Prism sont vérifiés.
La release et le canal packwiz sont publiés. Deux synchronisations isolées puis
deux dans la même instance Prism passent ; 767 fichiers personnels et réglages
suivis conservent leurs hashes. Le JAR précédent et le suivi packwiz sont
sauvegardés dans `sanctuary-backups/before-0.1.0-alpha.19/`.
Voir [le reçu de distribution](packwiz.md#alpha19-publiée--9-septembre-2026).
Aucun essai visuel dans le client ni essai Windows natif na été effectué.
## Témoin natif Moyen, graine 0
Créer un nouveau monde avec **Structures expérimentales activées**.
`/sanctuary structures` liste les entrées prévues sans charger de chunks ;
`/locate structure sanctuary:heritage_networks` retrouve lentrée dun site.
Le relevé ci-dessous provient des placements et parcours vérifiés par le moteur.
![Entrées et tracés du témoin alpha.19](images/heritage19-seed0.svg)
Treize des quatorze lieux apparaissent. **La cartothèque est absente de cette
graine** : la recherche na pas trouvé demplacement admissible. La ville et les
trois réseaux souterrains 17 sont conservés. Les raccords recherchés vers la
ville et lancienne mine ne sont pas admis sur ce témoin.
Les sept liaisons comprennent un tracé de fret **dépôt → pompage de 499 blocs**,
dune étendue de 245 blocs sur X et 181 sur Z. Son ballast est praticable ;
478 rails sont présents et 21 sont volontairement manquants. Les autres voies
et chemins relient gare, dépôt, atelier, poste, club photo, serre et vestiaires.
Le quai, lobservatoire, le relais, la sous-station et le refuge gardent leurs
propres accès au terrain, même sans liaison avec ce réseau.
La création et la réouverture dans deux JVM distinctes passent chacune les deux
GameTests : **112 pièces natives, 111 chunks FULL, 80 487 blocs contrôlés,
2 882 contrôles de portance, 13 coffres différés et 68 cibles accessibles**.
Trois tirages de chacune des quatorze tables de butin sont vérifiés
séparément, sans ouvrir les coffres persistants. Une modification témoin à
`-204 / 255 / -13` est conservée à la réouverture.
Empreinte stable du plan :
`7f476c2ecca703a05bf88a550c61cc5cbeff2594024c43ccd9f3620bbc2ad3de`.
JVM de création `36366`, de réouverture `36481`. Reçus locaux ignorés :
`build/alpha19-evidence/heritage19-create.json` et `heritage19-reload.json`.
La planification supplémentaire utilise 15 144 colonnes natives, dans un plafond
de 16 000, en 20,7 s sur le Mac de développement. Ce temps concerne ce plan,
pas le chargement total du jeu. Les routes sont découpées par chunk ; aucun
start ne couvre toute leur longueur ni ne charge une destination lointaine.
La prospection 19 consulte les plans hydrologiques de toute lenveloppe de
départ, avant de publier les sites. Le contrôle général utilise donc pour 19
une limite géographique calculée : 5 régions possibles pour Petit, 9 pour
Moyen/Grand, avec les budgets de colonnes et de sondes contrôlés séparément.
Le témoin Moyen en consulte 5 ; les quatre régions périphériques représentent
3,38 s de calcul hydrologique supplémentaire dans le relevé du build.
Ces consultations ne matérialisent aucun chunk. Les générations historiques
conservent le contrôle initial de 4 régions maximum autour du spawn.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryHeritage19Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryHeritage19Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
## Autres tailles et couverture du catalogue
Les deux GameTests natifs passent aussi sur Petit et Grand, graine 42.
Les quatorze architectures ont ainsi chacune été rencontrées et parcourues
sur le terrain réel, en complément des rotations vérifiées par les smokes.
| Taille et graine | Lieux | Liaisons | Pièces natives | Chunks FULL |
| --- | ---: | ---: | ---: | ---: |
| Petit 512, 42 | 10 | 2 | 35 | 44 |
| Moyen 724, 0 | 13 | 7 | 112 | 111 |
| Grand 1 024, 42 | 13 | 4 | 47 | 61 |
Pour visiter la cartothèque : **Petit, graine 42**, entrée
`-101 / 251 / -25`, ou **Grand, graine 42**, entrée `-252 / 249 / 146`.
Le Petit testé na ni poste, ni observatoire, ni atelier ferroviaire, ni quai.
Le Grand testé na pas de quai. Aucun grand axe nest admis sur ces deux témoins :
les portées maximales de leurs liaisons sont respectivement de 32 et 55 blocs.
Le témoin Moyen, graine 0, reste celui conseillé pour découvrir le grand fret.
Ces résultats rendent explicite la variation de cette première passe ; ils ne
constituent pas une garantie de présence ou de connectivité pour chaque graine.
Reçus locaux ignorés : `build/alpha19-evidence/small42-create.json` (JVM 36591)
et `large42-create.json` (JVM 36669).
## Compatibilité 17 vérifiée
Deux JVM de développement distinctes créent puis rouvrent le témoin 17
**Moyen, graine 0**, avec le nouveau binaire. Les deux GameTests passent dans
chaque exécution. Le codec, les starts natifs, les butins différés et les
parcours restent identiques aux preuves de lalpha.17 publiée. Le bloc dor
placé par le test est conservé à la réouverture.
Empreinte du plan 17 :
`7358ecd7841c03be1977a7f45f37c0c3ef83a32dc27e8d3ec4f0d85d6ee5d3ef`.
Les trois starts restent ceux documentés dans [le témoin 17](testing-alpha17.md).
Reçus locaux ignorés : `build/alpha19-evidence/legacy17-create.json` et
`legacy17-reload.json`.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryRuins17Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryRuins17Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Ces commandes visent uniquement le monde de développement ignoré. La première
le recrée ; la seconde conserve ce même monde et lit ses preuves précédentes.
+327
View File
@@ -0,0 +1,327 @@
# Alpha.20 — identité architecturale
**État : alpha.20 publiée, même instance Prism synchronisée et vérifiée.**
Ticket WG-23, branche `codex/architecture-alpha20`.
Minecraft 26.3-pre-2, dépendances identiques à la version 19 publiée.
## Intention
Chaque fonction reçoit une palette et une composition propre : bois clairs et
grandes baies des lieux publics, pierre et tuf des installations techniques,
loggias et retraits des logements. Les détails utilisent les blocs vanilla :
tuf ciselé, briques de tuf ciselées, briques de pierre sculptées, bois et
planches de chêne/bouleau, granit, diorite et andésite. Moulures, liserés,
encadrements, murs bas et corniches utilisent escaliers, dalles et trappes.
Les fondations restent en briques de pierre. Le [catalogue architectural](architecture-alpha20.md)
décrit les formes et les matériaux effectivement construits pour chaque usage.
La passe concerne les quatorze infrastructures, la ville et ses sous-sols,
ainsi que les petits réseaux oubliés. Elle ne déplace pas les sites ni les
routes pour obtenir ses silhouettes. Les plans 19/17/16 servent demprises
certifiées aux nouveaux rendus 20.
## Connexions des blocs
La géométrie complète dune pièce est assemblée avant le découpage par chunk.
Les états des vitres, grilles, clôtures et murets sont calculés à partir des voisins
réellement présents dans ce plan ; les escaliers reçoivent leurs angles.
Cela évite d’écrire des vitres sans raccord malgré leur juxtaposition, ou de
dépendre de lordre de chargement des chunks voisins. Aucun chunk extérieur
nest chargé pour compléter ces états.
## Sauvegardes et réversibilité
Créer un **nouveau monde Sanctuary** pour voir la génération 20. Le bouton
natif **Personnaliser** garde Petit 512, Moyen 724 et Grand 1 024.
**Structures expérimentales** reste activé par défaut, enregistré avec le
monde ; le désactiver dans un autre nouveau monde garde la ville sans les
réseaux expérimentaux. Les détails urbains de la version 20 restent présents.
Le nouveau codec `sanctuary:island_v20`, son journal et ses types de pièces
distinguent les nouveaux rendus des précédents. Les mondes 19 et antérieurs
gardent leurs codecs, leurs pièces et leurs futurs chunks. Aucun monde
personnel nest ouvert ni rénové par la mise à jour. Une sauvegarde 20 ne se
rétrograde pas vers un ancien binaire ; la release 19 reste disponible pour
ses propres mondes.
## Vérification avant livraison
- Parcours des entrées, pièces et coffres, quatre orientations, marches,
supports et limites d’écriture pour chaque type.
- Connexions des vitres et grilles, angles descaliers, comparaison du rendu
complet et du rendu découpé selon plusieurs ordres de chunks.
- Création et réouverture natives, option désactivée, ancien témoin 19.
- `check build assemblePack`, intégrité des artefacts, publication immuable,
deux synchronisations isolées puis deux dans la même instance Prism.
## Reçus natifs : infrastructures et réseaux oubliés
**Création et réouverture réussies**, dans deux JVM distinctes, pour un monde
jetable **Moyen 724, graine 0**, structures et structures expérimentales activées,
codec `sanctuary:island_v20`. Les rapports locaux conservés sont
`build/alpha20-evidence/heritage20-create.json` (processus 39381) et
`build/alpha20-evidence/heritage20-reload.json` (processus 39736).
Les journaux correspondants sont `build/alpha20-heritage-create-2.log` et
`build/alpha20-heritage-reload.log`. Ces fichiers de développement restent
ignorés par Git ; les valeurs utiles sont transcrites ci-dessous.
| Vérification | Création | Réouverture |
| --- | --- | --- |
| Sites expérimentaux naturels | 13 types présents sur 14 | Mêmes 13 sites |
| Structures natives des sites et voies | 85 starts, 112 pièces | Mêmes empreintes NBT et références |
| Positions de blocs contrôlées pour sites et voies | 80 487 | 80 487, hors modification témoin autorisée |
| Chunks `FULL` contrôlés pour sites, voies et réseaux oubliés | 120 | 120 |
| Contrôles de roche porteuse | 2 882 | 2 882 |
| Coffres des sites | 13 butins différés, non ouverts | Tables et graines conservées |
| Réseaux oubliés | 3 réseaux, 15 pièces, 7 639 positions contrôlées | Mêmes plans et structures natives |
| Coffres des réseaux oubliés | 3 butins différés, non ouverts | Tables et graines conservées |
| Parcours depuis les entrées | 68 cibles dans les sites et 49 dans les réseaux oubliés | Parcours revérifiés |
| Modification témoin | Bloc dor placé en `-203 255 -13` | Bloc dor conservé |
La cartothèque dexpédition (`EXPEDITION_OFFICE`) na pas trouvé demplacement
sur cette graine. Elle fait partie du catalogue et de ses smokes, mais ce reçu
ne constitue pas une preuve de sa génération naturelle. Les quatorze tables
de butin sont échantillonnées séparément sans consommer les coffres sauvegardés.
Les portes et trappes ouvertes pendant le parcours retrouvent leur état initial.
Aucune expansion ni sauvegarde personnelle nest ouverte dans ces essais.
Le plan des infrastructures conserve lempreinte
`7f476c2ecca703a05bf88a550c61cc5cbeff2594024c43ccd9f3620bbc2ad3de`
entre création et réouverture. Celui des réseaux oubliés conserve
`7358ecd7841c03be1977a7f45f37c0c3ef83a32dc27e8d3ec4f0d85d6ee5d3ef`.
Les pièces passent aussi un aller-retour NBT exact et refusent un numéro de
schéma inconnu.
### Portée des vérifications de connexion
Les états complets de **594 blocs détaillés** des infrastructures sont comparés
au rendu : 362 vitres ou barreaux, 49 clôtures, 18 murets et 165 escaliers.
Sur ce total, **565** sont aussi confrontés aux règles natives de mise à jour
des voisins (`updateShape`), avec les tags du serveur chargés. **Onze raccords
de vitres ou barreaux traversent une frontière de chunk** et restent connectés.
Les **29 autres blocs détaillés** ont un voisinage extérieur au plan : leur
état sauvegardé est bien comparé au rendu, mais ils sont exclus de cet oracle
natif. Le compteur `externalNeighbors=29` désigne ces 29 blocs contrôlés,
non un nombre de voisins uniques ; aucune stabilité complète vis-à-vis de ce
voisinage extérieur nest revendiquée. Les réseaux oubliés passent pour leur
part **211 comparaisons d’états et 211 vérifications natives** : 6 vitres ou
barreaux, 1 clôture et 204 escaliers. Ces résultats sont identiques à la
réouverture.
### Coordonnées du monde témoin
Créer un nouveau monde **Moyen, graine 0**, avec les deux options de structures
activées. Les coordonnées indiquent les pieds à lentrée, pas le centre dun
toit. `/sanctuary structures` liste les lieux ;
`/locate structure sanctuary:heritage_networks`, recherché depuis `0 250 0`
dans le test, désigne lentrée du refuge de chantier.
| Lieu | Entrée X Y Z |
| --- | --- |
| Gare de fret | `-110 252 -144` |
| Dépôt communal | `-75 254 -184` |
| Atelier ferroviaire | `-83 253 -83` |
| Château deau | `164 252 -3` |
| Serre | `173 252 -42` |
| Observatoire | `51 251 210` |
| Quai sur le vide | `184 181 218` |
| Refuge de chantier | `-10 251 -15` |
| Réseau minier oublié | `78 252 179` |
| Salles étranges oubliées | `-194 254 -22` |
| Réseau technique oublié | `116 253 -157` |
Sept liaisons sont parcourues dans le monde natif. La voie dépôtchâteau deau
compte **499 positions**, 478 rails présents et 21 rails volontairement absents,
avec une emprise de 245 blocs en X et 181 en Z. Le test prouve un parcours à pied,
le ballast et les formes de rails sauvegardées ; il ne prouve pas un trajet
ininterrompu en wagonnet sur ces voies abandonnées.
## Reçu natif : ville, expansion et grands sous-sols
**Création réussie ; réouverture encore en cours.** Le rapport
`build/alpha20-evidence/city20-create.json` (processus 40221), associé au journal
`build/alpha20-city-create.log`, couvre deux villes de génération 20 dans un
monde de développement jetable. L’île initiale est **Moyen 724, graine 0**.
Après trois prospections en lecture seule, une expansion de diamètre **256**
est effectivement préparée : `city16_0_2`, graine `9111000990819250332`, centre
`2000 -608`, climat `lost_city`. Les deux premiers prospects sont restés sans
ville et nont pas été activés. Lidentifiant de test historique ne change pas
la version 20 enregistrée dans cette expansion.
| Mesure | Ville initiale | Ville dexpansion |
| --- | --- | --- |
| Pièces natives | 48 | 23 |
| Chunks `FULL` inspectés autour des districts | 43 | 19 |
| Bâtiments parcourus | Immeuble, hôtel, bibliothèque | Immeuble, hall |
| Cibles intérieures et de toit atteintes | 65 | 46 |
| Entrées reliées par les rues et les porches seuls | 5 | 2 |
| Pièces de rue ou pont traversées | 33 | 17, dont un pont |
| Appuis contrôlés | 71 | 3 |
| Coffres gardant leur butin différé, non ouvert | 3 | 2 |
| Témoins de biome urbain | 38 | 19 |
Les **71 pièces** sont issues des plans naturels des deux îles. Le parcours
urbain ne prend aucun raccourci par le terrain naturel, les intérieurs ou les
toits : il confirme la continuité des entrées par les rues dessinées.
La table native dapparition des monstres est vérifiée comme limitée aux
zombies vanilla ; cela ne mesure pas un nombre de zombies effectivement apparus.
Le pont de lexpansion se traverse de `2000 226 -613` à `2000 224 -597`.
Aucune pile de viaduc nest présente dans ces deux districts.
Vingt-quatre pièces de test supplémentaires couvrent la sauvegarde des six
modèles de bâtiments dans quatre orientations. Ce sont des témoins de
sérialisation, pas vingt-quatre bâtiments naturels. Restaurant et caserne,
ainsi que la piscine extérieure, ne sont pas présents dans ces deux villes ;
leurs smokes ne constituent pas une preuve dapparition naturelle ici.
### Grands équipements du témoin initial
| Repère | Géométrie ou coordonnées constatées |
| --- | --- |
| Entrée de limmeuble | `-30 252 21` |
| Entrée de lhôtel | `-8 252 21` |
| Entrée de la bibliothèque | `-52 252 43` |
| Trappe daccès aux sous-sols, position du bloc | `-19 252 41` |
| Grande piscine, emprise du bâtiment | De `-47 224 -26` à `9 236 4` |
| Volume deau du bassin | 50 × 25 blocs, quatre couches ; de `-44 225 -23` à `5 228 1`, avec les marches dans cette emprise |
| Grande salle sèche, emprise | De `-54 228 8` à `-30 237 32`, niveau dentrée 229 |
| Parking, emprise | De `-8 116 8` à `22 237 32` |
| Huit planchers du parking, niveaux Y | `117, 133, 149, 165, 181, 197, 213, 229` |
| Bouche de sortie de l’égout | `-50 117 20` |
La piscine conserve ses 40 appuis, la grande salle 16, le parking 9 et la
sortie d’égout 6. Ces présences sont constatées sur cette graine ; elles ne
sont pas garanties sur toutes les îles.
### Fluides et connexions en création
Lessai observe **120 ticks du monde** après la mise en activité des chunks.
Dix-neuf petits témoins temporaires fermés, placés dans le ciel, démontrent
lexécution réelle des fluides par une descente deau observée dans chaque
chunk concerné. Ils sont retirés avant la sauvegarde et exclus des quantités
deau des structures.
Après cette simulation, le grand bassin garde **4 976 cellules deau** dans
son enceinte, et chaque égout conserve ses cinq cellules contrôlées. Les deux
bouches gardent leur source et produisent de leau à lextérieur. Leur écoulement
rencontre cependant du terrain : premier bloc solide sous les sorties aux
niveaux **113** et **191**. Ce témoin prouve des sorties deau actives, pas une
chute libre continue jusquau vide.
Les chiffres de connexion suivants sont ceux de linspection initiale des
pièces, avant la simulation des fluides. Le contrôle de réouverture ci-dessous complète ces premières mesures.
| États détaillés | Ville initiale | Expansion | Total |
| --- | --- | --- | --- |
| Vitres ou barreaux | 1 457 | 577 | 2 034 |
| Clôtures | 36 | 14 | 50 |
| Escaliers | 1 451 | 699 | 2 150 |
| Comparaisons complètes au rendu | 2 944 | 1 290 | 4 234 |
| Vérifications supplémentaires par règles natives | 2 277 | 1 276 | 3 553 |
| Blocs exclus de cet oracle pour voisinage extérieur | 667 | 14 | 681 |
| Raccords de vitres/barreaux à travers des frontières de chunks | 82 | 22 | 104 |
Les **681 exclusions** gardent leur comparaison d’état au rendu. Elles ne
prouvent pas la stabilité de la connexion face au voisinage extérieur, comme
pour les infrastructures. Aucun muret nest représenté dans cette partie du
contrôle urbain. Le bloc dor témoin est placé en `-38 252 21` et le journal
est sauvegardé avec lempreinte
`e776dcab879458b3268ad34f44a003cb53b8b03cfcb67378559d40732f8ce73f` ;
ils sont conservés à la réouverture.
### Réouverture de la ville et raccords avec le terrain
La réouverture passe dans une seconde JVM (40687), avec **71 pièces natives**,
**5 coffres différés**, **74 appuis**, **111 cibles accessibles**, le bloc dor et
le journal conservés. Reçu : `build/alpha20-evidence/city20-reload.json` ; journal
`build/alpha20-city-reload-2.log`. La piscine et les sorties deau sont revérifiées.
Le test confronte les raccords au voisinage réellement présent. Au coin du
parking `-8 118 8`, les grilles touchent de la deepslate au nord et à louest :
Minecraft ajoute légitimement ces raccords après une mise à jour de blocs.
Le contrôle refuse tout changement de matériau, orientation, demi-bloc ou
waterlogging ; seules les propriétés de raccord peuvent suivre les règles
natives. À lintérieur dune géométrie connue, l’état du rendu reste exigé.
Sur les **4 234 blocs détaillés**, **4 178** sont alors des états stables selon
loracle Minecraft ; **640** de ces états ont un voisinage extérieur ou un
voisin ayant lui-même évolué. **56** autres gardent exactement leur état de
plan et attendent une mise à jour native extérieure : ils sont comptés
séparément, sans revendiquer leur stabilisation. Les **104 raccords entre
chunks** restent présents. `externalNeighbors=696` compte les blocs dont le
voisinage nest plus entièrement identique au plan, dont les 640 + 56 cas ;
ce compteur a donc une portée plus large que les 681 exclusions initiales.
## Compatibilité avec la version 19 publiée
Avec le binaire 20, les deux rapports natifs 19 sont **identiques intégralement**
aux reçus publiés, en retirant seulement le PID et le chemin du monde de test.
La comparaison porte sur plans, pièces, paramètres, parcours, supports, butins
et modification témoin, à la création comme à la réouverture.
- Création canonique SHA-256 : `3b055a3bc0fc2ddf4146aef035f5103bb7fe20d55a86fa1d4b87aeedc02f7879`.
- Réouverture canonique SHA-256 : `aed0962f8dfbfbbf69da1b2153861801c58a3ea2376e6bbec2ef44b90d555d63`.
- Référence source publiée : `c23e72b8e6fd8308855459f26c08082a68e81b61`.
- Reçus : `build/alpha20-evidence/legacy19-create.json` et `legacy19-reload.json`.
Les renderers, modèles et pièces historiques restent inchangés ; le preset 19
caché est identique octet par octet au preset public de la version publiée.
Les journaux refusent les chargements croisés 19/20 au lieu de convertir un monde.
## Option désactivée et petite île
Le scénario **Moyen, graine 0, option désactivée** passe : plan expérimental
vide, aucun start natif expérimental et aucune entrée localisable. Le paramètre
est enregistré. Reçu : `build/alpha20-evidence/experimental-off.json`.
Le témoin **Petit 512, graine 42, option activée** passe avec dix types de lieux,
35 pièces de sites/voies et 50 chunks `FULL` contrôlés, réseaux oubliés compris.
La cartothèque est présente, entrée **`-101 251 -25`**, et ses cinq cibles sont
accessibles. Les **436 états détaillés** des infrastructures passent loracle
natif, dont un avec voisin extérieur ; vingt raccords traversent des frontières
de chunks. Reçu : `build/alpha20-evidence/small42-create.json`.
Les deux témoins 20 réunis rencontrent et parcourent les **quatorze types
dinfrastructures**. Ils ne garantissent pas leur présence sur toutes les îles.
La génération native 20 Grand nest pas rejouée ici : ses réglages sont couverts
par les contrôles de codec et les plans de placement conservés. Les six modèles
urbains sont vérifiés dans les quatre orientations ; leur apparition naturelle
est distinguée des fixtures ci-dessus.
## Prévisualisation et état de livraison
Le [montage du catalogue](architecture-alpha20.md) provient des exports de
blocs des renderers et des modèles Minecraft. Il permet de comparer les
silhouettes, matières, escaliers, vitres et corniches. Il ne montre ni le
terrain final ni le rendu dun client avec ses shaders ; ce nest pas une
capture de partie et il ne remplace pas les essais de circulation ou de lumière.
| Étape | État de ce reçu |
| --- | --- |
| Infrastructures et petits réseaux : création/réouverture natives | Réussi, dans le périmètre détaillé ci-dessus |
| Ville 20, espaces publics et grands sous-sols : essais natifs | Création et réouverture réussies, 71 pièces et 5 coffres conservés |
| Compatibilité avec le témoin 19 publié | Rapports complets identiques à la création et à la réouverture |
| Autres configurations | Petit 512/graine 42 et option désactivée passent ; Grand 20 non rejoué nativement |
| `check build assemblePack` final et intégrité des artefacts | Réussi ; huit GameTests généraux, JAR/MRpack/ZIP et hashes vérifiés |
| Publication, canal packwiz et synchronisations Prism | Réussi ; deux passages isolés puis deux dans la même instance, 925 fichiers personnels et réglages préservés |
`./gradlew check build assemblePack --max-workers=1` passe en 4 min 39 s sur
la machine de développement : 62 tâches, dont 55 exécutées, huit GameTests,
125 chunks `FULL`, 5 923 blocs deau conservés et 1 800 ticks de fluides.
Les tests des sept nouveaux composants 20 et des anciennes générations passent.
Les réglages des trois tailles et leur sauvegarde passent également.
Journal : `build/alpha20-check-build-pack.log`.
Le JAR de production ne contient aucune classe de test. Son identité est
vérifiée dans le MRpack et le pack local ; les index et le bootstrap officiel
Prism sont contrôlés. SHA-256 du JAR :
`9ea10576e641d83c13e1277960a375448a22d416a98a6b327c4d6d2a204c423a`.
Reçu : `build/alpha20-artifact-validation.log`.
Release immuable depuis `0edac29`, canal packwiz `4e0967d` et deux
synchronisations isolées vérifiés. Les deux passages dans la même instance
Prism conservent les hashes de 925 fichiers personnels et réglages. Un seul
JAR20 est actif ; JAR19 et suivi packwiz sont sauvegardés avant remplacement.
Voir le [reçu de distribution](packwiz.md#alpha20-publiée--10-septembre-2026).
Aucun essai client visuel ni essai Windows natif na été effectué.
+140
View File
@@ -0,0 +1,140 @@
# Alpha.21 — contrat et vérification
Ticket WG-24, branche `codex/palettes-reseaux-alpha21`.
Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3,
Java 25. Les dépendances restent celles de lalpha.20.
## Résultat recherché
La ville reprend une palette de pierre lisse, pierre, briques de pierre,
andésite et chêne. Les plans intérieurs sont conservés ; les corrections
portent sur les matériaux, les appuis et les couvertures. Chaque infrastructure
emploie un bois cohérent. Le tuf reste surtout au sol et dans les sous-sols.
La ville peut accueillir deux petits quartiers supplémentaires reliés à son
cœur, avec du terrain naturel entre eux. La réserve centrale existante nest
pas agrandie. Les infrastructures reçoivent davantage de chemins entre des
entrées réelles. Les recherches respectent des budgets de colonnes et refusent
un quartier si sa liaison entière ne peut pas être construite.
Les galeries industrielles expérimentales ont cinq blocs de chaussée et un
trottoir de chaque côté, des salles de maintenance et deux accès par escaliers.
Le plan est recherché après les structures précédentes et évite leurs volumes,
lhydrologie. Un tablier continu peut franchir une cavité entre ses appuis ;
les travées sans appuis complets sont limitées à 34 blocs, avec des piliers
locaux dau plus 16 blocs et deux couches de roche intacte sous les appuis. Chaque tronçon a une pièce native
locale ; aucun démarrage de structure ne charge toute l’île.
## Sauvegardes et comparaison
Créer un **nouveau monde Sanctuary**, taille **Moyen**, graine **0**, avec
**Structures expérimentales** activées. Petit 512, Moyen 724 et Grand 1024 restent
les tailles proposées par le bouton natif **Personnaliser**.
Le nouveau codec est `sanctuary:island_v21`. Les codecs et pièces 20 et
antérieurs restent disponibles, sans modifier leurs rendus. Le journal 21
réutilise le schéma de persistance existant avec une génération distincte ;
les ouvertures entre versions incompatibles sont refusées. Aucune conversion
ni rénovation automatique des mondes précédents nest effectuée.
Pour comparer sans les infrastructures, créer un autre monde avec loption
expérimentale désactivée. Une sauvegarde 21 nécessite le binaire 21 ou un futur
binaire compatible ; elle ne se rétrograde pas en remplaçant simplement le JAR.
## Compatibilité vérifiée
373 fichiers de production historiques sont identiques au commit de publication
20 (`0edac29`). Le preset caché `sanctuary_v20` est une copie exacte de son
ancien preset public. Création et réouverture natives de la génération 20 donnent
les mêmes plans, structures, appuis, accès et coffres que les rapports publiés.
La comparaison ignore seulement lidentifiant du processus, le chemin du monde
et deux nouveaux compteurs de détail ajoutés à loracle de connexion des tests.
## Galeries vérifiées dans Minecraft
Moyen, graine 0 : 23 modules dont deux accès et une salle, sur un axe de 340 blocs.
Les entrées sont `-135 253 -178` et `-135 249 162` ; chaussée à Y 216. La portée
sans appuis complets la plus longue du témoin est 17 blocs. Le plan emploie
8 686 colonnes de bruit, avec un budget maximal de 12 000 ; cette recherche prend
environ 11 secondes sur la machine de développement, hors reste du monde.
La création et la réouverture dans deux JVM distinctes passent : 34 chunks
FULL, 57 635 blocs vérifiés, mêmes pièces natives, mêmes coffres différés et
modification témoin conservée. Loracle de collision de Minecraft vérifie un
corps de 0,6×1,8 bloc, 74 cibles et 520 pas entre les deux portiques ; il utilise
les vrais escaliers et les demi-marches des trottoirs.
Une alcôve dexcavation inachevée ouvre le parement de la salle sur une paroi
irrégulière de tuf, pierre et deepslate. Certains tronçons exposent de petits
filons ordinaires. Le témoin contient cinq minerais de charbon, dix-neuf de
fer, cinq de redstone, un de diamant et un d’émeraude ajoutés dans ces fronts.
Cuivre, or et lapis font aussi partie des variantes possibles. Ce sont des
blocs finis, sans mécanisme de réapparition après minage. Ces quantités excluent
les minerais naturels de l’île et ne constituent pas une garantie par graine.
## Autres vérifications
La création et la réouverture natives de la ville passent. Le témoin possède
un cœur et deux quartiers reliés, 65 pièces de rue, trois immeubles de logements,
un hôtel, une bibliothèque, un jardin et une place. La réserve centrale nest
pas étendue. Les deux nouveaux chemins sont longs de 51 et 38 cellules. Une
expansion de test distincte est également vérifiée ; aucune expansion personnelle
nest activée.
La piscine couverte, le garage, la salle dexpansion, leurs accès et les égouts
restent présents. Les contrôles natifs vérifient les bassins et sorties après
exécution observée de la simulation des fluides.
Les quatorze types de lieux patrimoniaux apparaissent sur Moyen, graine 0,
avec douze liaisons et 127 segments. Leur création native passe : 141 pièces,
91 271 blocs, 140 chunks FULL et quatorze coffres différés. Les trois réseaux
oubliés sont également parcourus. Les chemins rejoignent deux entrées de ville.
Lobservatoire et le quai du vide restent isolés sur ce témoin : leurs chemins
nont pas trouvé de tracé admissible dans le budget. La commande `/sanctuary structures` indique leurs entrées ; aucun raccord
construit complet nest revendiqué.
Les smokes des renderers, plans, pièces enregistrées et raccords passent.
La réouverture des lieux patrimoniaux conserve également le plan, les pièces,
les coffres et la modification témoin. Loption expérimentale désactivée passe :
elle ne publie aucun module de galerie. `check build assemblePack` passe, avec les huit GameTests généraux. Le
MRpack, son index et le JAR embarqué sont vérifiés. La release et le canal
packwiz sont publiés. Deux synchronisations isolées puis deux dans la même
instance Prism conservent les 925 fichiers personnels et réglages suivis.
Voir le [reçu de distribution](packwiz.md#alpha21-publiée--10-septembre-2026).
Les chemins de quartier regroupent leurs pentes à lintérieur de rectangles ;
leurs frontières et raccords sont des paliers plats. Les structures restent
conditionnées par le terrain admissible, dans un budget borné. La traversée
industrielle ne relie pas encore tous les réseaux souterrains historiques.
Les vues de modèles éventuelles ne remplacent pas une inspection dans le client.
Aucune validation Windows native ni charge multijoueur nest revendiquée.
## Reproduire les contrôles
Depuis ce dépôt, avec Java 25, les mondes de test restent sous `mods/sanctuary/build/`.
Pour chaque scénario, exécuter la création puis sa réouverture avant tout autre
scénario qui nettoie ce monde de développement :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryTransit21Tests=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --max-workers=1
./gradlew :sanctuary:runGameTest -PsanctuaryTransit21Tests=true -PsanctuaryExpansionReload=true -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --max-workers=1
```
Remplacer `sanctuaryTransit21Tests` par `sanctuaryLostCity21Tests` pour la ville
ou `sanctuaryHeritage21Tests` pour les infrastructures. Le cas OFF ajoute
`-PsanctuaryExperimentalStructures=false` à une création du scénario transit.
Les rapports de cette livraison sont conservés localement dans
`build/alpha21-evidence/`. `./gradlew check build assemblePack` constitue la
vérification complète avant export.
## Artefacts vérifiés
MRpack : `Sanctuary-0.1.0-alpha.21.mrpack`. Son JAR est identique au JAR du build.
Les versions et hashes de lindex packwiz sont contrôlés, ainsi que le ZIP
damorçage Prism. SHA-256 du JAR :
```text
e528e42d99f9e1ea221f780ea089b49b42a1a4e9e24e4ddd001d045183202c21
```
+153
View File
@@ -0,0 +1,153 @@
# Alpha.22 — placement sélectif
Ticket WG-25, branche `codex/placement-selectif-alpha22`.
Minecraft 26.3-pre-2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.3 et Java25,
sans changement de dépendances.
## Contrat
Les bâtiments de surface redeviennent sélectifs : une ville peut être absente
si aucun emplacement nest adéquat. Deux éléments de base sont prioritaires :
**la grande traversée souterraine et la salle dexpansion**, même sans ville.
Leurs emplacements sont réservés avant les petits réseaux et le catalogue
expérimental. Une recherche bornée ne prouve pas quaucun autre emplacement
possible nexiste dans toute l’île.
- Ville : aucune réserve artificielle de secours, aucun pilier de rattrapage,
terrassement/remblai limité à deux blocs par colonne. Les fondations sont
entièrement recertifiées dans la roche. Les quartiers supplémentaires exigent
un chemin complet vers les quartiers retenus. Au plus trois propositions dans
un budget partagé de20 000 colonnes et1 120 000 contrôles de volume.
- Infrastructures : relief maximal de quatre blocs sur lemprise17×17, coupe et
remblai limités à deux blocs, deux couches de roche intacte sous chaque colonne
de fondation, sans piliers de secours. Jusqu’à24 candidats par famille, avec
les mêmes plafonds globaux de16 000 colonnes et800 000 sondages.
- Chemins patrimoniaux : coupe/remblai limité à un bloc, accès indépendant et
raccord complet requis pour publier une liaison.
- Traversée : conserve le génie civil21, avec deux accès réels, une chaussée
de cinq blocs et deux trottoirs. Les piliers sont bornés à16 blocs ; les
travées sans grille complète sont limitées à34 blocs et reposent sur les
modules adjacents. La recherche22 intervient avant les structures facultatives
pour leur réserver lemprise. Si les lignes droites sont refusées, une seconde
passe cherche des accès différents et des détours. Chaque passe est limitée à
12 000 colonnes et3 millions de sondages (24 000 et6 millions au total au plus).
Ses parois peuvent traverser des cavités existantes sans écraser les volumes
deau et de lave protégés.
- Salle : recherche autonome dun accès de surface et dune salle25×25, sans
faux quartier ni socle de ville. Une salle déjà présente sous une ville évite
un doublon. Les égouts et annexes peuvent accompagner ce lieu indépendant.
Les palettes, les plans intérieurs et les petits filons minables restent ceux
de21. Les anciens petits réseaux expérimentaux17 et les sous-sols des villes gardent
leur géométrie ; leurs protections restent respectées.
Le nouveau principe narratif est documenté dans [la vision](vision.md) et
[laudit](structures-audit.md), sans activer de système de progression.
## Sauvegardes
Le seul preset public utilise désormais `sanctuary:island_v22`. Le preset caché
`sanctuary_v21` est la copie de lancien preset public. Les anciens codecs,
planificateurs et rendus restent disponibles. Les pièces21 sont réutilisées
avec leur format existant ; les nouveaux coudes possèdent une pièce22 distincte
pour fermer leurs murs sans modifier les jonctions21 sauvegardées.
La salle indépendante possède un type de structure22
et réutilise les pièces souterraines20.
Le journal identifie la génération22 et refuse une lecture croisée avec21.
Aucun monde personnel nest ouvert, converti ou régénéré.
Les nouvelles règles nécessitent un **nouveau monde Sanctuary**. Petit512,
Moyen724 par défaut et Grand1024 restent proposés. Loption **Structures
expérimentales** reste enregistrée à la création ; en22 elle retire le catalogue
facultatif, tandis que la traversée et la salle restent des éléments de base.
Le commutateur Minecraft général des structures demeure respecté.
## Témoin natif Moyen, graine0
La création et la réouverture dans deux JVM distinctes passent. La ville est
refusée ; la traversée comporte23 modules, dont deux accès et une salle de
maintenance. Son axe s’étend sur340 blocs. Les accès sont `-170 253 -100` et
`170 254 -100`. Le hall dexpansion indépendant se rejoint par la trappe
`2 252 0` ; son réseau comprend laccès, la salle, deux passages et un égout.
La piscine est une annexe conditionnelle, absente sur ce témoin.
Les14 bâtiments patrimoniaux trouvent un terrain adapté ; sept liaisons sont
retenues. Les115 chunks de lemprise sont générés en statut FULL. Les plans,
pièces natives et une modification témoin persistent après réouverture. Le
contrôle natif de circulation vérifie la vraie trappe, les échelles soutenues,
les ouvertures et le trajet jusquau socle central. Aucun déplacement de joueur
ou chargement dun monde personnel na été utilisé pour ces essais.
## Vérifications complémentaires
**Petit512, graine42 :**17 modules, dont un coude sauvegardé avec sa pièce22,
et une salle indépendante. Les anciens alignements droits sont refusés ; le
repli trouve une paire daccès naturels et conserve une chaussée continue.
Les portiques sont `-68 250 -93` et `68 250 43`, la trappe du hall `43 244 -10`.
La marche native relie les sorties en564 déplacements et atteint53 points de
contrôle. Huit bâtiments patrimoniaux et six liaisons sont admis ; aucune ville
nest forcée. Les88 chunks de lemprise sont vérifiés en statut FULL.
**Grand1024, graine42 :** un quartier naturel est admis avec son réseau de
sous-sols : salle dexpansion, piscine, égout et parking. Aucun hall autonome
supplémentaire nest créé. La traversée comporte31 modules et s’étend sur476
blocs ; portiques `-272 249 -8` et `204 250 -8`. La marche native les relie
en740 déplacements et atteint98 points de contrôle. Quatorze bâtiments
patrimoniaux et huit liaisons sont admis. Les179 chunks de lemprise sont
vérifiés en statut FULL ; la salle et ses17 ouvertures sont accessibles.
Les tests purs des trois tailles passent sur des terrains plats adaptés ;
falaises, eau/protections, vides et appuis insuffisants sont refusés pour les
bâtiments. Le hall autonome et ses annexes sont vérifiés sans fixture de ville.
Les suites historiques restent actives. Avec loption expérimentale désactivée,
le témoin Moyen conserve ses23 modules de traversée et sa salle indépendante,
sans bâtiments patrimoniaux ni petits réseaux expérimentaux. La marche native
relie les deux sorties en548 déplacements et atteint74 points de contrôle ;
les échelles et laccès au hall restent praticables.
Les créations et réouvertures de génération21 sont identiques aux rapports de
la release21, hors identifiants de processus et dossier de test. Les391 fichiers
de production historiques qui ne nécessitent pas de raccord22 restent identiques
octet pour octet ; le preset caché21 est la copie exacte du précédent preset
public. Les masques sont vérifiés à lintérieur et hors des structures.
`check build assemblePack` passe :75 tâches, dont les huit GameTests généraux.
Le JAR, lindex packwiz, le MRpack et le ZIP Prism sont vérifiés. Aucun code de
test nest embarqué dans le JAR. SHA-256 du JAR :
```text
10d7ef32ac2e81a977ea4854503bafa29e0bc10929984b76df01d6f5f61f9056
```
SHA-256 du MRpack :
```text
0b553fcdd8b6a2ec26f72059e428d2cc0ce526f36ef8580dd3e50c1dfa40ec78
```
Les reçus de publication et de synchronisation sont suivis dans
[la distribution](packwiz.md).
Ces témoins vérifient les trois tailles, avec et sans ville ; ils ne constituent
pas une preuve de présence pour toutes les graines. La recherche reste bornée
et refuse un volume protégé ou insuffisamment porté. Aucun essai visuel client
ou essai Windows natif nest revendiqué.
## Reproduire dans le dossier de développement
Avec Java25, création du témoin Moyen puis réouverture du même monde de test :
```sh
./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuarySelective22Tests=true -PsanctuaryDensityMaps=false -PsanctuaryExpansionReload=true
```
Pour une nouvelle création Petit ou Grand, ajouter `-PsanctuaryTestSeed=42`
et `-PsanctuaryTestDiameter=512` ou `1024`. Le cas OFF utilise
`-PsanctuaryExperimentalStructures=false`. Chaque réouverture doit suivre la
création correspondante, avec les mêmes paramètres. Les rapports sont dans
`mods/sanctuary/build/run/gameTest/diagnostics/selective22-<graine>-<diamètre>/`.
Le témoin historique utilise `-PsanctuaryHeritage21Tests=true`, puis le même
drapeau avec `-PsanctuaryExpansionReload=true`. La vérification de livraison
reste `./gradlew check build assemblePack`. Exécuter ces JVM séquentiellement.
+258 -1
View File
@@ -19,6 +19,262 @@ Pour générer de vrais chunks et vérifier le spawn sur d'autres graines :
La propriété ne s'applique qu'au mod de test, jamais au mod distribué ni à une
sauvegarde de joueur. Chaque commande repart d'un monde de développement neuf.
## Correctif alpha.13.1 — journal dexpansion sous Windows
Le 9 septembre 2026, le contrôle ciblé suivant passe sur macOS et Java 25 :
```sh
./gradlew :sanctuary:expansionJournalSmoke --console=plain
```
Le journal de cette exécution est `build/alpha131-journal-smoke.log` :
`BUILD SUCCESSFUL`, en 7 secondes. Le test écrit et relit de vrais fichiers
temporaires ; il nouvre aucune sauvegarde Minecraft personnelle.
Les contrôles existants de persistance, de corruption, dhistorique immuable
et de compatibilité des racines restent actifs. Trois scénarios couvrent le
correctif :
- La politique Windows, sélectionnée par le séparateur `\`, permet la création
du journal, une réservation, sa finalisation et leurs rechargements. Le
callback de synchronisation du dossier, qui lèverait `AccessDeniedException`,
nest jamais appelé. Les écritures, remplacements atomiques et relectures du
fichier ont réellement lieu sur le système de fichiers du Mac.
- La politique Unix conserve une erreur `AccessDeniedException` injectée après
le remplacement atomique. La session devient indisponible, garde son ancien
état en mémoire et refuse toute nouvelle écriture. Le journal sur disque
contient la réservation ; une nouvelle instance la relit et peut la terminer.
- Une corruption injectée après le remplacement mais avant la relecture
empêche la publication du nouveau plan et conserve le fichier endommagé.
Le rechargement refuse ce journal au lieu de créer un historique vide.
**La branche Windows est simulée ; aucun processus Java ou Minecraft Windows
natif na encore été exécuté pour cette validation.**
`check build assemblePack` passe sur les sources alpha.13.1, en 1 min 27 s,
avec les huit GameTests du monde Moyen de graine 0. Les scénarios dexpansion
passent ensuite dans deux processus Minecraft distincts, sur la graine 42 et
le diamètre 724 : création (1 min 32 s), puis réouverture et nouvelle expansion
(26 s). Deux tests requis passent à chaque étape. Commandes de reproduction :
```sh
./gradlew check build assemblePack -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false --console=plain
```
Lancer les deux scénarios dexpansion consécutivement ; le second réutilise
uniquement leur monde de développement. Comme pour lalpha.13, le témoin de
camp naturel est facultatif et absent après recherche complète sur cette
graine ; aucune nouvelle preuve de structure nest revendiquée.
Les [résultats alpha.13.1](benchmarks/alpha131-journal.json) conservent les
hashes des journaux de test, les identifiants des deux processus et les régions.
La comparaison des JAR confirme que seuls le journal, sa classe de stratégie
et la version changent ; le switch synthétique ne change que ses numéros de
lignes. Toutes les autres classes et ressources restent identiques.
Les résultats des trois tailles et de linterface ci-dessous restent ceux de
la livraison alpha.13 ; ils nont pas été réexécutés pour ce correctif de stockage.
La cause est établie par les sources de
[WindowsChannelFactory dans OpenJDK 25](https://github.com/openjdk/jdk/blob/jdk-25-ga/src/java.base/windows/classes/sun/nio/fs/WindowsChannelFactory.java#L206-L272) :
`FileChannel.open` najoute pas le drapeau `FILE_FLAG_BACKUP_SEMANTICS`, exigé
pour ouvrir un répertoire selon la
[documentation Microsoft de CreateFile](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew#directories).
Seule cette synchronisation du dossier est omise sous Windows ; les exceptions
d’écriture, de remplacement ou de relecture restent bloquantes.
Le fichier temporaire est toujours synchronisé par `force(true)`, puis remplacé
atomiquement et vérifié. Cette séquence ne garantit pas sous Windows la même
durabilité du renommage en cas de coupure secteur quune synchronisation du
répertoire sous Unix :
[OpenJDK utilise MoveFileEx sans WRITE_THROUGH](https://github.com/openjdk/jdk/blob/jdk-25-ga/src/java.base/windows/classes/sun/nio/fs/WindowsFileCopy.java#L279-L294),
et le contrat de
[FileChannel.force](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/channels/FileChannel.html#force(boolean))
porte sur les modifications du fichier. Aucun changement de format, de graine,
de générateur ou de réservation enregistrée nest introduit.
## Alpha.13 — génération unifiée
`check build assemblePack` passe, y compris le monde Moyen de graine 0 et les
smokes. Les preuves exportées, journaux, paramètres, mesures et hashes figurent
dans [le rapport alpha.13](benchmarks/alpha13-worldgen.json).
Le test courant charge `sanctuary:sanctuary`, puis règle son vrai générateur
`sanctuary:island_v13` comme le fait la personnalisation. Les suites historiques
restent accessibles avec `-PsanctuaryUnifiedTests=false`.
Les trois tailles passent les huit tests moteur sur la graine **42**, avec
1 800 ticks de fluides. Chaque inspection des cavités utilise au plus 27 chunks
FULL, choisis sur des sols et plafonds naturels ; les contrôles dhydrologie
emploient un autre échantillon borné. Ces volumes ne sont pas un inventaire de
l’île et ne mesurent pas le rendu du client.
| Taille | Chunks FULL du diagnostic deau | Blocs deau retenus après 1 800 ticks | Pilier de spéléothèmes dans lair naturel |
| --- | ---: | ---: | ---: |
| Petit, 512 | 116 | 4 722 | 25 blocs |
| Moyen, 724 | 80 | 1 196 | 18 blocs |
| Grand, 1 024 | 136 | 1 434 | 18 blocs |
Les trois cavités natives sont réellement décorées dans chacun de ces mondes :
mousse et lianes récoltables pour le luxuriant, pointes et piliers pour les
spéléothèmes, roche soufrée et pointes pour le soufre. Musiques, brouillard et
table du `sulfur_cube` restent ceux des biomes Minecraft. La graine 0 présente
seulement du luxuriant dans le raster naturel inspecté ; elle ne justifie pas
de forcer les deux autres biomes dans la roche. Une livraison exige les preuves
cumulées des trois types, pas leur présence sur toute graine.
Le garde-fou est exercé par une vraie écriture `WorldGenRegion` : eau, lit,
parois naturelles, sources et sorties sont protégés. Quatre témoins de berge
appartiennent à un chunk voisin de leur cellule deau. Une écriture hors volume
reste possible. Le test utilise une vue modifiable de chunk dédiée, car les
holders FULL exposent normalement une vue de génération en lecture seule.
Les blocs témoins temporaires sont restaurés dans le monde de développement.
Lessai client ouvre le bouton natif **Personnaliser**, parcourt les trois
tailles et vérifie Terminé, Annuler, Échap, réouverture et rafraîchissement des
réglages. L’écriture/relecture réelle des paramètres conserve la taille, la
graine, les options, le Nether et lEnd. Les anciens générateurs ne deviennent
pas personnalisables par erreur ; l’éditeur Superflat vanilla reste disponible.
Une capture confirme la lisibilité de l’écran. Cet essai ne constitue pas une
visite graphique de l’île.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=512 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryTestDiameter=1024 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true
```
Les diagnostics détaillés restent dans le dossier ignoré
`mods/sanctuary/build/run/gameTest/diagnostics/unified-<diamètre>-seed-<graine>-*.json`.
Les anciennes mesures alpha.11 ci-dessous ne sont pas des mesures alpha.13.
### Mesure alpha.13 de Moyen
Sur le Mac M1 à 8 Go, Java 25.0.3 et heap de 2 Gio, graine 0, un seul processus
de jeu actif : la création initiale prend **17,65 s**, puis 289 chunks FULL autour
du spawn **46,01 s**, soit **63,66 s** cumulées. Le couloir suivant de 80 chunks
FULL prend **7,82 s**. Le maximum observé pour une requête de chunk est de 1,30 s
autour du spawn et de 0,79 s dans ce couloir.
Le protocole conserve les anneaux 17 × 17 et le couloir est de lalpha.11.
Lancien Grand et le nouveau Moyen ont le même diamètre nominal de 724 blocs,
mais la décoration et le spawn peuvent différer : ce nest pas une mesure
d’équivalence de génération. Le résultat de départ reste proche des 62,67 s
mesurées pour lalpha.11. Un seul passage par version, sans mesure du GPU,
des FPS, du réseau, de la disponibilité des chunks pour les ticks ou dune
charge multijoueur. Grand na pas de benchmark complet 289 + 80 dans cette
livraison ; ses tests moteur ci-dessus ont une autre charge de travail.
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryBenchmark=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=0 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Rapport local : `diagnostics/generation-performance-unified-724-seed-0.json`,
log `build/alpha13-benchmark-medium.log`.
### Expansions et rechargement alpha.13
Sur le monde unifié Moyen de graine 42, les deux processus réussissent :
refus des huit premiers sites occupés, tropicale plus loin au nord, branche
aride à au moins 2 048 blocs de son parent, relecture du journal et des blocs
témoins, puis nouvelle expansion après redémarrage. Les colonnes de terrain
et les biomes enregistrés restent identiques après décodage du générateur.
Le framework reconstruit les paramètres actifs depuis le preset ; le test
vérifie séparément le codec sauvegardé et les fichiers réellement rechargés.
Aucun camp admissible na été observé sur lorigine unifiée des graines 42 et
8 675 309 : les fondations naturelles ou la protection hydrologique écartent
les candidats. Les essais stricts y échouent donc sur la preuve de structure.
La paire unifiée validée utilise explicitement un témoin de structure
facultatif, seulement après inspection complète des candidats. Ses rapports
indiquent `native_structure_observed=false` et ne constituent aucune preuve
positive de placement ou de butin dans lorigine alpha.13. Les critères de
fondation et la génération ne sont pas assouplis pour faire passer un test.
Exécuter les deux commandes sans lancer un autre GameTest entre elles :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryOptionalStructureWitness=true -PsanctuaryTestDiameter=724 -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Sans ce drapeau explicite, le scénario exige toujours une vraie structure avec
son conteneur et sa table de butin native. La réouverture vérifie toujours ces
trois éléments lorsquils ont été observés dans le premier processus.
La paire historique, rejouée avec le binaire alpha.13, réussit sur la graine
42 sans rendre le témoin facultatif : camp abandonné de forêt, bloc du
conteneur et table de butin native retrouvés après redémarrage. Cela valide
la chaîne génération 12 conservée ; ce résultat ne vaut pas comme preuve
dun camp dans lorigine unifiée. Le smoke du journal teste aussi une origine
13 de diamètre 1 024 avec un enfant 12, le schéma 1 et le refus dune lecture
avec une autre génération ou taille, sans modifier les octets enregistrés.
## Correctif alpha.12.1 — tous les premiers emplacements occupés
Le scénario laboratoire utilise les mêmes commandes Gradle ci-dessous. Il
prépare maintenant un vrai chunk FULL vide sur chacun des huit premiers sites,
constate leur occupation puis ouvre une tropicale plus au nord. Une branche
aride demande ensuite une distance explicite de 2 048 blocs depuis cette île.
Les anciens chunks vides et le bloc témoin restent inchangés.
Le second processus vérifie dabord les huit refus sur disque avec les chunks
témoins non chargés, puis ouvre une autre île plus loin au sud et attend du
terrain prêt. Les preuves se trouvent dans
`diagnostics/expansion-alpha12-1-seed-42.json` et
`diagnostics/expansion-alpha12-1-reload-seed-42.json` sous le dossier GameTest.
Le rapport exporté est [alpha.12.1](benchmarks/alpha121-expansion.json).
`expansionPlacementSmoke`, inclus dans `check`, vérifie les huit directions,
les quatre tailles, les distances minimales diagonales/cardinales, les bornes,
les réservations existantes et limmutabilité. Les tests de commandes contrôlent
les anciennes syntaxes, les retours `preview` à 2 048 blocs et les distances
hors limites. Le correctif ne change ni le codec, ni la génération 12, ni le journal v1.
## Laboratoire alpha.12 — validation locale
`check build assemblePack` passe sur Java 25 / Minecraft 26.3-pre-2, avec
les douze tests moteur Grand de graine 0 et 1 800 ticks de fluides. Les smokes
`expansionJournalSmoke` et `expansionReliefSmoke` sont inclus dans `check`.
Le premier contrôle atomicité, corruption, immutabilité, relecture et réservation
interrompue ; le second couvre cinq graines, quatre diamètres et quatre reliefs.
Le laboratoire possède un scénario moteur distinct, à lancer puis à rouvrir
**dans cet ordre**, sans autre GameTest entre les deux :
```sh
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryExpansionTests=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
./gradlew :sanctuary:runGameTest -PsanctuaryUnifiedTests=false -PsanctuaryExpansionTests=true -PsanctuaryExpansionReload=true -PsanctuaryTestSeed=42 -PsanctuaryResourceSurvey=off -PsanctuaryDensityMaps=false
```
Le premier passage crée le monde jetable. Le second saute volontairement
`cleanGameTestWorld` et ouvre cette même sauvegarde dans un nouveau processus
Java. Il échoue si les preuves du premier passage manquent ; il ne les fabrique
pas après rechargement. La propriété reload est réservée à cette paire de tests.
Les deux passages réussissent sur la graine 42. Le premier ouvre une tropicale
au nord puis une branche aride, vérifie les vrais chunks FULL, les biomes,
les commandes opérateur, les refus de doublons incohérents et du vide déjà
chargé, et la conservation dun bloc témoin. Il observe un camp Vanilla placé
avec un tonneau et sa table de butin native. Le second retrouve le journal,
un bloc dor écrit avant larrêt, les colonnes de blocs/biomes/densités et le
tonneau. Il décode également le codec sauvegardé, mais le framework reconstruit
les réglages actifs depuis le preset : aucun parcours client normal ou joueur
connecté nest simulé. Le crash forcé au milieu dune préparation reste à essayer.
La graine 0 a fourni les deux expansions mais aucune structure acceptable :
neuf sites étaient vides et deux avaient une fondation trop mince. Le scénario
exigeant une preuve positive de butin y échoue ; cela ne justifie aucun ajout
forcé de terrain ni relâchement des fondations. Les validations visuelles des
villages, temples, reliefs et grandes tailles restent ouvertes.
Les preuves exportées figurent dans [le rapport alpha.12](benchmarks/alpha12-expansion.json).
Les diagnostics complets de chaque exécution restent dans
`mods/sanctuary/build/run/gameTest/diagnostics/expansion-alpha12[-reload]-seed-42.json`.
Les commandes et le contrat serveur sont dans [Laboratoire](alpha12-laboratory.md).
Aucun monde personnel na servi aux essais.
## Tests de forme
La tâche `:sanctuary:worldgenSmoke`, incluse dans `check`, vérifie les invariants de
@@ -43,7 +299,8 @@ labsence de remplissage de secours lorsque la géométrie ne convient pas.
`runGameTest` démarre le serveur de test headless officiel de Fabric dans
`mods/sanctuary/build/run/gameTest/`. La tâche `cleanGameTestWorld` supprime uniquement
son monde jetable avant chaque exécution afin de générer des chunks neufs.
son monde jetable avant chaque exécution afin de générer des chunks neufs, sauf
lors du second passage explicite `sanctuaryExpansionReload` décrit ci-dessus.
Il ne déploie rien dans un serveur ou une installation de jeu personnels.
La configuration garde `eula=false` : aucun fichier d'acceptation n'est écrit par
Loom. Fabric dispose d'un chemin de démarrage propre à ses tests automatisés.
+119 -19
View File
@@ -22,12 +22,38 @@ L'objectif mythologique est de progresser d'une condition très limitée, proche
- **Humour, surprise et souvenirs.** Les noms d'entités, les événements, les animaux rares et les objets insolites comptent autant que les systèmes économiques.
- **Un développement par tickets.** Livrer une petite mécanique jouable et vérifiable, puis l'ajuster avec des tickets de fonctionnalités et de bugs. Ne pas construire tous les systèmes avant de pouvoir jouer.
## Minecraft comme archéologie dun autre Minecraft
**Canon et ligne de conception retenus.** Steve, Ari, Sunny, Kai, Zuri, Alex,
Mefe, Makena, Noor sont les personnages disparus de la partie. Ils ont découvert
quelque chose de révolutionnaire et ont continué parce que ça marchait. Jusquau
moment où ça na plus marché.
> Ils nont pas ouvert quelque chose quils nauraient pas dû ouvrir. Ils ont
> construit quelque chose quils ne savaient plus refermer.
Chaque ruine doit être une trace dune ancienne solution, pas seulement la trace
dun problème : une infrastructure dont lauteur a progressivement disparu
derrière son fonctionnement. Son ancien usage, ses circulations, ses réparations,
ses extensions et ses chantiers doivent permettre de comprendre ce qui avait
été résolu ici, même quand linstallation est abandonnée.
La nature de la découverte et les rôles individuels restent à définir. Aucune
catastrophe précise nest confirmée par ce principe. **La mise en scène de cette
histoire et ses liens avec la progression restent à implémenter** ; les versions
livrées et leurs tests établissent ce que chaque structure contient effectivement.
## Périmètre de départ
Le premier incrément porte sur le socle Fabric et la génération du monde : retrouver le générateur 26.2, produire une île principale dans un vrai vide, assurer une arrivée commune et sûre, puis poser une manière reproductible de générer des continents à une distance, une direction et une taille choisies.
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.
Lalpha 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
nest pas considérée terminée avant ses essais et sa livraison.
## Mod, modpack et modules autonomes
| Ensemble | Rôle souhaité |
@@ -50,19 +76,19 @@ Les intégrations communautaires envisagées comprennent Fabric API, Sodium, Iri
L'île de Sanctuary est le point de départ commun. Elle flotte dans un vide qui ne doit pas être rempli par la génération habituelle du monde. Les continents suivants sont de vastes terres suspendues, créées progressivement selon une taille et une direction maîtrisées.
Le choix de taille initiale se limite à **Petit** et **Grand**, avec Grand
par défaut. Petit prend une référence de 512 blocs de diamètre ; Grand vise
environ 724 blocs, soit deux fois son aire nominale, sur une hauteur de
384 blocs. Les repères internes 5/10 ne constituent ni les noms affichés ni
une promesse de capacité multijoueur. Ce resserrement des tailles doit limiter
les distances entre constructions.
Le monde doit se créer par un seul choix **Sanctuary**, avec le bouton natif
**Personnaliser** pour régler sa taille : **Petit** (512 blocs de diamètre
nominal), **Moyen** (724, par défaut) et **Grand** (1 024). La hauteur reste de
384 blocs. Ces dimensions ne promettent ni une aire entièrement habitable,
ni un stock de ressources, ni une capacité multijoueur.
Lalpha.11 implémente ces deux choix et loptimisation des chargements. Les
anciens formats 20/100 sont masqués à la création mais restent chargeables
avec leurs paramètres enregistrés ; aucune sauvegarde nest réduite.
Les mesures serveur et leurs limites sont consignées dans [Validation](testing.md).
Ce dimensionnement ne remplace pas lexpansion collective ni ne garantit
une aire habitable ou un stock de ressources par joueur.
Lalpha.13 implémente cette interface et la réunion de l’île complète de Population,
de ses forêts et de son hydrologie, avec les cavités natives du laboratoire.
Lalpha.11 et ses anciens choix Petit/Grand restent historiques. Les anciens
codecs et paramètres sauvegardés restent chargeables ; les choix précédents
sont cachés à la création et aucune partie nest convertie ou redimensionnée.
L’état dintégration est décrit dans le [guide alpha.13](alpha13-worldgen.md) ;
les observations réelles et leurs limites restent dans [Validation](testing.md).
L'île initiale doit être **tempérée et légèrement humide**, avec quelques zones
plus sèches, notamment de larges plages et dépôts irréguliers de sable, gravier,
@@ -134,11 +160,23 @@ facultatives selon le relief ; leur existence doit être démontrée en jeu.
L’état de chaque incrément et ses limites
figurent dans [Génération](worldgen.md).
Le premier champ de recherche des expansions associe les **huit directions**
autour de Sanctuary à des signatures de température et d'humidité. En regardant
la carte avec le nord en haut : **nord froid, sud chaud, ouest sec et est humide**.
Les diagonales combinent ces tendances. La recherche porte sur des climats et
leurs distributions, sans imposer la même forme de continent à chaque direction.
La progression des expansions se fait par **choix en branchement**. La direction
géographique, le climat et larchétype sont des choix distincts. La convention
**nord froid, sud chaud, ouest sec et est humide** reste une voie possible, avec
ses diagonales. Le joueur peut aussi ouvrir une branche alternative et composer
des couronnes de nouvelles îles aux climats différents. Une île enregistre son
parent ; le serveur conserve ainsi lhistoire des découvertes de son univers.
La recherche porte sur des climats et leurs distributions, sans imposer la même
forme ni le même climat à une direction pour toutes les parties.
Les alpha.12 et alpha.12.1 ont fourni un laboratoire à cette conception :
commandes opérateur, climats et reliefs indépendants, structures vanilla
compatibles et recherche demplacements libres. Lalpha.13 reprend ces mêmes
outils dans le nouveau monde Sanctuary unifié. Son journal est séparé de celui
des anciens laboratoires, sans conversion de leur génération.
Les recherches, mailboxes et deposit boxes viendront ensuite ; elles ne sont
pas nécessaires aux commandes dessai. Le passage en bêta dépendra du périmètre
de progression retenu et de sa validation, pas du seul changement de générateur.
L'économie naturelle d'une île doit être connue **après sa génération** : nombre
de blocs de pierre, bûches, minerais de charbon, de fer et autres ressources,
@@ -154,9 +192,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.
Lautorisation ne garantit pas leur présence sur chaque île. Le laboratoire
commence par les villages, temples, cabanes de marais et camps abandonnés.
- **Lost City** : des quartiers traités comme un biome, où les bâtiments
contemporains en ruine prennent la place principale. Lalpha.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. Lalpha.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 dexpansion, 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 leau, un bassin contenu. Les parkings ne font pas partie
de la future dimension des cavernes. Lalpha.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. Lalpha.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 dexpansion** : 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 lensemble de l’île en vue du dessus.
Lalpha prépare la salle ; la machine, les cartes préremplies et les
interactions dexpansion seront définies et implémentées dans un ticket suivant.
- **Occupants de Lost City** : des zombies plutôt que des villageois, sans
spawner. Lalpha 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 lalpha.15) :
développer les trois familles ensemble : mines et ateliers, lieux étranges,
infrastructures à réactiver. Les ruines doivent raconter une occupation
antérieure par dautres joueurs, à travers leurs usages et leurs circulations.
Plusieurs réseaux distincts peuvent se rejoindre ponctuellement : découvrir
ces connexions fait partie de lexploration. 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 lalpha.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.
Loption « 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
+63 -41
View File
@@ -1,58 +1,80 @@
# Premier monde Sanctuary
Le preset `sanctuary:sanctuary` crée l'île principale à l'origine de l'Overworld,
entourée de vide sans répétition d'îles à grande distance. C'est une première
tranche de génération pour Minecraft **26.3-pre-2 / Fabric**, pas encore le
système d'expansion collective.
## Alpha.13 — génération unifiée
Lalpha.11 propose deux choix de création, **Petit** et **Grand**, avec Grand
par défaut. Les anciennes tailles 20/100 restent chargeables dans les sauvegardes.
Les observations de chaque version restent dans [Validation](testing.md).
Le choix public **Sanctuary** (`sanctuary:sanctuary`) crée l’île principale à
lorigine de lOverworld, entourée de vide. Son bouton natif **Personnaliser**
propose **Petit** (512 blocs de diamètre nominal), **Moyen** (724, par défaut)
et **Grand** (1 024). Les types vanilla restent disponibles. La hauteur reste
de 384 blocs ; les dimensions nominales ne donnent ni aire habitable garantie
ni capacité multijoueur.
## Petit et Grand — alpha.11
L’île initiale reprend la densité, les forêts et lhydrologie complète de la
famille Population, avec les poches de biomes souterrains natives du laboratoire.
Les extensions conservent leurs profils climatiques et leurs commandes
alpha.12.1. Leurs règles de génération restent distinctes de celles de l’île
initiale : la grande hydrologie de départ nest pas automatiquement reproduite
sur chaque continent ajouté.
| Choix public | Diamètre nominal | Aire nominale relative | Preset | Réglage enregistré |
| --- | --- | --- | --- | --- |
| Petit | 512 blocs | 1 | `sanctuary:sanctuary_5` | `sanctuary:population_5` |
| Grand, défaut | Environ 724 blocs | 2 | `sanctuary:sanctuary` | `sanctuary:population_10` |
Les trois tailles et les cavités passent les contrôles moteur. Le [guide alpha.13](alpha13-worldgen.md)
décrit linterface, les paramètres sauvegardés et les outils dexpansion ;
[Validation](testing.md) conserve la portée des essais de chaque version.
La hauteur reste de 384 blocs. Les repères internes 5/10 décrivent le calcul
de surface ; ils ne sont ni des noms affichés ni une capacité promise.
Le relief réel garde ses trous, ses surplombs et sa bordure sculptée.
## Créer un nouveau monde
Le nouveau Grand reçoit sa propre clé `population_10`. Les anciens mondes
`population_5`, `population_20` et `population_100` restent chargeables avec
leurs paramètres ; les formats 20/100 sont cachés dans les choix publics.
Changer la valeur du preset de création nagrandit ni ne réduit une sauvegarde.
Les générations antérieures restent distinctes, dont `sanctuary_cavern` et
`sanctuary_rift`.
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.
Loptimisation réutilise les coins dinterpolation avec la même arithmétique
et borne ce cache à 192 Kio par thread, sans retenir fortement un monde.
Les plans prêts restent accessibles pendant le calcul dune autre région ;
le sampler compilé est réutilisé dans ce calcul. Les contrôles d’équivalence,
de persistance et les mesures à géométrie identique sont dans [Validation](testing.md).
Les règles de forme, dhydrologie et les anciens réglages restent inchangés.
## Créer un monde
La création solo propose **Sanctuary (Petit)** et **Sanctuary (Grand)**,
ce dernier étant le défaut Sanctuary.
Les types de monde vanilla restent disponibles.
Sur un nouveau serveur Fabric disposant de cette version et de Fabric API,
le choix Grand utilise :
Sur un nouveau serveur Fabric avec Fabric API, le preset public utilise la
taille **Moyen**, 724 blocs :
```properties
level-type=sanctuary:sanctuary
level-name=sanctuary
level-name=sanctuary-dev
level-seed=0
```
Pour Petit, utiliser `level-type=sanctuary:sanctuary_5`. Choisir un nouveau
`level-name` pour tester lautre taille. La seed est libre ; ces exemples ne
convertissent aucun monde déjà enregistré et nautorisent aucune régénération.
Les fichiers de test et les sauvegardes personnelles restent distincts.
La graine est libre. Utiliser un dossier neuf pour un essai ; modifier
`level-type` ne convertit aucun monde enregistré. Aucune commande de migration
ou de régénération des anciennes îles nest fournie.
## Compatibilité et versions historiques
Lalpha.13 enregistre le générateur `sanctuary:island_v13`, son diamètre initial
et une racine de génération 13. Les nouvelles expansions restent de génération
12. Leur journal utilise le dossier propre au monde
`data/sanctuary-world-v13/`. Les anciens codecs et presets sont conservés, mais
cachés de la sélection publique. Les mondes déjà créés conservent leurs réglages
et leur journal ; aucun chunk nest remplacé par cette mise à jour.
### Alpha.12 et alpha.12.1 — laboratoire historique
Le preset distinct `sanctuary:expansion_lab` proposait une origine de 512 blocs
et des commandes opérateur pour ajouter des îles de 64/128/256/512 blocs.
Son relief expérimental et ses biomes natifs ne reprenaient pas les grandes
rivières et terrasses de Population. Lalpha.12.1 ajoutait une distance
facultative et la recherche dun emplacement libre au-delà des chunks occupés.
Ces mondes gardent leur génération 12 et leur dossier
`data/sanctuary-expansion-v1/`. Le [guide du laboratoire](alpha12-laboratory.md)
reste la référence historique de ces versions, avec leurs seules observations.
### Alpha.11 — Petit et Grand historiques
| Ancien choix public | Diamètre nominal | Ancien preset | Réglage enregistré |
| --- | --- | --- | --- |
| Petit | 512 blocs | `sanctuary:sanctuary_5` | `sanctuary:population_5` |
| Grand, ancien défaut | Environ 724 blocs | `sanctuary:sanctuary` | `sanctuary:population_10` |
Lancien Grand correspond à la taille nominale du nouveau Moyen. Les mondes
historiques `population_5`, `population_10`, `population_20` et `population_100`
restent chargeables avec leurs dimensions. Le preset public est une recette
de création ; sa nouvelle définition ne remplace pas leur générateur sauvegardé.
Loptimisation alpha.11 conserve larithmétique de linterpolation et les règles
de terrain et dhydrologie. Ses mesures et contrôles sont historiques, sans
validation anticipée des ajouts de lalpha.13.
## Socle de génération conservé
+2 -2
View File
@@ -8,6 +8,6 @@ loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.3
mod_version=0.1.0-alpha.11
pack_version=0.1.0-alpha.11
mod_version=0.1.0-alpha.22
pack_version=0.1.0-alpha.22
maven_group=fr.koka.sanctuary
+291 -2
View File
@@ -26,8 +26,9 @@ 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') {
@@ -36,14 +37,66 @@ tasks.named('runGameTest') {
systemProperty('sanctuary.test.players', providers.gradleProperty('sanctuaryTestPlayers').getOrElse('10'))
systemProperty('sanctuary.test.resourceSurvey', providers.gradleProperty('sanctuaryResourceSurvey').getOrElse('sample'))
systemProperty('sanctuary.test.densityMaps', providers.gradleProperty('sanctuaryDensityMaps').getOrElse('true'))
systemProperty('sanctuary.test.unified', providers.gradleProperty('sanctuaryUnifiedTests').getOrElse('true'))
systemProperty('sanctuary.test.diameter', providers.gradleProperty('sanctuaryTestDiameter').getOrElse('724'))
systemProperty('sanctuary.test.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' : providers.gradleProperty('sanctuaryRuins17Tests').map { it.toBoolean() }.getOrElse(false) ? '17' : providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false) ? '19' : (providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)) ? '20' : (providers.gradleProperty('sanctuaryHeritage21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false) || providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false)) ? '21' : '22'))
systemProperty('sanctuary.test.selective22', providers.gradleProperty('sanctuarySelective22Tests').getOrElse('false'))
systemProperty('sanctuary.transit.plannerDiagnostics', providers.gradleProperty('sanctuaryTransitDiagnostics').getOrElse('false'))
systemProperty('sanctuary.test.heritage21', providers.gradleProperty('sanctuaryHeritage21Tests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity21', providers.gradleProperty('sanctuaryLostCity21Tests').getOrElse('false'))
systemProperty('sanctuary.test.transit21', providers.gradleProperty('sanctuaryTransit21Tests').getOrElse('false'))
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.lostCity20', providers.gradleProperty('sanctuaryLostCity20Tests').getOrElse('false'))
systemProperty('sanctuary.test.lostCity16', providers.gradleProperty('sanctuaryLostCity16Tests').getOrElse('false'))
systemProperty('sanctuary.test.ruins17', providers.gradleProperty('sanctuaryRuins17Tests').getOrElse('false'))
systemProperty('sanctuary.test.heritage20', providers.gradleProperty('sanctuaryHeritage20Tests').getOrElse('false'))
systemProperty('sanctuary.test.heritage19', providers.gradleProperty('sanctuaryHeritage19Tests').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 lostCity20 = providers.gradleProperty('sanctuaryLostCity20Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lostCity20', lostCity20)
def heritage20 = providers.gradleProperty('sanctuaryHeritage20Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('heritage20', heritage20)
def heritage21 = providers.gradleProperty('sanctuaryHeritage21Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('heritage21', heritage21)
def lostCity21 = providers.gradleProperty('sanctuaryLostCity21Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lostCity21', lostCity21)
def selective22 = providers.gradleProperty('sanctuarySelective22Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('selective22', selective22)
def transit21 = providers.gradleProperty('sanctuaryTransit21Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('transit21', transit21)
def heritage19 = providers.gradleProperty('sanctuaryHeritage19Tests').map { it.toBoolean() }.getOrElse(false)
inputs.property('heritage19', heritage19)
def voidReuse = providers.gradleProperty('sanctuaryVoidReuseTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('voidReuse', voidReuse)
filesMatching('fabric.mod.json') {
expand(gametest_entrypoint: benchmark
expand(gametest_entrypoint: selective22 ? 'fr.koka.sanctuary.gametest.Selective22WorldGameTests' : transit21 ? 'fr.koka.sanctuary.gametest.Transit21WorldGameTests' : lostCity21 ? 'fr.koka.sanctuary.gametest.LostCity21WorldGameTests' : heritage21 ? 'fr.koka.sanctuary.gametest.Heritage21WorldGameTests' : lostCity20 ? 'fr.koka.sanctuary.gametest.LostCity20WorldGameTests' : heritage20 ? 'fr.koka.sanctuary.gametest.Heritage20WorldGameTests' : heritage19 ? 'fr.koka.sanctuary.gametest.Heritage19WorldGameTests' : 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')
}
}
@@ -241,3 +294,239 @@ tasks.register('populationPlanCacheSmoke', JavaExec) {
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) }
}
// Local native starts keep the long heritage routes bounded by Minecraft's reference range.
['Planner', 'Renderer', 'Piece'].each { component ->
def smoke = tasks.register("heritage19${component}Smoke", JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.heritage.Heritage${component}19Smoke"
}
tasks.named('check') { dependsOn(smoke) }
}
// Generation20 architecture retains the previous renderers and verifies connected block states.
[
connectedBlocks20Smoke: 'detail.ConnectedBlocks20Smoke',
heritage20RendererSmoke: 'heritage.HeritageRenderer20Smoke',
heritage20PieceSmoke: 'heritage.HeritagePiece20Smoke',
ruins20RendererSmoke: 'ruins.RuinsRenderer20Smoke',
lostCityUnderground20Smoke: 'city.LostCityUnderground20Smoke',
lostCitySurface20Smoke: 'city.LostCitySurface20Smoke',
lostCityInteriors20Smoke: 'city.LostCityInteriors20Smoke'
].each { name, harness ->
def smoke = tasks.register(name, JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
}
tasks.named('check') { dependsOn(smoke) }
}
// Optional actual block-state exports for architectural review; never enabled in ordinary checks.
tasks.named('heritage20RendererSmoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.test.heritage20.export', rootProject.layout.buildDirectory.dir('alpha20-preview/heritage').get().asFile.absolutePath)
}
tasks.named('lostCityInteriors20Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city20.preview', rootProject.layout.buildDirectory.file('alpha20-preview/city.json').get().asFile.absolutePath)
}
tasks.named('lostCitySurface20Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city20.exteriorPreview', rootProject.layout.buildDirectory.file('alpha20-preview/exterior.json').get().asFile.absolutePath)
}
// Generation21 keeps published20 renderers immutable.
[
heritage21RendererSmoke: 'heritage.HeritageRenderer21Smoke',
heritage21PieceSmoke: 'heritage.HeritagePiece21Smoke',
heritage21PlannerSmoke: 'heritage.HeritagePlanner21Smoke',
lostCity21PlannerSmoke: 'city.LostCityPlanner21Smoke',
lostCitySurface21Smoke: 'city.LostCitySurface21Smoke',
lostCityInteriors21Smoke: 'city.LostCityInteriors21Smoke',
transit21Smoke: 'transit.Transit21Smoke'
].each { name, harness ->
def smoke = tasks.register(name, JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
}
tasks.named('check') { dependsOn(smoke) }
}
tasks.named('heritage21RendererSmoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.test.heritage21.export', rootProject.layout.buildDirectory.dir('alpha21-preview/heritage').get().asFile.absolutePath)
}
tasks.named('lostCityInteriors21Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city21.preview', rootProject.layout.buildDirectory.file('alpha21-preview/city.json').get().asFile.absolutePath)
}
tasks.named('lostCitySurface21Smoke') {
if (providers.gradleProperty('sanctuaryArchitecturePreview').isPresent())
systemProperty('sanctuary.city21.exteriorPreview', rootProject.layout.buildDirectory.file('alpha21-preview/exterior.json').get().asFile.absolutePath)
}
// Generation22 filters surface placement and adds an independent core hall; historical tests still run.
[
lostCity22PlannerSmoke: 'city.LostCityPlanner22Smoke',
underground22Smoke: 'city.LostCityUnderground22Smoke',
heritage22PlannerSmoke: 'heritage.HeritagePlanner22Smoke',
transit22PlannerSmoke: 'transit.TransitPlanner22Smoke',
transit22RouteSmoke: 'transit.TransitRoute22Smoke',
transit22RendererSmoke: 'transit.TransitRenderer22Smoke'
].each { name, harness ->
def smoke = tasks.register(name, JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka.sanctuary.worldgen.${harness}"
}
tasks.named('check') { dependsOn(smoke) }
}
@@ -16,6 +16,7 @@ public final class SanctuaryClientRenderTests implements FabricClientGameTest {
"net.minecraft.client.renderer.fog.FogRenderer", "sanctuary$openVoidFog");
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
});
SanctuaryWorldOptionsClientChecks.run(context);
}
private static void assertApplied(ClassLoader loader, String target, String hook) {
@@ -0,0 +1,179 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.client.SanctuaryWorldOptionsScreen;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.CycleButton;
import net.minecraft.client.gui.components.tabs.TabNavigationBar;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.RegistryOps;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.WorldGenSettings;
import net.minecraft.world.level.storage.LevelStorageSource;
/** The real new-world button path and saved settings, without starting or opening a game world. */
final class SanctuaryWorldOptionsClientChecks {
private static final KeyEvent PRESS = new KeyEvent(InputConstants.KEY_RETURN, 0, 0);
static void run(ClientGameTestContext context) {
Screen previous = context.computeOnClient(client -> client.gui.screen());
context.runOnClient(client -> CreateWorldScreen.openFresh(client, () -> client.gui.setScreen(previous)));
context.waitFor(client -> client.gui.screen() instanceof CreateWorldScreen, 1200);
CreateWorldScreen create = context.computeOnClient(client -> (CreateWorldScreen) client.gui.screen());
List<Map<String, Object>> savedSizes = new ArrayList<>();
try {
context.runOnClient(client -> {
TabNavigationBar tabs = create.children().stream().filter(TabNavigationBar.class::isInstance)
.map(TabNavigationBar.class::cast).findFirst().orElseThrow();
tabs.selectTab(1, false); // The vanilla World tab owns the world type and Customize widgets.
var state = create.getUiState();
long sanctuaryChoices = state.getNormalPresetList().stream().filter(entry -> entry.preset() != null
&& entry.preset().getRegisteredName().startsWith("sanctuary:")).count();
require(sanctuaryChoices == 1, "Exactly one public Sanctuary choice must remain");
require(state.getAltPresetList().stream().filter(entry -> entry.preset() != null
&& entry.preset().getRegisteredName().startsWith("sanctuary:")).count() == 1,
"The extended world-type cycle must not expose the old laboratory or sizes");
CycleButton<?> worldType = create.children().stream().filter(CycleButton.class::isInstance)
.map(CycleButton.class::cast)
.filter(button -> button.getValue() instanceof WorldCreationUiState.WorldTypeEntry)
.findFirst().orElseThrow();
int attempts = state.getNormalPresetList().size();
while (!isSanctuary(state) && attempts-- > 0) worldType.onPress(PRESS);
require(isSanctuary(state), "The real world-type cycle must reach Sanctuary");
require(diameter(create) == 724, "Sanctuary defaults to Medium");
state.setSeed("42");
state.setGenerateStructures(false);
state.setBonusChest(true);
});
// Cancel and Escape both leave the pending generator untouched.
context.clickScreenButton("selectWorld.customizeType");
context.waitForScreen(SanctuaryWorldOptionsScreen.class);
context.runOnClient(client -> chooseSize(client.gui.screen(), "LARGE"));
context.clickScreenButton("gui.cancel");
context.runOnClient(client -> require(diameter(create) == 724, "Cancel must not apply Large"));
context.clickScreenButton("selectWorld.customizeType");
context.runOnClient(client -> {
chooseSize(client.gui.screen(), "SMALL");
client.gui.screen().keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE, 0, 0));
require(client.gui.screen() == create && diameter(create) == 724,
"Escape must return to creation without applying Small");
});
Path diagnostics = Path.of("diagnostics").toAbsolutePath();
Files.createDirectories(diagnostics);
Path testRoot = Files.createTempDirectory(diagnostics, "world-options-settings-");
var storage = LevelStorageSource.createDefault(testRoot);
for (var size : Map.of("SMALL", 512, "MEDIUM", 724, "LARGE", 1024).entrySet()) {
var before = context.computeOnClient(client -> create.getUiState().getSettings());
context.clickScreenButton("selectWorld.customizeType");
context.waitForScreen(SanctuaryWorldOptionsScreen.class);
context.runOnClient(client -> chooseSize(client.gui.screen(), size.getKey()));
context.clickScreenButton("gui.done");
context.runOnClient(client -> {
require(client.gui.screen() == create && diameter(create) == size.getValue(),
"Done must apply the chosen diameter and return to creation");
var after = create.getUiState().getSettings();
require(after.options().equals(before.options()), "Size must preserve seed, structures and bonus chest");
for (var dimension : List.of(LevelStem.NETHER, LevelStem.END)) {
require(after.selectedDimensions().get(dimension).equals(before.selectedDimensions().get(dimension)),
"Size must preserve other dimensions");
}
require(isSanctuary(create.getUiState()), "Customization must keep the single Sanctuary world type");
create.getUiState().setSettings(after.withOptions(options -> options));
require(diameter(create) == size.getValue(),
"Refreshing world settings must preserve the custom size instead of restoring the preset default");
String name = size.getKey().toLowerCase(java.util.Locale.ROOT);
var settings = new WorldGenSettings(after.options(), after.selectedDimensions());
try (var save = storage.createAccess(name)) {
LevelStorageSource.writeWorldGenSettings(after.worldgenLoadContext(), storage.getLevelPath(name), settings);
var restored = LevelStorageSource.readExistingSavedData(save, after.worldgenLoadContext(),
WorldGenSettings.TYPE).getOrThrow();
require(restored.dimensions().overworld() instanceof SanctuaryChunkGenerator generator
&& generator.initialDiameter() == size.getValue(), "Selected size must survive actual settings storage");
var ops = RegistryOps.create(JsonOps.INSTANCE, after.worldgenLoadContext());
require(WorldGenSettings.CODEC.encodeStart(ops, restored).getOrThrow()
.equals(WorldGenSettings.CODEC.encodeStart(ops, settings).getOrThrow()),
"Stored options and dimensions must remain unchanged");
savedSizes.add(Map.of("size", name, "diameter", size.getValue(), "disk_round_trip", true));
}
});
context.clickScreenButton("selectWorld.customizeType");
context.runOnClient(client -> require(sizeButton(client.gui.screen()).getValue().toString().equals(size.getKey()),
"Reopening Customize must restore the saved selection"));
if (size.getKey().equals("MEDIUM")) context.takeScreenshot("sanctuary-world-options-medium");
context.clickScreenButton("gui.cancel");
}
context.runOnClient(client -> {
var state = create.getUiState();
var settings = state.getSettings();
var presets = settings.worldgenLoadContext().lookupOrThrow(Registries.WORLD_PRESET);
var legacy = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_5")))
.value().createWorldDimensions().overworld();
state.updateDimensions((registries, dimensions) -> dimensions.replaceOverworldGenerator(registries, legacy));
require(state.getPresetEditor() == null,
"A legacy generator under the public key must not gain an implicit conversion editor");
state.setSettings(settings);
var flat = presets.getOrThrow(net.minecraft.world.level.levelgen.presets.WorldPresets.FLAT);
state.setWorldType(new WorldCreationUiState.WorldTypeEntry(flat));
require(state.getPresetEditor() != null
&& !(state.getPresetEditor().createEditScreen(create, state.getSettings()) instanceof SanctuaryWorldOptionsScreen),
"Vanilla flat-world customization must remain available");
});
Files.writeString(diagnostics.resolve("sanctuary-world-options-client.json"),
new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
"public_sanctuary_choices", 1, "default_diameter", 724,
"saved_sizes", savedSizes, "cancel_and_escape_preserved", true,
"settings_refresh_preserved", true,
"legacy_editor_guard", true, "vanilla_editor_preserved", true,
"test_settings_directory", testRoot.toString(), "passed", true)) + "\n");
SanctuaryMod.LOGGER.info("Sanctuary world options client tests passed: one world type, three saved sizes, Done/Cancel/Escape.");
} catch (IOException failure) {
throw new AssertionError("Unable to verify the selected world settings on disk", failure);
} finally {
context.runOnClient(client -> create.onClose());
}
}
private static boolean isSanctuary(WorldCreationUiState state) {
var preset = state.getWorldType().preset();
return preset != null && preset.getRegisteredName().equals("sanctuary:sanctuary");
}
private static int diameter(CreateWorldScreen create) {
return ((SanctuaryChunkGenerator) create.getUiState().getSettings().selectedDimensions().overworld()).initialDiameter();
}
private static CycleButton<?> sizeButton(Screen screen) {
require(screen instanceof SanctuaryWorldOptionsScreen, "Customize must open the Sanctuary screen");
return screen.children().stream().filter(CycleButton.class::isInstance).map(CycleButton.class::cast)
.findFirst().orElseThrow();
}
private static void chooseSize(Screen screen, String target) {
CycleButton<?> button = sizeButton(screen);
int attempts = 3;
while (!button.getValue().toString().equals(target) && attempts-- > 0) button.onPress(PRESS);
require(button.getValue().toString().equals(target), "Size cycle must expose " + target);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,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");
}
}
@@ -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");
}
}
@@ -0,0 +1,533 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionAdmission;
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.expansion.ExpansionJournal;
import fr.koka.sanctuary.expansion.ExpansionRuntime;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource;
/** Opt-in end-to-end exercise on a new laboratory world; never rewrites chunks or a personal world. */
public final class ExpansionWorldGameTests {
private static final boolean OPTIONAL_STRUCTURE_WITNESS = Boolean.getBoolean("sanctuary.test.optionalStructureWitness");
@GameTest(maxTicks = 6000)
public void islandsBranchPersistAndProtectVisitedChunks(GameTestHelper helper) {
helper.assertTrue(Boolean.getBoolean("sanctuary.test.expansion"), "Expansion GameTest must be explicitly enabled");
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof ExpansionChunkGenerator,
"Expansion GameTest must load the new laboratory preset");
if (Boolean.getBoolean("sanctuary.test.expansionReload")) {
verifyProcessReload(helper);
return;
}
new Scenario(helper).advance();
}
private static void verifyProcessReload(GameTestHelper helper) {
ServerLevel level = helper.getLevel();
var generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
var session = ExpansionRuntime.session(level);
Map<String, Object> report = new LinkedHashMap<>();
report.put("seed", level.getSeed());
report.put("process_id", ProcessHandle.current().pid());
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
report.put("scope", "A second Java process opens the same saved development chunks and expansion journal, and verifies a "
+ "gold block written and flushed before the first shutdown. GameTestServer reconstructs its world settings from the preset; "
+ "the prior generator codec is decoded separately here and its base columns are compared. This is not a normal-client "
+ "world-selection test or a multiplayer/teleportation test.");
try {
JsonObject previous = JsonParser.parseString(Files.readString(diagnostic(level, false))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean(), "Reload requires a successful first integration run");
helper.assertTrue(previous.has("restart_snapshot"), "The first process must save a restart witness before shutting down");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(),
"The reload proof must run in another Java process");
helper.assertTrue(previous.get("seed").getAsLong() == level.getSeed(), "Reload must keep the original world seed");
JsonObject saved = previous.getAsJsonObject("restart_snapshot");
List<ExpansionIsland> blockers = ExpansionIsland.CODEC.listOf().parse(JsonOps.INSTANCE, saved.get("blocked_sites")).getOrThrow();
int savedRefusals = 0;
for (var site : blockers) {
helper.assertTrue(level.getChunkSource().chunkMap.getUpdatingChunkIfPresent(new ChunkPos(site.centerX() >> 4, site.centerZ() >> 4).pack()) == null,
"Reload collision proof must start with an unloaded saved witness");
try { ExpansionAdmission.verify(level, site); }
catch (ExpansionAdmission.OccupiedException expected) {
helper.assertTrue(expected.getMessage().contains("saved chunk"), "The reload proof must detect disk occupancy, not just a loaded holder");
savedRefusals++;
}
}
helper.assertTrue(savedRefusals == 8, "All eight first sites must remain unavailable on disk after restart");
report.put("saved_only_collision_refusals", savedRefusals);
helper.assertTrue(saved.get("world_directory").getAsString().equals(worldDirectory(level)),
"Reload must use exactly the same development save directory");
helper.assertTrue(session.islands().size() == 3 && session.islands().stream().allMatch(i -> i.status().equals("ready")),
"The loaded journal must contain exactly the ready root, alpha and beta");
helper.assertTrue(session.island("alpha").parent().equals("sanctuary") && session.island("beta").parent().equals("alpha"),
"Reload must preserve the expansion branch");
var encodedIslands = ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow();
helper.assertTrue(encodedIslands.equals(saved.get("islands")), "Every persisted region field must survive the process restart");
helper.assertTrue(sha256(Files.readString(journalFile(level))).equals(saved.get("journal_sha256").getAsString()),
"Loading a ready journal must preserve its exact saved contents");
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var liveCodec = ChunkGenerator.CODEC.encodeStart(ops, generator).getOrThrow();
helper.assertTrue(liveCodec.equals(saved.get("generator_codec")), "Generator settings and biome holder identities must remain stable");
var decoded = ChunkGenerator.CODEC.parse(ops, saved.get("generator_codec")).getOrThrow();
helper.assertTrue(decoded instanceof ExpansionChunkGenerator, "The saved generator codec must decode to the laboratory generator");
var restored = (ExpansionChunkGenerator) decoded;
restored.initialize(level, session.islands());
BlockPos witness = position(saved.getAsJsonArray("gold_witness"));
full(helper, level, witness.getX() >> 4, witness.getZ() >> 4);
helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK),
"The first process's saved gold block must exist before the reload test writes anything");
List<Map<String, Object>> samples = new ArrayList<>();
for (var element : saved.getAsJsonArray("samples")) {
JsonObject original = element.getAsJsonObject();
var island = session.island(original.get("id").getAsString());
BlockPos point = position(original.getAsJsonArray("point"));
Map<String, Object> current = sample(helper, generator, island, point);
helper.assertTrue(new GsonBuilder().create().toJsonTree(current).equals(original),
"Stored blocks, stored biomes and generated base column must survive restart: " + island.id());
helper.assertTrue(new GsonBuilder().create().toJsonTree(sample(helper, restored, island, point)).equals(original),
"Decoding the saved generator codec must reproduce its base column and climate: " + island.id());
samples.add(current);
}
boolean hasNativeStructure = previous.has("native_structure") && !previous.get("native_structure").isJsonNull();
if (hasNativeStructure) {
helper.assertTrue(!previous.has("native_structure_observed") || previous.get("native_structure_observed").getAsBoolean(),
"Stored native structure evidence must not contradict the observation result");
JsonObject nativeStructure = previous.getAsJsonObject("native_structure");
BlockPos containerPos = position(nativeStructure.getAsJsonArray("container"));
full(helper, level, containerPos.getX() >> 4, containerPos.getZ() >> 4);
helper.assertTrue(level.getBlockState(containerPos).toString().equals(nativeStructure.get("placed_block").getAsString()),
"The native structure's placed block state must survive restart");
helper.assertTrue(level.getBlockEntity(containerPos) instanceof RandomizableContainerBlockEntity,
"The native structure's placed container must be loaded from the saved chunk");
var container = (RandomizableContainerBlockEntity) level.getBlockEntity(containerPos);
helper.assertTrue(container.getLootTable() != null && container.getLootTable().identifier().toString()
.equals(nativeStructure.get("loot_table").getAsString()), "The native structure loot table must survive restart unconsumed");
report.put("native_container", coordinates(containerPos));
report.put("native_structure_observed", true);
report.put("native_container_verified", true);
report.put("native_structure_status", "placed_block_and_unconsumed_loot_verified_after_restart");
} else {
helper.assertTrue(OPTIONAL_STRUCTURE_WITNESS
&& previous.has("optional_native_structure_witness") && previous.get("optional_native_structure_witness").getAsBoolean()
&& previous.has("native_structure_observed") && !previous.get("native_structure_observed").getAsBoolean()
&& previous.has("native_structure_search_complete") && previous.get("native_structure_search_complete").getAsBoolean(),
"Missing native structure evidence is allowed only after an explicitly optional, completed first-process search");
report.put("native_structure_observed", false);
report.put("native_container_verified", false);
report.put("native_structure_status", "not_observed_after_complete_first_process_scan; no_native_structure_reload_proof");
report.put("native_structure_limitation", "No natural structure with placed vanilla loot was observed in the first process. "
+ "This reload verifies chunks, generator settings, the journal and expansion behavior, but supplies no positive structure proof.");
}
report.put("previous_process_id", previous.get("process_id").getAsLong());
report.put("world_directory", worldDirectory(level));
report.put("gold_witness", coordinates(witness));
report.put("samples", samples);
report.put("regions", session.islands().size());
report.put("saved_generator_codec_decoded", true);
var firstCandidate = session.candidate("after_reload", "sanctuary", "south", "boreal", 64, "natural");
var opened = session.create("after_reload", "sanctuary", "south", "boreal", 64, "natural");
helper.assertTrue(opened.centerZ() > firstCandidate.centerZ() && opened.centerX() == 0,
"Creation must skip the saved occupied first site and preserve the requested southern ray");
report.put("after_reload_expansion", List.of(opened.centerX(), opened.centerZ()));
awaitReloadExpansion(helper, session, blockers, witness, report);
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
try { writeDiagnostic(level, true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
SanctuaryMod.LOGGER.error("Expansion process reload scenario failed", exception);
helper.fail(exception.toString());
}
}
private static void awaitReloadExpansion(GameTestHelper helper, ExpansionRuntime.Session session,
List<ExpansionIsland> blockers, BlockPos gold, Map<String, Object> report) {
try {
helper.assertTrue(helper.getTick() < 5000, "Reload expansion preparation deadline: " + session.progress());
if (!session.island("after_reload").status().equals("ready")) {
helper.runAtTickTime(helper.getTick() + 10, () -> awaitReloadExpansion(helper, session, blockers, gold, report));
return;
}
verifyUntouchedVoid(helper, blockers);
helper.assertTrue(helper.getLevel().getBlockState(gold).is(Blocks.GOLD_BLOCK), "Opening farther away must preserve the saved player witness");
var landing = session.landing("after_reload");
helper.assertTrue(ExpansionRuntime.safeLanding(helper.getLevel(), landing), "The new region after restart must reach usable terrain");
report.put("after_reload_landing", coordinates(landing));
report.put("final_regions", session.islands().size());
report.put("passed", true);
writeDiagnostic(helper.getLevel(), true, report);
helper.succeed();
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
try { writeDiagnostic(helper.getLevel(), true, report); } catch (IOException failure) { exception.addSuppressed(failure); }
helper.fail(exception.toString());
}
}
private static void verifyUntouchedVoid(GameTestHelper helper, List<ExpansionIsland> blockers) {
var generator = (ExpansionChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
for (var site : blockers) {
var chunk = full(helper, helper.getLevel(), site.centerX() >> 4, site.centerZ() >> 4);
helper.assertTrue(generator.islandAt(site.centerX(), site.centerZ()) == null
&& Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()),
"An occupied saved void chunk must remain unchanged after farther placement: " + site.direction());
}
}
private static final class Scenario {
private final GameTestHelper helper;
private final ServerLevel level;
private final ExpansionChunkGenerator generator;
private final ExpansionRuntime.Session session;
private final Map<String, Object> report = new LinkedHashMap<>();
private final List<Map<String, Object>> observedIslands = new ArrayList<>();
private final Map<String, BlockPos> samplePoints = new LinkedHashMap<>();
private final List<ChunkPos> structureSearch = new ArrayList<>();
private final long started = System.nanoTime();
private ExpansionIsland alpha;
private ExpansionIsland beta;
private BlockPos marker;
private BlockState previousMarker;
private int phase;
private int structureIndex;
private int validStarts;
private final List<ExpansionIsland> blockedSites = new ArrayList<>();
Scenario(GameTestHelper helper) {
this.helper = helper;
this.level = helper.getLevel();
this.generator = (ExpansionChunkGenerator) level.getChunkSource().getGenerator();
this.session = ExpansionRuntime.session(level);
report.put("seed", level.getSeed());
report.put("process_id", ProcessHandle.current().pid());
report.put("generator_version", generator.rootGeneration());
report.put("initial_diameter", generator.initialDiameter());
report.put("islands", observedIslands);
report.put("optional_native_structure_witness", OPTIONAL_STRUCTURE_WITNESS);
report.put("native_structure_observed", false);
report.put("native_structure_search_complete", false);
report.put("native_structure_status", "not_searched");
report.put("scope", "Fresh versioned Sanctuary development world; real FULL chunks, a natural native-structure search, asynchronous activation, "
+ "branching, journal re-read and loaded void admission refusal. Journal re-read is not a process restart. "
+ "The parent block witness represents a player edit; no player teleport, multiplayer or client rendering is simulated. "
+ (OPTIONAL_STRUCTURE_WITNESS ? "The native structure witness is explicitly optional; an absent witness is reported as missing evidence, never as a successful structures test."
: "A naturally accepted structure with placed vanilla loot is required."));
}
void advance() {
try {
helper.assertTrue(helper.getTick() < 5800, "Expansion scenario deadline: " + session.progress());
switch (phase) {
case 0 -> start();
case 1 -> {
if (!session.island("alpha").status().equals("ready")) break;
helper.assertTrue(session.island("alpha").equals(alpha.ready()), "Activation must only advance the immutable island state");
observeIsland("alpha", Set.of("jungle", "sparse_jungle", "bamboo_jungle", "mangrove_swamp"));
verifyMarker();
beta = session.create("beta", "alpha", "east", "arid", 64, "natural", 2048);
helper.assertTrue(beta.parent().equals("alpha") && beta.centerX() > alpha.centerX()
&& beta.centerZ() == alpha.centerZ(), "A child must branch east from alpha, not from world origin");
helper.assertTrue(beta.centerX() - alpha.centerX() >= 2048, "The requested minimum distance must be honoured from the chosen parent");
verifyJournal();
phase = 2;
}
case 2 -> {
if (!session.island("beta").status().equals("ready")) break;
helper.assertTrue(session.island("beta").equals(beta.ready()), "Branch activation must preserve its planned identity");
observeIsland("beta", Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands"));
verifyMarker();
verifyJournal();
verifyUntouchedVoid(helper, blockedSites);
prepareStructureSearch();
phase = 3;
}
case 3 -> {
if (observeNativeStructure()) {
verifyMarker();
verifyJournal();
level.setBlock(marker, previousMarker, 3);
prepareRestartWitness();
report.put("islands", observedIslands);
report.put("native_structure_starts_seen", validStarts);
report.put("structure_start_chunks_inspected", structureIndex);
report.put("ticks", helper.getTick());
report.put("elapsed_ms", (System.nanoTime() - started) / 1_000_000.0);
report.put("passed", true);
writeReport();
helper.succeed();
return;
}
}
default -> throw new IllegalStateException("Unknown expansion test phase");
}
if (helper.getTick() % 200 == 0) SanctuaryMod.LOGGER.info("Expansion scenario phase {}: {}", phase, session.progress());
helper.runAtTickTime(helper.getTick() + 10, this::advance);
} catch (Exception exception) {
report.put("passed", false);
report.put("failure", exception.toString());
report.put("phase", phase);
report.put("runtime_progress", session.progress());
try { writeReport(); } catch (IOException failure) { exception.addSuppressed(failure); }
SanctuaryMod.LOGGER.error("Expansion integration scenario failed", exception);
helper.fail(exception.toString());
}
}
private void start() throws IOException {
helper.assertTrue(session.islands().size() == 1, "The test must start from a new expansion journal");
helper.assertTrue(level.structureManager().shouldGenerateStructures(), "The laboratory GameTest must enable actual vanilla structures in WorldOptions");
ExpansionCommandChecks.verify(helper);
report.put("registered_command_permission_checks", true);
helper.assertTrue(generator.source().possibleBiomes().size() >= ExpansionBiomeSource.REQUIRED_BIOMES.size(),
"Future biome holders must be declared before the first expansion");
BlockPos landing = session.landing("sanctuary");
helper.assertTrue(ExpansionRuntime.safeLanding(level, landing), "The initial laboratory island must provide a real safe landing");
full(landing.getX() >> 4, landing.getZ() >> 4);
marker = landing.above(6);
previousMarker = level.getBlockState(marker);
helper.assertTrue(level.setBlock(marker, Blocks.DIAMOND_BLOCK.defaultBlockState(), 3), "Place the parent player-edit witness");
report.put("parent_witness", coordinates(marker));
report.put("root_landing", coordinates(landing));
samplePoints.put("sanctuary", landing);
for (String direction : ExpansionIsland.DIRECTIONS) {
var site = session.candidate("blocked_" + direction, "sanctuary", direction, "boreal", 64, "natural");
var chunk = full(site.centerX() >> 4, site.centerZ() >> 4);
helper.assertTrue(Arrays.stream(chunk.getSections()).allMatch(section -> section.hasOnlyAir()), "The first site must initially contain only real generated void");
blockedSites.add(site);
}
int occupied = 0;
for (var site : blockedSites) {
try { ExpansionAdmission.verify(level, site); }
catch (ExpansionAdmission.OccupiedException expected) { occupied++; }
}
helper.assertTrue(occupied == 8, "Reproduce the user failure with all eight first directions occupied");
report.put("initial_occupied_directions", occupied);
var nearestNorth = session.candidate("alpha", "sanctuary", "north", "tropical", 64, "natural");
alpha = session.create("alpha", "sanctuary", "north", "tropical", 64, "natural");
helper.assertTrue(alpha.status().equals("reserved") && alpha.centerZ() < 0 && alpha.centerX() == 0,
"North placement and the explicitly chosen tropical climate must remain independent");
helper.assertTrue(alpha.centerZ() < nearestNorth.centerZ(), "Automatic placement must skip occupied chunks and continue farther north");
helper.assertTrue(alpha.equals(session.create("alpha", "sanctuary", "north", "tropical", 64, "natural"))
&& session.islands().size() == 2, "Repeating a matching request must not create another region or change its identity");
boolean conflict = false;
try { session.create("alpha", "sanctuary", "north", "arid", 64, "natural"); }
catch (IllegalArgumentException expected) { conflict = expected.getMessage().equals("id_conflict"); }
helper.assertTrue(conflict, "A conflicting duplicate island id must be refused");
verifyJournal();
phase = 1;
}
private void observeIsland(String id, Set<String> expectedSurfaceBiomes) {
ExpansionIsland island = session.island(id);
BlockPos landing = session.landing(id);
helper.assertTrue(island.contains(landing.getX(), landing.getZ()) && ExpansionRuntime.safeLanding(level, landing),
"READY must offer actual safe terrain inside " + id);
LevelChunk chunk = full(landing.getX() >> 4, landing.getZ() >> 4);
helper.assertTrue(Arrays.stream(chunk.getSections()).anyMatch(section -> !section.hasOnlyAir()), "READY cannot be only a registry entry: " + id);
// The 240 sample is the stored upper climate in a FULL chunk, independent of which safe ledge was found.
String climateBiome = chunk.getNoiseBiome(landing.getX() >> 2, 60, landing.getZ() >> 2).unwrapKey().orElseThrow().identifier().toString();
helper.assertTrue(expectedSurfaceBiomes.contains(climateBiome.substring("minecraft:".length())),
"The FULL chunk must contain its requested upper climate: " + id + " / " + climateBiome);
String landingBiome = level.getBiome(landing).unwrapKey().orElseThrow().identifier().toString();
samplePoints.put(id, landing);
observedIslands.add(Map.of("id", id, "parent", island.parent(), "climate", island.climate(),
"centre", List.of(island.centerX(), island.centerZ()), "diameter", island.diameter(),
"landing", coordinates(landing), "landing_biome", landingBiome, "upper_biome", climateBiome));
}
private void verifyMarker() {
helper.assertTrue(level.getBlockState(marker).is(Blocks.DIAMOND_BLOCK), "An expansion must preserve the parent player-edit witness");
}
private void verifyJournal() throws IOException {
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory());
var reloaded = new ExpansionJournal(directory, level.getSeed(), generator.initialDiameter(), generator.rootGeneration());
helper.assertTrue(reloaded.islands().equals(session.islands()), "Re-reading the saved journal must preserve all region identities and states");
report.put("journal_reloaded_regions", reloaded.islands().size());
}
private void prepareRestartWitness() throws IOException {
BlockPos gold = samplePoints.get("beta").above(6);
while (!level.getBlockState(gold).isAir() && gold.getY() < level.getMaxY() - 1) gold = gold.above();
helper.assertTrue(gold.getY() < level.getMaxY() - 1 && level.getBlockState(gold).isAir(), "The restart witness needs empty space above beta");
helper.assertTrue(level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3), "Write the restart witness in the first process");
var snapshot = new LinkedHashMap<String, Object>();
snapshot.put("world_directory", worldDirectory(level));
snapshot.put("gold_witness", coordinates(gold));
snapshot.put("journal_sha256", sha256(Files.readString(journalFile(level))));
snapshot.put("islands", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, session.islands()).getOrThrow());
snapshot.put("generator_codec", ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow());
snapshot.put("blocked_sites", ExpansionIsland.CODEC.listOf().encodeStart(JsonOps.INSTANCE, blockedSites).getOrThrow());
List<Map<String, Object>> samples = new ArrayList<>();
for (var island : session.islands()) samples.add(sample(helper, generator, island, samplePoints.get(island.id())));
snapshot.put("samples", samples);
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush the first process's world and marker before writing restart evidence");
snapshot.put("world_flushed_before_shutdown", true);
report.put("restart_snapshot", snapshot);
}
private void prepareStructureSearch() {
ExpansionIsland root = session.island("sanctuary");
int limit = (root.terrainRadius() + 15) / 16;
// STARTS only, centre outwards. Only an accepted structure's region is later requested at FULL.
for (int ring = 0; ring <= limit; ring++) for (int x = -ring; x <= ring; x++) for (int z = -ring; z <= ring; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) == ring && root.contains(x * 16 + 8, z * 16 + 8)) structureSearch.add(new ChunkPos(x, z));
}
report.put("structure_search_candidate_chunks", structureSearch.size());
report.put("native_structure_status", "search_in_progress");
}
private boolean observeNativeStructure() {
int end = Math.min(structureSearch.size(), structureIndex + 16);
while (structureIndex < end) {
ChunkPos position = structureSearch.get(structureIndex++);
var chunk = level.getChunkSource().getChunk(position.x(), position.z(), ChunkStatus.STRUCTURE_STARTS, true);
helper.assertTrue(chunk != null, "A structure-start request must complete");
for (StructureStart start : chunk.getAllStarts().values()) {
if (!start.isValid()) continue;
validStarts++;
String name = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getKey(start.getStructure()).toString();
if (!name.startsWith("minecraft:")) continue;
Map<String, Object> evidence = materializeAndFindLoot(start, name);
if (!evidence.isEmpty()) {
report.put("native_structure", evidence);
report.put("native_structure_observed", true);
report.put("native_structure_status", "natural_start_with_placed_vanilla_loot_observed");
report.put("native_structure_search_complete", structureIndex == structureSearch.size());
return true;
}
}
}
report.put("native_structure_starts_seen", validStarts);
report.put("structure_start_chunks_inspected", structureIndex);
if (structureIndex == structureSearch.size()) {
report.put("native_structure_search_complete", true);
report.put("native_structure_status", "not_observed_after_complete_scan");
report.put("native_structure_limitation", "No naturally accepted native structure with placed vanilla loot was observed after "
+ structureIndex + " root-island start chunks and " + validStarts + " valid starts. "
+ "This is missing engine evidence for structures, not a positive generation or loot result.");
if (OPTIONAL_STRUCTURE_WITNESS) return true;
}
helper.assertTrue(structureIndex < structureSearch.size(), "No naturally accepted native structure with placed vanilla loot was observed; "
+ validStarts + " valid starts. This is a missing engine proof, not a successful structures test.");
return false;
}
private Map<String, Object> materializeAndFindLoot(StructureStart start, String name) {
var bounds = start.getBoundingBox();
List<LevelChunk> chunks = new ArrayList<>();
for (int x = bounds.minX() >> 4; x <= bounds.maxX() >> 4; x++) {
for (int z = bounds.minZ() >> 4; z <= bounds.maxZ() >> 4; z++) chunks.add(full(x, z));
}
for (LevelChunk chunk : chunks) {
for (var entry : chunk.getBlockEntities().entrySet()) {
if (!(entry.getValue() instanceof RandomizableContainerBlockEntity container) || container.getLootTable() == null) continue;
BlockPos pos = entry.getKey();
if (start.getPieces().stream().noneMatch(piece -> piece.getBoundingBox().isInside(pos))) continue;
String loot = container.getLootTable().identifier().toString();
if (!loot.startsWith("minecraft:")) continue;
return Map.of("structure", name, "loot_table", loot, "container", coordinates(pos),
"placed_block", level.getBlockState(pos).toString(), "full_chunks", chunks.size(),
"mode", "natural candidate accepted by production ExpansionStructures, native decoration");
}
}
return Map.of();
}
private LevelChunk full(int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Expected real FULL chunk at " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private void writeReport() throws IOException {
writeDiagnostic(level, false, report);
}
}
private static Map<String, Object> sample(GameTestHelper helper, ExpansionChunkGenerator generator, ExpansionIsland island, BlockPos point) {
ServerLevel level = helper.getLevel();
LevelChunk chunk = full(helper, level, point.getX() >> 4, point.getZ() >> 4);
var random = generator.randomState(island);
var base = generator.getBaseColumn(point.getX(), point.getZ(), level, random);
StringBuilder storedBlocks = new StringBuilder(), baseBlocks = new StringBuilder(), biomes = new StringBuilder(), resolvedBiomes = new StringBuilder();
var resolver = generator.source().createUncachedResolver(random);
for (int y = level.getMinY(); y <= level.getMaxY(); y++) {
storedBlocks.append(chunk.getBlockState(new BlockPos(point.getX(), y, point.getZ()))).append('\n');
baseBlocks.append(base.getBlock(y)).append('\n');
if ((y & 3) == 0) {
biomes.append(chunk.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
resolvedBiomes.append(resolver.getNoiseBiome(point.getX() >> 2, y >> 2, point.getZ() >> 2).getRegisteredName()).append('\n');
}
}
return Map.of("id", island.id(), "point", coordinates(point), "stored_column_sha256", sha256(storedBlocks.toString()),
"base_column_sha256", sha256(baseBlocks.toString()), "stored_biomes_sha256", sha256(biomes.toString()),
"resolved_biomes_sha256", sha256(resolvedBiomes.toString()), "base_height",
generator.getBaseHeight(point.getX(), point.getZ(), Heightmap.Types.WORLD_SURFACE_WG, level, random));
}
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Expected saved FULL chunk at " + new ChunkPos(x, z));
return (LevelChunk) chunk;
}
private static String sha256(String text) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
}
private static Path journalFile(ServerLevel level) {
return level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + ((ExpansionChunkGenerator) level.getChunkSource().getGenerator()).journalDirectory()).resolve(ExpansionJournal.FILE_NAME);
}
private static String worldDirectory(ServerLevel level) { return level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize().toString(); }
private static Path diagnostic(ServerLevel level, boolean reload) {
return Path.of("diagnostics", (Boolean.getBoolean("sanctuary.test.unified") ? "expansion-alpha13-" : "expansion-alpha12-1-") + (reload ? "reload-" : "") + "seed-" + level.getSeed() + ".json");
}
private static void writeDiagnostic(ServerLevel level, boolean reload, Map<String, Object> report) throws IOException {
Path output = diagnostic(level, reload);
Files.createDirectories(output.getParent());
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
}
private static BlockPos position(JsonArray coordinates) { return new BlockPos(coordinates.get(0).getAsInt(), coordinates.get(1).getAsInt(), coordinates.get(2).getAsInt()); }
private static List<Integer> coordinates(BlockPos pos) { return List.of(pos.getX(), pos.getY(), pos.getZ()); }
}
@@ -0,0 +1,452 @@
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.heritage.HeritagePiece19;
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer19;
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
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.Locale;
import java.util.Map;
import java.util.Set;
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.item.Item;
import net.minecraft.world.item.Items;
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.TrapDoorBlock;
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.LootParams;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Native creation/reload proof confined to a disposable generation19 starter island. */
public final class Heritage19WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The heritage proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 19, "Use a fresh generation19 test world; do not convert an old save");
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 HeritagePlan19 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.heritagePlan19();
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 local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
}
void advance() {
try {
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
if (nextChunk < chunks.size()) {
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
"Heritage footprint must reach native FULL: " + pos);
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage19 loaded {}/{} footprint chunks", nextChunk, chunks.size());
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
}
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
if (!reload) {
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
report.put("world_flushed_before_shutdown", true);
} else {
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's 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("Heritage19 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() == 19
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation19 codec lost its experimental switch");
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
"entry", coords(HeritagePiece19.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
verifyLootTables(helper, report);
if (reload) {
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second 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 selected another world");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
}
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof HeritagePiece19, "Heritage loaded a foreign piece type");
var box = piece.getBoundingBox();
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
box.intersectingChunks().forEach(uniqueChunks::add);
}
}
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece19.locator(site)));
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
}
void finishDisabled() throws IOException {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
report.put("empty_plan_verified", true); report.put("passed", true);
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save 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(HeritageStructures19.KEY);
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
int blocks = 0, chests = 0, pieces = 0, supports = 0;
for (var start : starts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece19.encodeSite(s)));
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece19.encodeSegment(s)));
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
}
var loot = new ArrayList<Map<String, Object>>();
for (var value : start.getPieces()) {
var piece = (HeritagePiece19) value; pieces++;
var tag = piece.createTag(context); var restored = new HeritagePiece19(context, tag);
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 20);
boolean rejected = false; try { new HeritagePiece19(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Unknown schema must fail closed");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
var expected = rendered(piece); blocks += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
if (reload && at.equals(gold)) continue;
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
}
if (piece.isSite()) {
supports += verifySiteBearing(helper, generator, plan, piece.site());
if (!reload && gold == null) {
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
}
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
}
}
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
}
report.put("starts", summaries); 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("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
report.put("stage", "verify_read_only_landings");
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
int greatestSpan = plan.links().stream().mapToInt(Heritage19WorldGameTests::routeSpan).max().orElse(0);
report.put("greatest_single_route_span", greatestSpan);
if (level.getSeed() == 0 && generator.initialDiameter() == 724) {
int requiredSpan = generator.initialDiameter() / 4;
report.put("reference_minimum_route_span", requiredSpan);
helper.assertTrue(greatestSpan >= requiredSpan, "Seed0 Medium must demonstrate an island-scale route spanning at least "
+ requiredSpan + " blocks on X or Z; got " + greatestSpan);
}
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece19.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
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(HeritagePiece19 piece) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
var writer = new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
}
};
if (piece.isSite()) HeritageRenderer19.render(piece.site(), writer); else HeritageRenderer19.renderSegment(piece.segment(), writer);
return new Rendered(blocks, chests);
}
private static boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
var level = helper.getLevel(); var expected = rendered(new HeritagePiece19(site)); var doors = new HashMap<BlockPos, BlockState>();
expected.blocks().forEach((at, state) -> {
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
});
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
try {
var entry = HeritagePiece19.locator(site);
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
var reached = walk(helper, entry, inside); int targets = 0;
for (var target : HeritageRenderer19.targets(site)) {
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
}
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
var result = new ArrayList<Map<String, Object>>();
for (var link : plan.links()) {
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
var first = link.path().getFirst(); var last = link.path().getLast();
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
int present = 0, missing = 0;
for (int i = 0; i < link.path().size(); i++) {
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
var cell = cells.get(column(point.x(), point.z()));
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
var actual = helper.getLevel().getBlockState(at);
if (cell.railPresent()) {
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
}
}
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
}
return result;
}
private static int routeSpan(HeritagePlan19.Link link) {
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
return Math.max(spanX, spanZ);
}
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
}
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
int checked = 0;
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
int x = site.minX() + dx, z = site.minZ() + dz;
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
for (int below = 1; below <= 2; below++) {
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
checked++;
}
}
return checked;
}
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
var result = new ArrayList<Map<String, Object>>();
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
var e = landmark.entry(); int checked = 0;
for (int side = -1; side <= 1; side++) {
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
var at = new BlockPos(x, y, z);
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
if (y == e.y() - 1) assertRock(helper, at);
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
checked++;
}
}
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
}
return result;
}
private static void assertRock(GameTestHelper helper, BlockPos at) {
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
}
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
for (var kind : HeritagePlan19.Kind.values()) {
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
}
report.put("tool_tables_sampled_separately", result);
}
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
private static Item tool(HeritagePlan19.Kind kind) {
return switch (kind) {
case OBSERVATORY -> Items.SPYGLASS;
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
case FREIGHT_STATION -> Items.MINECART;
case COMMUNAL_DEPOT -> Items.IRON_AXE;
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
case PUMP_STATION -> Items.BUCKET;
case GREENHOUSE -> Items.IRON_HOE;
case VOID_DOCK -> Items.LEAD;
case PHOTO_CLUB -> Items.BOOK;
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
};
}
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 or unsupported exterior entrance: " + 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 site traversal escaped its 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 long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
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("heritage19-create-proof.json"); }
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "heritage19-" + (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); }
}
}
@@ -0,0 +1,556 @@
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.heritage.HeritagePiece20;
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer20;
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece20;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer20;
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.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
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.item.Item;
import net.minecraft.world.item.Items;
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.TrapDoorBlock;
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.LootParams;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Native creation/reload proof confined to a disposable generation20 starter island. */
public final class Heritage20WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The heritage proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 20, "Use a fresh generation20 test world; do not convert an old save");
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 HeritagePlan19 plan;
final boolean reload;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
final List<StructureStart> starts = new ArrayList<>();
final List<StructureStart> ruinsStarts = 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.heritagePlan19();
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 local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
}
void advance() {
try {
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
if (nextChunk < chunks.size()) {
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
"Heritage footprint must reach native FULL: " + pos);
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage20 loaded {}/{} footprint chunks", nextChunk, chunks.size());
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
}
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
if (!reload) {
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
report.put("world_flushed_before_shutdown", true);
} else {
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's 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("Heritage20 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() == 20
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation20 codec lost its experimental switch");
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
"entry", coords(HeritagePiece20.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
verifyLootTables(helper, report);
if (reload) {
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second 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 selected another world");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
}
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof HeritagePiece20, "Heritage loaded a foreign piece type");
var box = piece.getBoundingBox();
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
box.intersectingChunks().forEach(uniqueChunks::add);
}
}
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece20.locator(site)));
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
var ruins = generator.ruinsPlan17();
report.put("ruins_plan_sha256", hash(ruins.networks().toString()));
if (reload) helper.assertTrue(previous.get("ruins_plan_sha256").getAsString().equals(hash(ruins.networks().toString())), "Reload changed forgotten room planning");
helper.assertTrue(!ruins.isEmpty(), "Reference20 must demonstrate native experimental room architecture");
var ruinsType = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var ruinsOrigins = new HashSet<ChunkPos>();
for (var network : ruins.networks()) {
var origin = new ChunkPos(network.startChunkX(), network.startChunkZ()); if (!ruinsOrigins.add(origin)) continue;
var nativeStart = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(ruinsType.value());
helper.assertTrue(nativeStart != null && nativeStart.isValid(), "Missing native20 forgotten network: " + network.id());
ruinsStarts.add(nativeStart);
for (var piece : nativeStart.getPieces()) {
helper.assertTrue(piece instanceof RuinsPiece20, "Generation20 loaded a historical forgotten piece");
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
}
}
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
}
void finishDisabled() throws IOException {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
report.put("empty_plan_verified", true); report.put("passed", true);
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save 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(HeritageStructures19.KEY);
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
int blocks = 0, chests = 0, pieces = 0, supports = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : starts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece20.encodeSite(s)));
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece20.encodeSegment(s)));
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
}
var loot = new ArrayList<Map<String, Object>>();
for (var value : start.getPieces()) {
var piece = (HeritagePiece20) value; pieces++;
var tag = piece.createTag(context); var restored = new HeritagePiece20(context, tag);
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 21);
boolean rejected = false; try { new HeritagePiece20(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Unknown schema must fail closed");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
var expected = rendered(piece); blocks += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
if (reload && at.equals(gold)) continue;
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), reload ? Set.of(gold) : Set.of()));
if (piece.isSite()) {
supports += verifySiteBearing(helper, generator, plan, piece.site());
if (!reload && gold == null) {
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
}
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
}
}
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
}
report.put("connected_blocks", connections);
helper.assertTrue(connections.panes() > 0 && connections.stairs() > 0 && connections.nativeOracles() > 0,
"Reference20 needs real panes/stairs validated by vanilla neighbor rules");
if (level.getSeed() == 0 && generator.initialDiameter() == 724)
helper.assertTrue(connections.crossChunkPaneLinks() > 0, "Reference20 must demonstrate a real pane join across a chunk boundary");
verifyRuins();
report.put("starts", summaries); 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("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
report.put("stage", "verify_read_only_landings");
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
int greatestSpan = plan.links().stream().mapToInt(Heritage20WorldGameTests::routeSpan).max().orElse(0);
report.put("greatest_single_route_span", greatestSpan);
if (level.getSeed() == 0 && generator.initialDiameter() == 724) {
int requiredSpan = generator.initialDiameter() / 4;
report.put("reference_minimum_route_span", requiredSpan);
helper.assertTrue(greatestSpan >= requiredSpan, "Seed0 Medium must demonstrate an island-scale route spanning at least "
+ requiredSpan + " blocks on X or Z; got " + greatestSpan);
}
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece20.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
report.put("locate", coords(located.getFirst()));
}
void verifyRuins() {
var context = StructurePieceSerializationContext.fromLevel(level);
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var summaries = new ArrayList<Map<String, Object>>(); int count = 0, blockCount = 0, lootCount = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : ruinsStarts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); var digest = hash(saved.toString());
var loaded = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(loaded != null && loaded.createTag(context, origin).equals(saved), "Forgotten20 native start NBT changed");
var expectedRooms = generator.ruinsPlan17().networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z()).flatMap(n -> n.rooms().stream()).toList();
var actualRooms = start.getPieces().stream().map(RuinsPiece20.class::cast).map(RuinsPiece20::room).toList();
helper.assertTrue(expectedRooms.size() == actualRooms.size() && new HashSet<>(expectedRooms).equals(new HashSet<>(actualRooms)), "Forgotten20 native rooms differ from plan");
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("ruins_starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(digest), "Forgotten20 native start changed on a second JVM load");
}
for (var raw : start.getPieces()) {
var piece = (RuinsPiece20) raw; var room = piece.room(); count++;
var tag = piece.createTag(context); var roundtrip = new RuinsPiece20(context, tag);
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(tag), "Forgotten20 piece lost saved parameters");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true).getReferencesForStructure(registry.value()).contains(origin.pack()), "Forgotten20 chunk lost native references"));
var expected = rendered(room); blockCount += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
helper.assertTrue(piece.getBoundingBox().isInside(entry.getKey()), "Forgotten20 renderer escaped saved bounds");
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Forgotten20 native geometry changed at " + entry.getKey());
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), Set.of()));
for (var support : room.supports()) assertRock(helper, new BlockPos(support.x(), support.minY() - 1, support.z()));
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Forgotten20 missing deferred chest");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(chest.table().equals(entity.getLootTable()) && chest.seed() == entity.getLootTableSeed(), "Forgotten20 consumed/changed saved loot"); lootCount++;
}
}
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", digest, "pieces", start.getPieces().size()));
}
report.put("ruins_starts", summaries); report.put("ruins_native_pieces", count); report.put("ruins_native_blocks_checked", blockCount);
report.put("ruins_unopened_deferred_chests", lootCount); report.put("ruins_connections", connections);
report.put("ruins_access", generator.ruinsPlan17().networks().stream().map(n -> verifyAccess(helper, n)).toList());
}
}
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
private static Rendered rendered(HeritagePiece20 piece) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
var writer = new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
}
};
if (piece.isSite()) HeritageRenderer20.render(piece.site(), writer); else HeritageRenderer20.renderSegment(piece.segment(), writer);
return new Rendered(blocks, chests);
}
private static Rendered rendered(RuinsPlan17.Room room) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
RuinsRenderer20.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 = RuinsPiece20.locator(access); var reached = walk(helper, start, inside);
int targets = 0;
for (var room : network.rooms()) for (var target : RuinsRenderer20.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 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 boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
var level = helper.getLevel(); var expected = rendered(new HeritagePiece20(site)); var doors = new HashMap<BlockPos, BlockState>();
expected.blocks().forEach((at, state) -> {
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
});
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
try {
var entry = HeritagePiece20.locator(site);
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
var reached = walk(helper, entry, inside); int targets = 0;
for (var target : HeritageRenderer20.targets(site)) {
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
}
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
var result = new ArrayList<Map<String, Object>>();
for (var link : plan.links()) {
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
var first = link.path().getFirst(); var last = link.path().getLast();
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
int present = 0, missing = 0;
for (int i = 0; i < link.path().size(); i++) {
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
var cell = cells.get(column(point.x(), point.z()));
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
var actual = helper.getLevel().getBlockState(at);
if (cell.railPresent()) {
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
}
}
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
}
return result;
}
private static int routeSpan(HeritagePlan19.Link link) {
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
return Math.max(spanX, spanZ);
}
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
}
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
int checked = 0;
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
int x = site.minX() + dx, z = site.minZ() + dz;
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
for (int below = 1; below <= 2; below++) {
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
checked++;
}
}
return checked;
}
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
var result = new ArrayList<Map<String, Object>>();
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
var e = landmark.entry(); int checked = 0;
for (int side = -1; side <= 1; side++) {
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
var at = new BlockPos(x, y, z);
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
if (y == e.y() - 1) assertRock(helper, at);
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
checked++;
}
}
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
}
return result;
}
private static void assertRock(GameTestHelper helper, BlockPos at) {
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
}
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
for (var kind : HeritagePlan19.Kind.values()) {
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
}
report.put("tool_tables_sampled_separately", result);
}
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
private static Item tool(HeritagePlan19.Kind kind) {
return switch (kind) {
case OBSERVATORY -> Items.SPYGLASS;
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
case FREIGHT_STATION -> Items.MINECART;
case COMMUNAL_DEPOT -> Items.IRON_AXE;
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
case PUMP_STATION -> Items.BUCKET;
case GREENHOUSE -> Items.IRON_HOE;
case VOID_DOCK -> Items.LEAD;
case PHOTO_CLUB -> Items.BOOK;
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
};
}
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 or unsupported exterior entrance: " + 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 site traversal escaped its 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 long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
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("heritage20-create-proof.json"); }
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "heritage20-" + (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); }
}
}
@@ -0,0 +1,554 @@
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.heritage.HeritagePiece21;
import fr.koka.sanctuary.worldgen.heritage.HeritagePlan19;
import fr.koka.sanctuary.worldgen.heritage.HeritageRenderer21;
import fr.koka.sanctuary.worldgen.heritage.HeritageStructures19;
import fr.koka.sanctuary.worldgen.ruins.RuinsPiece20;
import fr.koka.sanctuary.worldgen.ruins.RuinsPlan17;
import fr.koka.sanctuary.worldgen.ruins.RuinsRenderer20;
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.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
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.item.Item;
import net.minecraft.world.item.Items;
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.TrapDoorBlock;
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.LootParams;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Native creation/reload proof confined to a disposable generation21 starter island. */
public final class Heritage21WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void heritagePersistsWithRealDestinations(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The heritage proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 21, "Use a fresh generation21 test world; do not convert an old save");
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 HeritagePlan19 plan;
final boolean reload;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
final List<StructureStart> starts = new ArrayList<>();
final List<StructureStart> ruinsStarts = 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.heritagePlan19();
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 local starts, saved sites and routes, player-sized entrances, supports and unopened chests on the starter island. "
+ "All14 tool tables are sampled separately without consuming a saved chest. Door states are restored after traversal. "
+ "No personal save or expansion is opened; one disposable outer-wall edit is checked in a second JVM. "
+ "Missing rails are intentional; this proof checks walking and saved rail shapes, not an uninterrupted minecart journey.");
}
void advance() {
try {
if (!prepared) { report.put("stage", "prepare"); prepare(); prepared = true; if (plan.isEmpty()) { finishDisabled(); return; } }
if (nextChunk < chunks.size()) {
report.put("stage", "load_full_chunks"); var pos = chunks.get(nextChunk++);
helper.assertTrue(level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true) instanceof LevelChunk,
"Heritage footprint must reach native FULL: " + pos);
if (nextChunk % 20 == 0) SanctuaryMod.LOGGER.info("Heritage21 loaded {}/{} footprint chunks", nextChunk, chunks.size());
helper.runAtTickTime(helper.getTick() + 1, this::advance); return;
}
report.put("stage", "verify_native_geometry"); verifyPlaced(); report.put("passed", true);
if (!reload) {
helper.assertTrue(gold != null && level.getBlockState(gold).isCollisionShapeFullBlock(level, gold), "Gold witness must replace a solid wall away from circulation");
level.setBlock(gold, Blocks.GOLD_BLOCK.defaultBlockState(), 3); report.put("gold_witness", coords(gold));
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native starts, unopened loot and the wall edit");
report.put("world_flushed_before_shutdown", true);
} else {
helper.assertTrue(level.getBlockState(gold).is(Blocks.GOLD_BLOCK), "Reload replaced the player's 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("Heritage21 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() == 21
&& copy.experimentalStructures() == generator.experimentalStructures(), "Generation21 codec lost its experimental switch");
report.put("generator_codec", encoded); report.put("plan_sha256", planHash(plan));
report.put("planned_sites", plan.sites().stream().map(site -> Map.of("id", site.id(), "kind", site.kind().name(),
"entry", coords(HeritagePiece21.locator(site)), "floor_y", site.floorY(), "foundation_depth", site.foundationDepth(), "footings", site.supports().size())).toList());
report.put("planned_links", plan.links().stream().map(link -> Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(), "path", pathCoords(link))).toList());
report.put("planning_samples", plan.samples()); report.put("planning_columns", plan.columns()); report.put("planning_diagnostics", plan.diagnostics());
helper.assertTrue(plan.samples() <= HeritagePlan19.MAX_SAMPLES && plan.columns() <= HeritagePlan19.MAX_COLUMNS, "Heritage planning exceeded its budget");
verifyLootTables(helper, report);
if (reload) {
previous = JsonParser.parseString(Files.readString(proof(level))).getAsJsonObject();
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("world_flushed_before_shutdown").getAsBoolean(), "Reload needs a successful flushed creation proof");
helper.assertTrue(previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload must use a second 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 selected another world");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("plan_sha256").getAsString().equals(planHash(plan)), "Saved settings or planned identities changed on reload");
if (previous.has("gold_witness")) gold = readPos(previous.getAsJsonArray("gold_witness"));
}
if (!generator.experimentalStructures() || !level.structureManager().shouldGenerateStructures()) {
helper.assertTrue(plan.isEmpty() && plan.segments().isEmpty() && plan.links().isEmpty(), "Disabled generation published heritage geometry"); return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no heritage site");
report.put("present_site_kinds", plan.sites().stream().map(s -> s.kind().name()).distinct().sorted().toList());
report.put("missing_site_kinds", java.util.Arrays.stream(HeritagePlan19.Kind.values()).filter(k -> plan.sites().stream().noneMatch(s -> s.kind() == k)).map(Enum::name).toList());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var origins = new HashSet<ChunkPos>(); var uniqueChunks = new HashSet<ChunkPos>();
plan.sites().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
plan.segments().forEach(s -> origins.add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
for (var origin : origins.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList()) {
var chunk = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true);
var start = chunk.getStartForStructure(structure.value());
helper.assertTrue(start != null && start.isValid(), "Accepted site/route has no native start at " + origin); starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof HeritagePiece21, "Heritage loaded a foreign piece type");
var box = piece.getBoundingBox();
helper.assertTrue(Math.abs((long) (box.minX() >> 4) - origin.x()) <= 7 && Math.abs((long) (box.maxX() >> 4) - origin.x()) <= 7
&& Math.abs((long) (box.minZ() >> 4) - origin.z()) <= 7 && Math.abs((long) (box.maxZ() >> 4) - origin.z()) <= 7, "Native start references a distant route endpoint");
box.intersectingChunks().forEach(uniqueChunks::add);
}
}
for (var site : plan.sites()) uniqueChunks.add(ChunkPos.containing(HeritagePiece21.locator(site)));
for (var link : plan.links()) for (var point : link.path()) uniqueChunks.add(new ChunkPos(point.x() >> 4, point.z() >> 4));
helper.assertTrue(uniqueChunks.size() <= 1024, "Bounded proof footprint exceeds1024 chunks");
var ruins = generator.ruinsPlan17();
report.put("ruins_plan_sha256", hash(ruins.networks().toString()));
if (reload) helper.assertTrue(previous.get("ruins_plan_sha256").getAsString().equals(hash(ruins.networks().toString())), "Reload changed forgotten room planning");
helper.assertTrue(!ruins.isEmpty(), "Reference20 must demonstrate native experimental room architecture");
var ruinsType = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var ruinsOrigins = new HashSet<ChunkPos>();
for (var network : ruins.networks()) {
var origin = new ChunkPos(network.startChunkX(), network.startChunkZ()); if (!ruinsOrigins.add(origin)) continue;
var nativeStart = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(ruinsType.value());
helper.assertTrue(nativeStart != null && nativeStart.isValid(), "Missing native20 forgotten network: " + network.id());
ruinsStarts.add(nativeStart);
for (var piece : nativeStart.getPieces()) {
helper.assertTrue(piece instanceof RuinsPiece20, "Generation21 loaded a historical forgotten piece");
piece.getBoundingBox().intersectingChunks().forEach(uniqueChunks::add);
}
}
chunks.addAll(uniqueChunks.stream().sorted(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z)).toList());
}
void finishDisabled() throws IOException {
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(HeritageStructures19.KEY);
var start = level.getChunk(0, 0, ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(structure.value());
helper.assertTrue(start == null || !start.isValid(), "Disabled heritage emitted a native start");
helper.assertTrue(generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false) == null, "Disabled heritage is locatable");
report.put("empty_plan_verified", true); report.put("passed", true);
if (!reload) { helper.assertTrue(level.getServer().saveEverything(true, true, true), "Save 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(HeritageStructures19.KEY);
var context = StructurePieceSerializationContext.fromLevel(level); var summaries = new ArrayList<Map<String, Object>>();
int blocks = 0, chests = 0, pieces = 0, supports = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : starts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); String nativeHash = hash(saved.toString());
var copy = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(copy != null && copy.isValid() && copy.createTag(context, origin).equals(saved), "Native start failed an exact NBT roundtrip");
var expectedPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
plan.sites().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece21.encodeSite(s)));
plan.segments().stream().filter(s -> s.startChunkX() == origin.x() && s.startChunkZ() == origin.z()).forEach(s -> expectedPayloads.add(HeritagePiece21.encodeSegment(s)));
var actualPayloads = new HashSet<net.minecraft.nbt.CompoundTag>();
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(nativeHash), "Reload changed saved geometry or native reference count");
}
var loot = new ArrayList<Map<String, Object>>();
for (var value : start.getPieces()) {
var piece = (HeritagePiece21) value; pieces++;
var tag = piece.createTag(context); var restored = new HeritagePiece21(context, tag);
helper.assertTrue(restored.createTag(context).equals(tag), "Saved piece parameters changed on native load");
actualPayloads.add(tag.getCompound("SanctuaryHeritage").orElseThrow());
var corrupt = tag.copy(); corrupt.getCompound("SanctuaryHeritage").orElseThrow().putInt("Schema", 22);
boolean rejected = false; try { new HeritagePiece21(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Unknown schema must fail closed");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true)
.getReferencesForStructure(structure.value()).contains(origin.pack()), "FULL chunk lost its native structure reference"));
var expected = rendered(piece); blocks += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue(); helper.assertTrue(piece.getBoundingBox().isInside(at), "Renderer escaped saved bounds");
if (reload && at.equals(gold)) continue;
helper.assertTrue(level.getBlockState(at).is(wanted.getBlock()), "Clipping or decoration changed heritage at " + at + ": expected " + wanted + " got " + level.getBlockState(at));
helper.assertTrue(!wanted.is(Blocks.SPAWNER), "Heritage must not create spawners");
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), reload ? Set.of(gold) : Set.of()));
if (piece.isSite()) {
supports += verifySiteBearing(helper, generator, plan, piece.site());
if (!reload && gold == null) {
gold = expected.blocks().entrySet().stream().filter(e -> isOuterWall(piece.site(), e.getKey()) && e.getValue().isCollisionShapeFullBlock(level, e.getKey()))
.map(Map.Entry::getKey).sorted(Comparator.<BlockPos>comparingInt(p -> p.getY()).thenComparingInt(p -> p.getX()).thenComparingInt(p -> p.getZ())).findFirst().orElse(null);
}
} else for (var cell : piece.segment().cells()) { supports++; assertRock(helper, new BlockPos(cell.x(), cell.minY() - 1, cell.z())); }
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Deferred chest is missing");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(entity.getLootTable() != null && entity.getLootTable().equals(chest.table()) && entity.getLootTableSeed() == chest.seed(), "Deferred loot table/seed was lost or consumed");
if (piece.isSite()) helper.assertTrue(chest.table().equals(lootKey(piece.site().kind())), "Site uses another kind's tool table");
loot.add(Map.of("position", coords(chest.position()), "table", chest.table().identifier().toString(), "seed", chest.seed())); chests++;
}
}
helper.assertTrue(actualPayloads.equals(expectedPayloads) && start.getPieces().size() == expectedPayloads.size(), "Native start lost or duplicated planned pieces");
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", nativeHash, "pieces", start.getPieces().size(), "unopened_chests", loot));
}
report.put("connected_blocks", connections);
helper.assertTrue(connections.panes() > 0 && connections.stairs() > 0 && connections.nativeOracles() > 0,
"Reference20 needs real panes/stairs validated by vanilla neighbor rules");
if (level.getSeed() == 0 && generator.initialDiameter() == 724)
helper.assertTrue(connections.crossChunkPaneLinks() > 0, "Reference20 must demonstrate a real pane join across a chunk boundary");
verifyRuins();
report.put("starts", summaries); 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("stage", "verify_site_routes"); var sites = new ArrayList<Map<String, Object>>(); for (var site : plan.sites()) sites.add(verifyAccess(helper, site)); report.put("native_sites", sites);
report.put("stage", "verify_read_only_landings");
report.put("read_only_landmarks", verifyReadOnlyLandings(helper, generator, plan));
report.put("stage", "verify_island_routes"); report.put("native_links", verifyLinks(helper, plan));
int greatestSpan = plan.links().stream().mapToInt(Heritage21WorldGameTests::routeSpan).max().orElse(0);
report.put("greatest_single_route_span", greatestSpan);
var reachedIds = new HashSet<String>(); plan.links().forEach(l -> { reachedIds.add(l.fromId()); reachedIds.add(l.toId()); });
report.put("connected_destination_ids", reachedIds.stream().sorted().toList());
report.put("isolated_destination_ids", plan.sites().stream().map(HeritagePlan19.Site::id).filter(id -> !reachedIds.contains(id)).toList());
report.put("connected_city_arrivals", plan.landmarks().stream().filter(l -> l.kind() == HeritagePlan19.LandmarkKind.CITY && reachedIds.contains(l.id())).map(HeritagePlan19.Landmark::id).toList());
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(HeritageStructures19.KEY) && plan.sites().stream().anyMatch(s -> HeritagePiece21.locator(s).equals(located.getFirst())), "Locate must lead to a real exterior site entry");
report.put("locate", coords(located.getFirst()));
}
void verifyRuins() {
var context = StructurePieceSerializationContext.fromLevel(level);
var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(RuinsStructures17.KEY);
var summaries = new ArrayList<Map<String, Object>>(); int count = 0, blockCount = 0, lootCount = 0;
var connections = NativeConnections20.Stats.empty();
for (var start : ruinsStarts) {
var origin = start.getChunkPos(); var saved = start.createTag(context, origin); var digest = hash(saved.toString());
var loaded = StructureStart.loadStaticStart(context, saved, level.getSeed());
helper.assertTrue(loaded != null && loaded.createTag(context, origin).equals(saved), "Forgotten20 native start NBT changed");
var expectedRooms = generator.ruinsPlan17().networks().stream().filter(n -> n.startChunkX() == origin.x() && n.startChunkZ() == origin.z()).flatMap(n -> n.rooms().stream()).toList();
var actualRooms = start.getPieces().stream().map(RuinsPiece20.class::cast).map(RuinsPiece20::room).toList();
helper.assertTrue(expectedRooms.size() == actualRooms.size() && new HashSet<>(expectedRooms).equals(new HashSet<>(actualRooms)), "Forgotten20 native rooms differ from plan");
if (reload) {
JsonObject prior = null;
for (var value : previous.getAsJsonArray("ruins_starts")) { var item = value.getAsJsonObject(); var at = item.getAsJsonArray("start_chunk"); if (at.get(0).getAsInt() == origin.x() && at.get(1).getAsInt() == origin.z()) prior = item; }
helper.assertTrue(prior != null && prior.get("native_start_sha256").getAsString().equals(digest), "Forgotten20 native start changed on a second JVM load");
}
for (var raw : start.getPieces()) {
var piece = (RuinsPiece20) raw; var room = piece.room(); count++;
var tag = piece.createTag(context); var roundtrip = new RuinsPiece20(context, tag);
helper.assertTrue(roundtrip.room().equals(room) && roundtrip.createTag(context).equals(tag), "Forgotten20 piece lost saved parameters");
piece.getBoundingBox().intersectingChunks().forEach(at -> helper.assertTrue(level.getChunk(at.x(), at.z(), ChunkStatus.FULL, true).getReferencesForStructure(registry.value()).contains(origin.pack()), "Forgotten20 chunk lost native references"));
var expected = rendered(room); blockCount += expected.blocks().size();
for (var entry : expected.blocks().entrySet()) {
helper.assertTrue(piece.getBoundingBox().isInside(entry.getKey()), "Forgotten20 renderer escaped saved bounds");
helper.assertTrue(level.getBlockState(entry.getKey()).is(entry.getValue().getBlock()), "Forgotten20 native geometry changed at " + entry.getKey());
}
connections = connections.add(NativeConnections20.verify(helper, expected.blocks(), Set.of()));
for (var support : room.supports()) assertRock(helper, new BlockPos(support.x(), support.minY() - 1, support.z()));
for (var chest : expected.chests()) {
helper.assertTrue(level.getBlockEntity(chest.position()) instanceof RandomizableContainerBlockEntity, "Forgotten20 missing deferred chest");
var entity = (RandomizableContainerBlockEntity) level.getBlockEntity(chest.position());
helper.assertTrue(chest.table().equals(entity.getLootTable()) && chest.seed() == entity.getLootTableSeed(), "Forgotten20 consumed/changed saved loot"); lootCount++;
}
}
summaries.add(Map.of("start_chunk", List.of(origin.x(), origin.z()), "native_start_sha256", digest, "pieces", start.getPieces().size()));
}
report.put("ruins_starts", summaries); report.put("ruins_native_pieces", count); report.put("ruins_native_blocks_checked", blockCount);
report.put("ruins_unopened_deferred_chests", lootCount); report.put("ruins_connections", connections);
report.put("ruins_access", generator.ruinsPlan17().networks().stream().map(n -> verifyAccess(helper, n)).toList());
}
}
private record Chest(BlockPos position, ResourceKey<LootTable> table, long seed) {}
private record Rendered(Map<BlockPos, BlockState> blocks, List<Chest> chests) {}
private static Rendered rendered(HeritagePiece21 piece) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
int x0 = piece.isSite() ? piece.site().minX() : piece.segment().minX();
int y0 = piece.isSite() ? piece.site().floorY() : piece.segment().minY();
int z0 = piece.isSite() ? piece.site().minZ() : piece.segment().minZ();
var writer = new LostCityRenderer.BlockWriter() {
@Override public void set(int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x0 + x, y0 + y, z0 + z), state); }
@Override public void chest(int x, int y, int z, ResourceKey<LootTable> table, long seed) {
var at = new BlockPos(x0 + x, y0 + y, z0 + z); blocks.put(at, Blocks.CHEST.defaultBlockState()); chests.add(new Chest(at, table, RandomSource.create(seed).nextLong()));
}
};
if (piece.isSite()) HeritageRenderer21.render(piece.site(), writer); else HeritageRenderer21.renderSegment(piece.segment(), writer);
return new Rendered(blocks, chests);
}
private static Rendered rendered(RuinsPlan17.Room room) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
RuinsRenderer20.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 = RuinsPiece20.locator(access); var reached = walk(helper, start, inside);
int targets = 0;
for (var room : network.rooms()) for (var target : RuinsRenderer20.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 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 boolean isOuterWall(HeritagePlan19.Site site, BlockPos pos) {
return pos.getY() >= site.floorY() + 2 && pos.getY() <= site.floorY() + 3
&& (pos.getX() == site.minX() + 2 || pos.getX() == site.maxX() - 2 || pos.getZ() == site.minZ() + 2 || pos.getZ() == site.maxZ() - 2)
&& Math.abs(pos.getX() - site.entry().x()) + Math.abs(pos.getZ() - site.entry().z()) > 6;
}
private static Map<String, Object> verifyAccess(GameTestHelper helper, HeritagePlan19.Site site) {
var level = helper.getLevel(); var expected = rendered(new HeritagePiece21(site)); var doors = new HashMap<BlockPos, BlockState>();
expected.blocks().forEach((at, state) -> {
if ((state.getBlock() instanceof DoorBlock || state.getBlock() instanceof TrapDoorBlock) && state.hasProperty(BlockStateProperties.OPEN)) doors.put(at, level.getBlockState(at));
});
doors.forEach((at, state) -> level.setBlock(at, state.setValue(BlockStateProperties.OPEN, true), 2));
try {
var entry = HeritagePiece21.locator(site);
Predicate<BlockPos> inside = p -> p.equals(entry) || p.getX() >= site.minX() && p.getX() <= site.maxX()
&& p.getZ() >= site.minZ() && p.getZ() <= site.maxZ() && p.getY() >= site.floorY() - 4 && p.getY() < site.maxY();
var reached = walk(helper, entry, inside); int targets = 0;
for (var target : HeritageRenderer21.targets(site)) {
var at = new BlockPos(site.minX() + target.x(), site.floorY() + target.y(), site.minZ() + target.z());
helper.assertTrue(reached.contains(at), "Site " + site.id() + " cannot reach " + target.name() + " at " + at); targets++;
}
return Map.of("id", site.id(), "kind", site.kind().name(), "entry", coords(entry), "standing_cells", reached.size(), "walkable_targets", targets);
} finally { doors.forEach((at, state) -> level.setBlock(at, state, 2)); }
}
private static List<Map<String, Object>> verifyLinks(GameTestHelper helper, HeritagePlan19 plan) {
helper.assertTrue(!plan.links().isEmpty(), "Reference island needs a real route between accepted destinations");
var entries = new HashMap<String, HeritagePlan19.Entry>(); plan.sites().forEach(s -> entries.put(s.id(), s.entry())); plan.landmarks().forEach(s -> entries.put(s.id(), s.entry()));
var cells = new HashMap<Long, HeritagePlan19.Cell>(); plan.segments().forEach(s -> s.cells().forEach(c -> cells.put(column(c.x(), c.z()), c)));
var result = new ArrayList<Map<String, Object>>();
for (var link : plan.links()) {
var from = entries.get(link.fromId()); var to = entries.get(link.toId());
helper.assertTrue(from != null && to != null, "Route points to an absent destination");
var first = link.path().getFirst(); var last = link.path().getLast();
helper.assertTrue(first.x() == from.x() && first.z() == from.z() && first.floorY() + 1 == from.y()
&& last.x() == to.x() && last.z() == to.z() && last.floorY() + 1 == to.y(), "Route does not meet its real entrances");
int present = 0, missing = 0;
for (int i = 0; i < link.path().size(); i++) {
var point = link.path().get(i); var at = new BlockPos(point.x(), point.floorY() + 1, point.z());
helper.assertTrue(standable(helper.getLevel(), at), "Ballast route " + link.id() + " is blocked at " + at);
if (i > 0) { var prior = link.path().get(i - 1); if (point.floorY() > prior.floorY()) helper.assertTrue(clearBody(helper.getLevel(), new BlockPos(prior.x(), prior.floorY() + 2, prior.z())), "Route slope cannot be climbed by a player"); }
var cell = cells.get(column(point.x(), point.z()));
if (cell != null && cell.rail() != HeritagePlan19.Rail.NONE) {
var actual = helper.getLevel().getBlockState(at);
if (cell.railPresent()) {
helper.assertTrue(actual.is(Blocks.RAIL), "Saved existing rail disappeared");
helper.assertTrue(actual.getValue(BlockStateProperties.RAIL_SHAPE).name().equals(cell.rail().name()), "Native rail update changed the intended slope or curve at " + at); present++;
} else { helper.assertTrue(!actual.is(Blocks.RAIL), "An intentionally missing rail regenerated"); missing++; }
}
}
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow() - link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
result.add(Map.of("id", link.id(), "from", link.fromId(), "to", link.toId(), "kind", link.kind().name(), "length", link.path().size(),
"span_x", spanX, "span_z", spanZ, "existing_rails", present, "deliberately_missing_rails", missing, "path", pathCoords(link)));
}
return result;
}
private static int routeSpan(HeritagePlan19.Link link) {
int spanX = link.path().stream().mapToInt(HeritagePlan19.Point::x).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::x).min().orElseThrow();
int spanZ = link.path().stream().mapToInt(HeritagePlan19.Point::z).max().orElseThrow()
- link.path().stream().mapToInt(HeritagePlan19.Point::z).min().orElseThrow();
return Math.max(spanX, spanZ);
}
private static List<List<Integer>> pathCoords(HeritagePlan19.Link link) {
return link.path().stream().map(point -> List.of(point.x(), point.floorY(), point.z())).toList();
}
private static int verifySiteBearing(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan, HeritagePlan19.Site site) {
int checked = 0;
// The planner certifies a nine-point bearing grid, either directly below its slab or below a saved short footing.
for (int dx : new int[] {1, 8, 15}) for (int dz : new int[] {1, 8, 15}) {
int x = site.minX() + dx, z = site.minZ() + dz;
var support = site.supports().stream().filter(s -> s.x() == x && s.z() == z).findFirst();
int bottom = support.map(HeritagePlan19.Support::minY).orElse(site.minY());
for (int below = 1; below <= 2; below++) {
var at = new BlockPos(x, bottom - below, z); assertRock(helper, at);
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Site's certified bearing layer is not published in the decoration mask: " + site.id() + " at " + at);
helper.assertTrue(!plan.contains(at.getX(), at.getY(), at.getZ()), "Original bearing rock became a writable heritage volume: " + at);
checked++;
}
}
return checked;
}
private static List<Map<String, Object>> verifyReadOnlyLandings(GameTestHelper helper, SanctuaryChunkGenerator generator, HeritagePlan19 plan) {
var used = new HashSet<String>(); plan.links().forEach(link -> { used.add(link.fromId()); used.add(link.toId()); });
var result = new ArrayList<Map<String, Object>>();
for (var landmark : plan.landmarks()) if (used.contains(landmark.id())) {
var e = landmark.entry(); int checked = 0;
for (int side = -1; side <= 1; side++) {
int x = e.x() + ((e.facing() & 1) == 0 ? side : 0), z = e.z() + ((e.facing() & 1) == 1 ? side : 0);
for (int y = e.y() - 2; y <= e.y() + 2; y++) {
var at = new BlockPos(x, y, z);
helper.assertTrue(plan.protects(x, y, z) && generator.protectsCityDecoration(at), "Read-only arrival lost its decoration mask: " + landmark.id() + " at " + at);
helper.assertTrue(!plan.contains(x, y, z), "Heritage gained write ownership of a historical arrival: " + landmark.id() + " at " + at);
if (y == e.y() - 1) assertRock(helper, at);
if (y >= e.y()) helper.assertTrue(helper.getLevel().getBlockState(at).isAir(), "Decoration obstructed a certified historical arrival: " + at);
checked++;
}
}
result.add(Map.of("id", landmark.id(), "entry", List.of(e.x(), e.y(), e.z()), "read_only_cells", checked));
}
return result;
}
private static void assertRock(GameTestHelper helper, BlockPos at) {
helper.assertTrue(helper.getLevel().getBlockState(at).isSolid() && helper.getLevel().getFluidState(at).isEmpty(), "Lost original rock bearing at " + at);
}
private static void verifyLootTables(GameTestHelper helper, Map<String, Object> report) {
var result = new ArrayList<Map<String, Object>>(); var level = helper.getLevel();
var params = new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN, new Vec3(0, 160, 0)).create(LootContextParamSets.CHEST);
for (var kind : HeritagePlan19.Kind.values()) {
var table = level.getServer().reloadableRegistries().getLootTable(lootKey(kind));
helper.assertTrue(table != LootTable.EMPTY, "Heritage tool table did not load: " + kind);
for (long seed : new long[] {0, 42, -1}) helper.assertTrue(table.getRandomItems(params, seed).stream().anyMatch(stack -> stack.is(tool(kind))), "Tool table is empty or lacks its expected primary tool: " + kind);
result.add(Map.of("kind", kind.name(), "table", lootKey(kind).identifier().toString(), "sampled_seeds", List.of(0, 42, -1)));
}
report.put("tool_tables_sampled_separately", result);
}
private static ResourceKey<LootTable> lootKey(HeritagePlan19.Kind kind) { return ResourceKey.create(Registries.LOOT_TABLE, SanctuaryMod.id("chests/heritage19/" + kind.name().toLowerCase(Locale.ROOT))); }
private static Item tool(HeritagePlan19.Kind kind) {
return switch (kind) {
case OBSERVATORY -> Items.SPYGLASS;
case POST_OFFICE, EXPEDITION_OFFICE, RADIO_RELAY -> Items.COMPASS;
case SUBSTATION, IMPOSSIBLE_SERVICE -> Items.CLOCK;
case FREIGHT_STATION -> Items.MINECART;
case COMMUNAL_DEPOT -> Items.IRON_AXE;
case RAIL_WORKSHOP -> Items.IRON_PICKAXE;
case PUMP_STATION -> Items.BUCKET;
case GREENHOUSE -> Items.IRON_HOE;
case VOID_DOCK -> Items.LEAD;
case PHOTO_CLUB -> Items.BOOK;
case CONSTRUCTION_REFUGE -> Items.IRON_SHOVEL;
};
}
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 or unsupported exterior entrance: " + 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 site traversal escaped its 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 long column(int x, int z) { return (long) x << 32 ^ z & 0xffffffffL; }
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("heritage21-create-proof.json"); }
private static String planHash(HeritagePlan19 plan) { return hash(plan.sites().toString() + plan.landmarks() + plan.links() + plan.segments()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "heritage21-" + (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); }
}
}
@@ -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); }
}
}
@@ -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; }
}
@@ -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); }
}
}
@@ -0,0 +1,93 @@
package fr.koka.sanctuary.gametest;
import java.util.Map;
import java.util.Set;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.FenceBlock;
import net.minecraft.world.level.block.IronBarsBlock;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.block.WallBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
/** Independent oracle: actual native neighbor-update rules, with datapack tags loaded by the test server. */
public final class NativeConnections20 {
private NativeConnections20() {}
public record Stats(int panes, int fences, int walls, int stairs, int nativeOracles, int externalNeighbors,
int crossChunkPaneLinks, int settledExternalStates, int unsettledExternalStates) {
public Stats add(Stats other) { return new Stats(panes + other.panes, fences + other.fences, walls + other.walls,
stairs + other.stairs, nativeOracles + other.nativeOracles, externalNeighbors + other.externalNeighbors,
crossChunkPaneLinks + other.crossChunkPaneLinks, settledExternalStates + other.settledExternalStates,
unsettledExternalStates + other.unsettledExternalStates); }
public static Stats empty() { return new Stats(0, 0, 0, 0, 0, 0, 0, 0, 0); }
}
public static Stats verify(GameTestHelper helper, Map<BlockPos, BlockState> geometry, Set<BlockPos> edits) {
var level = helper.getLevel(); int panes = 0, fences = 0, walls = 0, stairs = 0, oracles = 0, exterior = 0, cross = 0, settled = 0, unsettled = 0;
for (var entry : geometry.entrySet()) {
var pos = entry.getKey(); var wanted = entry.getValue(); if (edits.contains(pos)) continue;
boolean pane = wanted.getBlock() instanceof IronBarsBlock, fence = wanted.getBlock() instanceof FenceBlock;
boolean wall = wanted.getBlock() instanceof WallBlock, stair = wanted.getBlock() instanceof StairBlock;
if (!pane && !fence && !wall && !stair) continue;
if (pane) panes++; if (fence) fences++; if (wall) walls++; if (stair) stairs++;
var actual = level.getBlockState(pos);
helper.assertTrue(sameNonConnectionState(actual, wanted),
"Material, orientation, half or waterlogging changed at " + pos + ": expected " + wanted + " got " + actual);
boolean knownNeighborhood = true;
for (var side : Direction.values()) {
var at = pos.relative(side); var expectedNeighbor = geometry.get(at); var actualNeighbor = level.getBlockState(at);
// A changed neighbor's shape can propagate a legitimate exterior connection, e.g. stacked walls.
if (edits.contains(at) || (expectedNeighbor == null ? !actualNeighbor.isAir()
: !expectedNeighbor.equals(actualNeighbor))) knownNeighborhood = false;
}
var updated = actual;
for (var side : Direction.values()) {
var at = pos.relative(side);
updated = updated.updateShape(level, level, pos, side, at, level.getBlockState(at), RandomSource.create(20));
}
if (knownNeighborhood) {
helper.assertTrue(actual.equals(wanted), "Native detailed state differs inside known geometry at " + pos
+ ": expected " + wanted + " got " + actual);
helper.assertTrue(updated.equals(actual), "Vanilla neighbor rules disagree with generation20 connections at " + pos
+ ": saved " + actual + " native " + updated);
oracles++;
} else {
exterior++;
if (updated.equals(actual)) { settled++; oracles++; }
else {
// A fresh structure has not necessarily received an update from untouched surrounding rock.
// Permit only its exact planned state until that update; a partially/wrongly changed state fails.
helper.assertTrue(actual.equals(wanted) && sameNonConnectionState(updated, wanted),
"Exterior connection is neither the untouched plan nor a vanilla fixed point at " + pos
+ ": plan " + wanted + " saved " + actual + " native " + updated);
unsettled++;
}
}
if (pane) for (var side : new Direction[] {Direction.EAST, Direction.SOUTH}) {
var at = pos.relative(side); var neighbor = level.getBlockState(at);
if (neighbor.getBlock() instanceof IronBarsBlock && (pos.getX() >> 4 != at.getX() >> 4 || pos.getZ() >> 4 != at.getZ() >> 4)) {
var property = side == Direction.EAST ? BlockStateProperties.EAST : BlockStateProperties.SOUTH;
helper.assertTrue(actual.getValue(property), "Pane connection disappeared exactly at a native chunk boundary: " + pos);
cross++;
}
}
}
return new Stats(panes, fences, walls, stairs, oracles, exterior, cross, settled, unsettled);
}
/** Normalize only geometry-derived properties. Everything else, including fluids and facing, stays exact. */
private static boolean sameNonConnectionState(BlockState actual, BlockState wanted) {
if (!actual.is(wanted.getBlock())) return false;
if (wanted.getBlock() instanceof IronBarsBlock || wanted.getBlock() instanceof FenceBlock) {
for (var property : new net.minecraft.world.level.block.state.properties.BooleanProperty[] {
BlockStateProperties.NORTH, BlockStateProperties.EAST, BlockStateProperties.SOUTH, BlockStateProperties.WEST})
actual = actual.setValue(property, wanted.getValue(property));
} else if (wanted.getBlock() instanceof WallBlock) {
for (var property : WallBlock.PROPERTY_BY_DIRECTION.values()) actual = actual.setValue(property, wanted.getValue(property));
actual = actual.setValue(WallBlock.UP, wanted.getValue(WallBlock.UP));
} else if (wanted.getBlock() instanceof StairBlock) actual = actual.setValue(StairBlock.SHAPE, wanted.getValue(StairBlock.SHAPE));
return actual.equals(wanted);
}
}
@@ -37,9 +37,11 @@ public final class PopulationDiagnostics {
static void verifyLegacyIsolation(GameTestHelper helper) {
var level = helper.getLevel();
var generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
var binding = binding(level);
var generator = binding.generator();
var capacity = IslandCapacity.forGenerator(generator);
helper.assertTrue(capacity != null && capacity.players() == Integer.getInteger("sanctuary.test.players", 10),
helper.assertTrue(capacity != null && capacity.players() == (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator unified
? unified.capacity().players() : Integer.getInteger("sanctuary.test.players", 10)),
"The engine must create the requested production world preset");
helper.assertFalse(HydrologyRuntime.enabled(generator) || NaturalHydrologyRuntime.enabled(generator)
|| LayeredHydrologyRuntime.enabled(generator) || WoodlandHydrologyRuntime.enabled(generator)
@@ -56,24 +58,34 @@ public final class PopulationDiagnostics {
helper.assertFalse(new PopulationDecorationsFeature().place(level, old, RandomSource.create(0), BlockPos.ZERO),
"New cave decorations must refuse older saved worlds");
}
helper.assertTrue(generator.getBiomeSource() instanceof PopulationIslandBiomeSource,
"The biome palette must use the new versioned resource family");
helper.assertTrue(generator.getBiomeSource().possibleBiomes().size() == 10
&& generator.getBiomeSource().possibleBiomes().stream().allMatch(b -> b.getRegisteredName().startsWith("sanctuary:population_")),
"All ten layers of the ecology must remain available at each capacity");
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator) {
helper.assertTrue(generator.getBiomeSource() instanceof SanctuaryBiomeSource
&& generator.getBiomeSource().possibleBiomes().size() == (((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 generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
var binding = binding(level);
var generator = binding.generator();
var capacity = IslandCapacity.forGenerator(generator);
var density = level.registryAccess().lookupOrThrow(Registries.DENSITY_FUNCTION).getOrThrow(
ResourceKey.create(Registries.DENSITY_FUNCTION, SanctuaryMod.id("population_" + capacity.players()))).value();
helper.assertTrue(density instanceof PopulationIslandDensity, "Inspect the actual compiled population density");
var island = (PopulationIslandDensity) density;
helper.assertTrue(island.capacity() == capacity, "Noise settings and density must agree on saved capacity");
var random = level.getChunkSource().randomState();
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), level.getSeed(), generator.generatorSettings().value());
var random = binding.random();
var replay = RandomState.create(level.registryAccess().lookupOrThrow(Registries.NOISE), random.seed(), generator.generatorSettings().value());
int step = Math.max(8, (int) Math.ceil(capacity.terrainLimit() / 32.0));
int extent = (int) Math.ceil(capacity.terrainLimit() / (double) step) * step;
int solidColumns = 0, outerColumns = 0, emptyColumns = 0;
@@ -132,7 +144,7 @@ public final class PopulationDiagnostics {
static void verifyExterior(GameTestHelper helper) {
var level = helper.getLevel();
var capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator());
var capacity = IslandCapacity.forGenerator(binding(level).generator());
int edge = Math.floorDiv(capacity.terrainLimit() + 48, 16) + 1;
for (int[] point : new int[][]{{edge,0},{-edge-1,0},{0,edge},{0,-edge-1},{256,0},{-257,0}}) {
var chunk = full(helper, level, point[0], point[1]);
@@ -168,6 +180,7 @@ public final class PopulationDiagnostics {
final GameTestHelper helper;
final ServerLevel level;
final NoiseBasedChunkGenerator generator;
final RandomState random;
final IslandCapacity capacity;
final List<PopulationHydrologyRuntime.RegionPlan> regions = new ArrayList<>();
final Map<Long, LevelChunk> chunks = new TreeMap<>();
@@ -190,13 +203,37 @@ public final class PopulationDiagnostics {
Context(GameTestHelper helper) {
this.helper = helper; level = helper.getLevel();
generator = (NoiseBasedChunkGenerator) level.getChunkSource().getGenerator();
var binding = binding(level);
generator = binding.generator();
random = binding.random();
capacity = IslandCapacity.forGenerator(generator);
}
void prepare() throws IOException {
helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= 4,
"Initial spawn must plan only nearby regions, not materialize the large island: " + regionsAtInitialSpawn);
boolean heritage = level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator sanctuary
&& (sanctuary.rootGeneration() == 22 || (sanctuary.rootGeneration() == 19 || sanctuary.rootGeneration() == 20 || sanctuary.rootGeneration() == 21) && sanctuary.experimentalStructures());
// Generation19 explicitly prospects destinations across the root island before publishing them.
// A consulted hydrology region is a bounded immutable plan, not a loaded/generated chunk.
// Historical generators retain their original near-spawn contract.
int regionLimit = 4;
if (heritage) {
int extent = capacity.terrainLimit();
var first = capacity.regionAt(-extent, -extent); var last = capacity.regionAt(extent, extent);
regionLimit = 0;
for (int x = first.x(); x <= last.x(); x++) for (int z = first.z(); z <= last.z(); z++) {
var region = new IslandCapacity.Region(x, z);
double nearestX = Math.max(0, Math.max(region.minX(), -region.maxX()));
double nearestZ = Math.max(0, Math.max(region.minZ(), -region.maxZ()));
if (Math.hypot(nearestX, nearestZ) < extent) regionLimit++;
}
helper.assertTrue(regionLimit <= 9, "Heritage starter hydrology must fit within nine local regions");
var plan = ((SanctuaryChunkGenerator) level.getChunkSource().getGenerator()).heritagePlan19();
helper.assertTrue(plan.columns() <= fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.MAX_COLUMNS
&& plan.samples() <= fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.MAX_SAMPLES,
"Island-wide heritage prospecting exceeded its independent column/probe budget");
}
helper.assertTrue(regionsAtInitialSpawn > 0 && regionsAtInitialSpawn <= regionLimit,
"Initial hydrology exceeded its geographic planning scope (limit " + regionLimit + "): " + 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));
@@ -207,7 +244,7 @@ public final class PopulationDiagnostics {
Set<Long> requested = new TreeSet<>();
for (var target : targets) {
SanctuaryMod.LOGGER.info("Capacity {}: inspecting hydrology in region {}", capacity.players(), target);
var region = PopulationHydrologyRuntime.regionPlan(generator, level.getChunkSource().randomState(), target);
var region = PopulationHydrologyRuntime.regionPlan(generator, random, target);
regions.add(region);
var plan = region.water();
checkOwnership(region);
@@ -257,7 +294,7 @@ public final class PopulationDiagnostics {
for (int dx = -144; dx <= 144; dx += 48) for (int dz = -144; dz <= 144; dz += 48) {
int x = ox+dx, z=oz+dz;
int top = generator.getBaseHeight(x,z,net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE,
level,level.getChunkSource().randomState());
level,random);
if (top > highest) { highest=top; bestX=x; bestZ=z; }
}
helper.assertTrue(highest > 48, "Every inspected region must contain real natural terrain");
@@ -294,7 +331,7 @@ public final class PopulationDiagnostics {
"Hydrology must never be truncated at or written across region ownership: "+cell);
helper.assertTrue(ids.contains(cell.featureId()),"Each retained cell needs its complete owning feature");
for(int y=cell.bedY()-cell.sedimentDepth()-1;y<=cell.bedY();y++)
helper.assertTrue(level.getChunkSource().randomState().sampleBlockValueUncached(
helper.assertTrue(random.sampleBlockValueUncached(
generator.generatorSettings().value().noiseRouter().finalDensity(),cell.x(),y,cell.z())>0,
"Regional translation must retain the real natural support below sediment: "+cell);
}
@@ -388,7 +425,7 @@ public final class PopulationDiagnostics {
}
float natural(BlockPos p) {
return level.getChunkSource().randomState().sampleBlockValueUncached(
return random.sampleBlockValueUncached(
generator.generatorSettings().value().noiseRouter().finalDensity(), p.getX(), p.getY(), p.getZ());
}
@@ -428,7 +465,7 @@ public final class PopulationDiagnostics {
helper.assertTrue(natural(p) > 0 && actual.isCollisionShapeFullBlock(level, p),
"Every sediment layer and its two natural supports must remain full after generation and ticks: " + p + "=" + actual);
if (d >= cell.sedimentDepth()) continue;
BlockState expected = PopulationMaterials.sedimentBlock(cell, level.getSeed(), d);
BlockState expected = PopulationMaterials.sedimentBlock(cell, random.seed(), d);
boolean ore = actual.is(Blocks.COAL_ORE) || actual.is(Blocks.DEEPSLATE_COAL_ORE)
|| actual.is(Blocks.IRON_ORE) || actual.is(Blocks.DEEPSLATE_IRON_ORE)
|| actual.is(Blocks.COPPER_ORE) || actual.is(Blocks.DEEPSLATE_COPPER_ORE)
@@ -575,10 +612,15 @@ public final class PopulationDiagnostics {
Files.createDirectories(output.getParent());
var report=new LinkedHashMap<String,Object>();
report.put("players",capacity.players());report.put("seed",level.getSeed());
report.put("terrain_seed", random.seed());
if (level.getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator unified) {
report.put("generation", 13); report.put("diameter", unified.initialDiameter());
report.put("rejected_decoration_writes", unified.rejectedDecorationWrites());
}
report.put("mod_version",FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString());
report.put("settings",generator.generatorSettings().getRegisteredName());
report.put("regions_at_initial_spawn",regionsAtInitialSpawn);
report.put("regions_cached_after_inspection",PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState()));
report.put("regions_cached_after_inspection",PopulationHydrologyRuntime.cachedRegionCount(random));
report.put("regions",regionReports);report.put("inspected_full_chunks",chunks.size());
report.put("retained_water_blocks",expectedWater.size());report.put("tested_springs",springs.size());
report.put("tested_spills",spills.size());report.put("fluid_ticks",elapsed);
@@ -597,6 +639,10 @@ public final class PopulationDiagnostics {
}
}
private static PopulationGenerationContext.Binding binding(ServerLevel level) {
return Objects.requireNonNull(PopulationGenerationContext.of(level), "This diagnostic requires Population or unified Sanctuary generation");
}
private static String json(Object value) {return new GsonBuilder().setPrettyPrinting().create().toJson(value)+"\n";}
private static BlockPos pos(PopulationHydrology.Position p) {return new BlockPos(p.x(),p.y(),p.z());}
private static BlockPos lavaPos(PopulationLavaDeposit.Position p) {return new BlockPos(p.x(),p.y(),p.z());}
@@ -34,12 +34,15 @@ final class PopulationPersistenceDiagnostics {
var presets = access.lookupOrThrow(Registries.WORLD_PRESET);
var standard = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
SanctuaryMod.id("sanctuary"))).value();
helper.assertTrue(IslandCapacity.forGenerator(standard.createWorldDimensions().overworld()) == IslandCapacity.TEN,
"The public Sanctuary preset defaults to the new large geometry");
helper.assertTrue(standard.createWorldDimensions().overworld() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified && unified.initialDiameter() == 724,
"The public Sanctuary preset defaults to the unified medium geometry");
var visible = presets.getOrThrow(WorldPresetTags.NORMAL).stream().map(holder -> holder.getRegisteredName())
.filter(name -> name.startsWith("sanctuary:")).collect(Collectors.toSet());
helper.assertTrue(visible.equals(Set.of("sanctuary:sanctuary", "sanctuary:sanctuary_5")),
"World creation must expose only small and large Sanctuary presets");
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);
@@ -52,7 +55,7 @@ final class PopulationPersistenceDiagnostics {
Files.createDirectories(testRoot);
LevelStorageSource storage = LevelStorageSource.createDefault(testRoot);
for (int players : new int[]{5, 10, 20, 100}) {
String presetId = players == 10 ? "sanctuary" : "sanctuary_" + players;
String presetId = "sanctuary_" + players;
WorldDimensions dimensions = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET,
SanctuaryMod.id(presetId))).value().createWorldDimensions();
helper.assertTrue(IslandCapacity.forGenerator(dimensions.overworld()) == IslandCapacity.fromPlayers(players),
@@ -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); }
}
}
@@ -0,0 +1,156 @@
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.expansion.ExpansionIsland;
import fr.koka.sanctuary.worldgen.PopulationGenerationContext;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.city.*;
import fr.koka.sanctuary.worldgen.heritage.*;
import fr.koka.sanctuary.worldgen.transit.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
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.resources.ResourceKey;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
import net.minecraft.world.level.storage.LevelResource;
/** Read-only admission audit, then native starts and reload in the disposable development world. */
public final class Selective22WorldGameTests {
@GameTest(maxTicks = 12000)
public void naturalSitesAndRefusalsPersist(GameTestHelper helper) throws Exception {
var level = helper.getLevel();
var generator = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 22, "Use a fresh22 development world");
var root = ExpansionIsland.origin(generator.initialDiameter(), level.getSeed(), 22);
var city = generator.cityPlacement21(root);
var heritage = generator.heritagePlan19();
var transit = generator.transitPlan21();
helper.assertTrue(city != null && city.reservation() == null && city.supports().isEmpty(), "No forced city envelope or rescue piers");
var binding = Objects.requireNonNull(PopulationGenerationContext.of(level));
var terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
int columns = 0;
for (var district : city.surface().districts()) for (var parcel : district.parcels()) {
for (int x = parcel.minX(); x <= parcel.maxX(); x++) for (int z = parcel.minZ(); z <= parcel.maxZ(); z++) {
int floor = parcel.surfaceY(x - parcel.minX(), z - parcel.minZ());
helper.assertTrue(Math.abs(floor - terrain.surfaceY(x, z)) <= 2, "City must fit natural relief"); columns++;
}
}
// The three samplers have independent bounded caches, just like production planning.
terrain = new HeritageTerrain21(root, binding.generator(), binding.random(), level, p -> false);
for (var site : heritage.sites()) {
helper.assertTrue(site.supports().isEmpty(), "No heritage rescue piers");
for (int x = site.minX(); x <= site.maxX(); x++) for (int z = site.minZ(); z <= site.maxZ(); z++) {
helper.assertTrue(Math.abs(site.floorY() - terrain.surfaceY(x, z)) <= 2, "Building cut/fill stays within two blocks");
helper.assertTrue(terrain.solid(x, site.minY() - 1, z) && terrain.solid(x, site.minY() - 2, z), "Every foundation column rests on original rock"); columns++;
}
}
helper.assertTrue(!transit.isEmpty(), "The reference island retains its core traversal");
var hall = generator.undergroundHall22();
helper.assertTrue(!hall.isEmpty() || generator.undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream())
.anyMatch(m -> m.kind() == LostCityUnderground16.Kind.EXPANSION_HALL), "Core hall exists with or without a surface town");
for (var module : transit.modules()) for (var support : module.supports())
helper.assertTrue(support.maxY() - support.minY() + 1 <= 16, "Core traversal retains bounded engineered supports");
if (!generator.experimentalStructures()) helper.assertTrue(heritage.isEmpty() && generator.ruinsPlan17().isEmpty(), "OFF removes optional catalogue, not core hall and traversal");
var gson = new GsonBuilder().setPrettyPrinting().create();
var encoded = ChunkGenerator.CODEC.encodeStart(RegistryOps.create(JsonOps.INSTANCE, level.registryAccess()), generator).getOrThrow();
var geometry = gson.toJsonTree(Map.of("city", city.surface().districts(), "heritage_sites", heritage.sites(),
"heritage_links", heritage.links(), "heritage_segments", heritage.segments(), "transit", transit.modules(), "independent_hall", hall.networks()));
var report = new LinkedHashMap<String, Object>();
report.put("seed", level.getSeed()); report.put("size", generator.initialDiameter());
report.put("process_id", ProcessHandle.current().pid()); report.put("generator_codec", encoded); report.put("geometry", geometry);
report.put("experimental", generator.experimentalStructures()); report.put("city_districts", city.surface().districts().size());
report.put("heritage_sites", heritage.sites().size()); report.put("heritage_links", heritage.links().size()); report.put("transit_modules", transit.modules().size());
report.put("independent_hall_networks", hall.networks().size());
report.put("core_entries", transit.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).map(m -> List.of(m.centerX(), m.surfaceY() + 1, m.minZ() - 2)).toList());
report.put("hall_entries", hall.networks().stream().map(n -> { var at = LostCityUndergroundStructures22.locator(n); return List.of(at.getX(), at.getY(), at.getZ()); }).toList());
report.put("natural_columns_audited", columns); report.put("city_diagnostics", city.diagnostics());
report.put("heritage_diagnostics", heritage.diagnostics()); report.put("transit_diagnostics", transit.diagnostic());
var starts = new ArrayList<StructureStart>();
var origins = new LinkedHashMap<ResourceKey<Structure>, Set<ChunkPos>>();
origins.put(LostCityStructures.KEY, new HashSet<>()); origins.put(HeritageStructures19.KEY, new HashSet<>()); origins.put(TransitStructures21.KEY, new HashSet<>()); origins.put(LostCityUndergroundStructures22.KEY, new HashSet<>());
city.surface().districts().forEach(d -> origins.get(LostCityStructures.KEY).add(new ChunkPos(d.startChunkX(), d.startChunkZ())));
heritage.sites().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
heritage.segments().forEach(s -> origins.get(HeritageStructures19.KEY).add(new ChunkPos(s.startChunkX(), s.startChunkZ())));
transit.modules().forEach(m -> origins.get(TransitStructures21.KEY).add(new ChunkPos(m.startChunkX(), m.startChunkZ())));
hall.networks().forEach(n -> origins.get(LostCityUndergroundStructures22.KEY).add(new ChunkPos(n.startChunkX(), n.startChunkZ())));
var chunks = new TreeSet<ChunkPos>(Comparator.comparingInt(ChunkPos::x).thenComparingInt(ChunkPos::z));
var nativeTags = new TreeMap<String, String>();
var context = StructurePieceSerializationContext.fromLevel(level);
for (var entry : origins.entrySet()) for (var origin : entry.getValue()) {
var type = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(entry.getKey()).value();
var start = level.getChunk(origin.x(), origin.z(), ChunkStatus.STRUCTURE_STARTS, true).getStartForStructure(type);
helper.assertTrue(start != null && start.isValid(), "Accepted geometry must have a native start");
for (var piece : start.getPieces()) {
helper.assertTrue(!(piece instanceof LostCityFoundationPiece16), "No artificial city foundation emitted");
if (entry.getKey().equals(HeritageStructures19.KEY)) helper.assertTrue(piece instanceof HeritagePiece21, "Use published21 architecture");
if (entry.getKey().equals(LostCityUndergroundStructures22.KEY)) helper.assertTrue(piece instanceof LostCityUndergroundPiece20, "Independent hall keeps published20 room geometry");
if (entry.getKey().equals(TransitStructures21.KEY)) helper.assertTrue(piece instanceof TransitPiece21 || piece instanceof TransitPiece22, "Published21 tunnel geometry with sealed22 corners");
piece.getBoundingBox().intersectingChunks().forEach(chunks::add);
}
var tag = start.createTag(context, origin);
var loaded = StructureStart.loadStaticStart(context, tag, level.getSeed());
helper.assertTrue(loaded != null && tag.equals(loaded.createTag(context, origin)), "Native start round trip");
nativeTags.put(entry.getKey().identifier() + "/" + origin.x() + "/" + origin.z(), tag.toString()); starts.add(start);
}
if (chunks.isEmpty()) chunks.add(new ChunkPos(0, 0));
report.put("native_starts", nativeTags); report.put("full_chunks", chunks.size());
Path world = level.getServer().getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path proof = world.resolve("selective22-create.json");
boolean reload = Boolean.getBoolean("sanctuary.test.expansionReload");
JsonObject previous = reload ? JsonParser.parseString(Files.readString(proof)).getAsJsonObject() : null;
if (reload) {
helper.assertTrue(previous.get("passed").getAsBoolean() && previous.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload follows a successful separate JVM");
helper.assertTrue(previous.get("generator_codec").equals(encoded) && previous.get("geometry").equals(geometry), "Generation, options and accepted/refused placements survive reload");
helper.assertTrue(previous.get("native_starts").equals(gson.toJsonTree(nativeTags)), "Native saved starts survive reload");
}
var iterator = chunks.iterator();
Runnable finish = () -> {
try {
// Refresh already-generated transit chunks: the earlier short FULL tickets can expire
// while the catalogue loads. The walk checks real native collision shapes, including turns.
var transitChunks = new HashSet<ChunkPos>();
transit.modules().forEach(m -> TransitPiece21.bounds(m).intersectingChunks().forEach(transitChunks::add));
for (var pos : transitChunks) level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
report.put("transit_native_walk", Transit21WorldGameTests.verifyAccess(helper, transit));
report.put("hall_native_access", Underground22NativeChecks.verify(helper, hall.isEmpty() ? generator.undergroundPlan16(root) : hall));
if (!heritage.sites().isEmpty()) {
var site = heritage.sites().getFirst();
var witness = new BlockPos(site.centerX(), site.floorY() - 1, site.centerZ());
if (reload) helper.assertTrue(level.getBlockState(witness).is(Blocks.GOLD_BLOCK), "Personal edit in disposable test survives reload");
else {
helper.assertTrue(level.getBlockState(witness).isSolid(), "Accepted building foundation generated in real FULL chunks");
level.setBlock(witness, Blocks.GOLD_BLOCK.defaultBlockState(), 3);
}
report.put("saved_edit", List.of(witness.getX(), witness.getY(), witness.getZ()));
}
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush native world before shutdown");
report.put("passed", true);
var directory = Path.of("diagnostics", "selective22-" + level.getSeed() + "-" + generator.initialDiameter()); Files.createDirectories(directory);
var text = gson.toJson(report) + "\n"; Files.writeString(directory.resolve(reload ? "reload.json" : "create.json"), text);
if (!reload) Files.writeString(proof, text);
helper.succeed();
} catch (Exception failure) { throw new IllegalStateException(failure); }
};
Runnable[] load = new Runnable[1];
load[0] = () -> {
if (!iterator.hasNext()) { finish.run(); return; }
var pos = iterator.next(); level.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, true);
helper.runAtTickTime(helper.getTick() + 1, () -> load[0].run());
};
load[0].run();
}
}
@@ -0,0 +1,453 @@
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.transit.TransitPiece21;
import fr.koka.sanctuary.worldgen.transit.TransitPlan21;
import fr.koka.sanctuary.worldgen.transit.TransitRenderer21;
import fr.koka.sanctuary.worldgen.transit.TransitStructures21;
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.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.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.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.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 generation21 starter island in a disposable development world. */
public final class Transit21WorldGameTests {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
@GameTest(maxTicks = 12000)
public void industrialTransitPersistsNatively(GameTestHelper helper) {
helper.assertTrue(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator,
"The industrial-transits proof requires a Sanctuary generator");
var generator = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
helper.assertTrue(generator.rootGeneration() == 21, "Use a fresh generation21 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 TransitPlan21 plan;
final boolean reload;
final Map<String, Object> report = new LinkedHashMap<>();
final List<ChunkPos> chunks = new ArrayList<>();
final List<StructureStart> starts = new ArrayList<>();
final Map<BlockPos, BlockState> allGeometry = new HashMap<>();
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.transitPlan21();
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("planning_diagnostic", plan.diagnostic());
report.put("scope", "Native starts, references, geometry, rock bearings, unopened loot and a player-sized stair walk between two surface gates. "
+ "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. Traversal remains inside the modules and certified portico approaches; underground one-block rises need real correctly oriented stairs. Only the certified natural portico approaches permit an ordinary one-block jump. No flight, ladder or external terrain shortcut is allowed. No 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("Industrial transit21: 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("Industrial transit21 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() == 21
&& copy.experimentalStructures() == generator.experimentalStructures(), "The generation21 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() <= TransitPlan21.MAX_SAMPLES, "Transit 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 generation21 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 industrial transit");
return;
}
helper.assertTrue(!plan.isEmpty(), "Enabled reference island has no experimental network");
helper.assertTrue(plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).count() == 2,
"A through-route needs two independent saved surface stairs");
int spanX = plan.modules().stream().mapToInt(TransitPlan21.Module::maxX).max().orElseThrow()
- plan.modules().stream().mapToInt(TransitPlan21.Module::minX).min().orElseThrow() + 1;
int spanZ = plan.modules().stream().mapToInt(TransitPlan21.Module::maxZ).max().orElseThrow()
- plan.modules().stream().mapToInt(TransitPlan21.Module::minZ).min().orElseThrow() + 1;
report.put("span_x", spanX); report.put("span_z", spanZ);
helper.assertTrue(Math.max(spanX, spanZ) >= generator.initialDiameter() * .4,
"The native transit reference must cross at least40% of the island's nominal diameter");
report.put("modules", plan.modules().size());
report.put("halls", plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.HALL).count());
var structure = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(TransitStructures21.KEY);
var origins = new HashSet<Long>(); var uniqueChunks = new HashSet<ChunkPos>();
for (var network : plan.modules()) {
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.kind() + " at " + network.centerX() + "," + network.centerZ());
starts.add(start);
for (var piece : start.getPieces()) {
helper.assertTrue(piece instanceof TransitPiece21, "Industrial transit loaded a foreign or historical piece type");
piece.getBoundingBox().intersectingChunks().forEach(at -> {
helper.assertTrue(Math.abs(at.x() - network.startChunkX()) <= 7 && Math.abs(at.z() - network.startChunkZ()) <= 7,
"Transit native start exceeds its local reference range");
uniqueChunks.add(at);
});
}
}
helper.assertTrue(uniqueChunks.size() <= 256, "The bounded root-only proof unexpectedly covers more than256 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(TransitStructures21.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(TransitStructures21.KEY);
var context = StructurePieceSerializationContext.fromLevel(level);
var sites = new ArrayList<Map<String, Object>>();
var modules = new ArrayList<Map<String, Object>>();
int blocks = 0, supports = 0, chests = 0, pieces = 0;
var bearingMasks = new HashMap<TransitPlan21.Module, Integer>();
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.modulesForChunk(origin.x(), origin.z());
var actualRooms = start.getPieces().stream().map(TransitPiece21.class::cast).map(TransitPiece21::module).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 = (TransitPiece21) piece; var room = roomPiece.module(); 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 TransitPiece21(context, roomTag);
helper.assertTrue(roundtrip.module().equals(room) && roundtrip.createTag(context).equals(roomTag), "The native room failed its exact saved-field roundtrip");
var corrupt = roomTag.copy(); var payload = corrupt.getCompound("SanctuaryModule").orElseThrow(); payload.putInt("Schema", 99);
boolean rejected = false;
try { new TransitPiece21(context, corrupt); } catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "An unknown generation21 piece schema must be refused");
var expected = rendered(room); blocks += expected.blocks().size();
expected.blocks().forEach((at, state) -> helper.assertTrue(allGeometry.putIfAbsent(at, state) == null,
"Two saved transit modules paint the same position: " + at));
modules.add(Map.of("kind", room.kind().name(), "min", List.of(room.minX(), room.minY(), room.minZ()),
"max", List.of(room.maxX(), room.maxY(), room.maxZ()), "floor_y", room.floorY(),
"surface_y", room.surfaceY(), "openings", room.openings(), "supports", room.supports().size(),
"start_chunk", List.of(room.startChunkX(), room.startChunkZ())));
for (var entry : expected.blocks().entrySet()) {
var at = entry.getKey(); var wanted = entry.getValue();
helper.assertTrue(TransitPiece21.bounds(room).isInside(at), "Renderer escaped its saved room/support bounds");
helper.assertTrue(plan.protects(at.getX(), at.getY(), at.getZ()) && generator.protectsCityDecoration(at),
"Transit construction/cleared air is missing its published decoration protection at " + at);
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");
}
supports += verifyBearings(helper, generator, room, bearingMasks);
for (var entry : expected.blocks().entrySet()) if (entry.getValue().is(Blocks.LANTERN)) {
var at = entry.getKey(); var state = level.getBlockState(at);
helper.assertTrue(state.canSurvive(level, at) && level.getBlockState(at.above()).isFaceSturdy(level, at.above(), Direction.DOWN),
"Transit lamp lost its complete ceiling bearing at " + at);
}
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() != TransitPlan21.Kind.ACCESS && room.kind() != TransitPlan21.Kind.JUNCTION)
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));
}
report.put("module_coordinates", modules);
report.put("starts", sites);
int weakSpan = 0, longestSpan = 0;
for (var room : plan.modules()) {
weakSpan = bearingMasks.get(room) == 15 ? 0 : weakSpan + 1;
longestSpan = Math.max(longestSpan, weakSpan);
helper.assertTrue(weakSpan <= 2, "Native deck spans more than 34 blocks without distributed rock bearings");
}
report.put("longest_span_without_full_bearings", longestSpan * 17);
var ores = new java.util.TreeMap<String, Integer>();
allGeometry.values().forEach(state -> {
var id = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString();
if (id.endsWith("_ore")) ores.merge(id, 1, Integer::sum);
});
report.put("finite_exposed_ores", ores);
var traversal = verifyAccess(helper, plan);
report.put("native_connections", NativeConnections20.verify(helper, allGeometry,
reload && gold != null ? Set.of(gold) : Set.of()));
report.put("module_coordinates", modules);
var located = generator.findNearestMapStructure(level, HolderSet.direct(structure), new BlockPos(0, 250, 0), 100, false);
helper.assertTrue(located != null && located.getSecond().is(TransitStructures21.KEY), "Native locate cannot find a saved industrial transit");
helper.assertTrue(plan.modules().stream().filter(r -> r.kind() == TransitPlan21.Kind.ACCESS)
.anyMatch(r -> TransitPiece21.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(TransitPlan21.Module room) {
var blocks = new HashMap<BlockPos, BlockState>(); var chests = new ArrayList<Chest>();
TransitRenderer21.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 int verifyBearings(GameTestHelper helper, SanctuaryChunkGenerator generator, TransitPlan21.Module room, Map<TransitPlan21.Module, Integer> masks) {
var level = helper.getLevel(); int checked = 0, quarters = 0;
for (int z = room.minZ();; z = Math.min(z + 4, room.maxZ())) {
for (int x = room.minX();; x = Math.min(x + 4, room.maxX())) {
int anchor = room.minY() - 1;
for (var support : room.supports()) if (support.x() == x && support.z() == z) anchor = support.minY() - 1;
var bearing = new BlockPos(x, anchor, z);
boolean supported = level.getBlockState(bearing).isSolid() && level.getFluidState(bearing).isEmpty()
&& level.getBlockState(bearing.below()).isSolid() && level.getFluidState(bearing.below()).isEmpty();
if (supported) {
quarters |= 1 << ((x < room.centerX() ? 0 : 1) + (z < room.centerZ() ? 0 : 2));
for (int dy = 0; dy >= -1; dy--) {
var at = new BlockPos(x, anchor + dy, z);
helper.assertTrue(level.getBlockState(at).isSolid() && level.getFluidState(at).isEmpty(),
"Transit bearing grid lost one of its two untouched rock layers at " + at);
helper.assertTrue(generator.protectsCityDecoration(at), "A certified transit rock dependency is unprotected at " + at);
checked++;
}
}
if (x == room.maxX()) break;
}
if (z == room.maxZ()) break;
}
helper.assertTrue(room.kind() == TransitPlan21.Kind.GALLERY || room.kind() == TransitPlan21.Kind.JUNCTION || quarters == 15, "The continuous deck lost its distributed rock bearings");
masks.put(room, quarters);
return checked;
}
static Map<String, Object> verifyAccess(GameTestHelper helper, TransitPlan21 plan) {
var level = helper.getLevel();
var accesses = plan.modules().stream().filter(m -> m.kind() == TransitPlan21.Kind.ACCESS).toList();
helper.assertTrue(accesses.size() == 2, "Native transit requires exactly two surface gates");
Predicate<BlockPos> inside = pos -> plan.contains(pos.getX(), pos.getY(), pos.getZ())
|| accesses.stream().anyMatch(m -> pos.getX() >= m.centerX() - 2 && pos.getX() <= m.centerX() + 2
&& pos.getZ() >= m.minZ() - 3 && pos.getZ() < m.minZ()
&& pos.getY() >= m.surfaceY() && pos.getY() <= m.surfaceY() + 2);
var start = TransitPiece21.locator(accesses.getFirst());
Predicate<BlockPos> naturalApproach = pos -> accesses.stream().anyMatch(m ->
pos.getX() >= m.centerX() - 2 && pos.getX() <= m.centerX() + 2
&& pos.getZ() >= m.minZ() - 3 && pos.getZ() < m.minZ()
&& pos.getY() >= m.surfaceY() && pos.getY() <= m.surfaceY() + 2);
var reached = walk(helper, start, inside, naturalApproach); int targets = 0;
var arrivals = new ArrayList<Map<String, Object>>();
for (var module : plan.modules()) for (var target : TransitRenderer21.targets(module)) {
var point = at(module, target.x(), target.y(), target.z());
helper.assertTrue(reached.containsKey(Foot.at(point)), "Native transit cannot walk from its first portico to "
+ module.kind() + "/" + target.name() + " at " + point);
targets++;
}
for (var access : accesses) {
var gate = TransitPiece21.locator(access);
helper.assertTrue(reached.containsKey(Foot.at(gate)), "The second independent surface gate is not connected by real stairs");
int approaches = 0;
for (int x = access.centerX() - 2; x <= access.centerX() + 2; x++) for (int z = access.minZ() - 3; z < access.minZ(); z++) {
var high = new BlockPos(x, access.surfaceY() + 1, z);
var low = high.below(); var position = standable(level, high) ? high : low;
helper.assertTrue(standable(level, position) && reached.containsKey(Foot.at(position)),
"The certified natural approach no longer joins its portico at " + position);
helper.assertTrue(level.getBlockState(position.below()).isSolid()
&& level.getFluidState(position.below()).isEmpty(), "A surface arrival is suspended above air or water");
approaches++;
}
arrivals.add(Map.of("gate", coords(gate), "roadway", coords(at(access, 8, 2, 8)),
"rise", access.surfaceY() - access.floorY(), "steps_from_first_gate", reached.get(Foot.at(gate)),
"natural_approach_cells", approaches));
}
return Map.of("surface_accesses", arrivals, "walkable_targets", targets, "standing_cells", reached.size(),
"surface_to_surface_steps", reached.get(Foot.at(TransitPiece21.locator(accesses.getLast()))),
"traversal_policy", "0.6 by1.8 native collision volume, half-block sidewalk steps; underground rises require a real forward-facing stair; natural portico approaches alone permit a normal one-block jump; no vertical moves");
}
/** Half-block standing elevations retain real sidewalk slabs without pretending an integer cell can hover over them. */
private record Foot(int x, int halfY, int z) {
static Foot at(BlockPos pos) { return new Foot(pos.getX(), pos.getY() * 2, pos.getZ()); }
double y() { return halfY / 2.0; }
BlockPos block() { return new BlockPos(x, Math.floorDiv(halfY, 2), z); }
BlockPos bearing() { return new BlockPos(x, Math.floorDiv(halfY - 1, 2), z); }
Foot rise(int halves) { return new Foot(x, halfY + halves, z); }
Foot move(Direction direction, int halves) { return new Foot(x + direction.getStepX(), halfY + halves, z + direction.getStepZ()); }
}
private static Map<Foot, Integer> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside, Predicate<BlockPos> naturalApproach) {
var level = helper.getLevel(); var first = Foot.at(start);
helper.assertTrue(inside.test(start) && standable(level, first), "Blocked native surface portico: " + start);
var seen = new HashMap<Foot, Integer>(); var queue = new ArrayDeque<Foot>(); seen.put(first, 0); queue.add(first);
while (!queue.isEmpty()) {
var pos = queue.removeFirst();
for (var direction : HORIZONTAL) for (int dy = -2; dy <= 2; dy++) {
var next = pos.move(direction, dy);
if (!inside.test(next.block()) || seen.containsKey(next)) continue;
boolean ordinaryThreshold = naturalApproach.test(pos.block()) || naturalApproach.test(next.block());
// Native step height is enough for a half slab; a full block needs the two treads of a stair.
if (dy > 0 && (!clearBody(level, pos.rise(dy))
|| dy == 2 && !ordinaryThreshold && !ascendingStair(level, next.bearing(), direction))) continue;
if (dy < 0 && (!clearBody(level, next.rise(-dy))
|| dy == -2 && !ordinaryThreshold && !ascendingStair(level, pos.bearing(), direction.getOpposite()))) continue;
if (standable(level, next)) { seen.put(next, seen.get(pos) + 1); queue.add(next); }
}
helper.assertTrue(seen.size() <= 120_000, "Native transit traversal exceeded its bounded room volumes");
}
return seen;
}
private static boolean ascendingStair(ServerLevel level, BlockPos pos, Direction direction) {
var state = level.getBlockState(pos);
return state.getBlock() instanceof StairBlock
&& state.getValue(BlockStateProperties.HORIZONTAL_FACING) == direction
&& state.getValue(BlockStateProperties.HALF) == net.minecraft.world.level.block.state.properties.Half.BOTTOM;
}
private static boolean standable(ServerLevel level, BlockPos pos) { return standable(level, Foot.at(pos)); }
private static boolean standable(ServerLevel level, Foot pos) {
if (!clearBody(level, pos)) return false;
var below = pos.bearing(); double top = pos.y() - below.getY();
return level.getBlockState(below).getCollisionShape(level, below).toAabbs().stream()
.anyMatch(b -> Math.abs(b.maxY - top) < .0001 && b.minX <= .5 && b.maxX >= .5 && b.minZ <= .5 && b.maxZ >= .5);
}
private static boolean clearBody(ServerLevel level, Foot pos) {
var body = new AABB(pos.x() + .2, pos.y(), pos.z() + .2, pos.x() + .8, pos.y() + 1.8, pos.z() + .8);
for (int y = (int) Math.floor(pos.y()); y <= (int) Math.floor(pos.y() + 1.8 - .0001); y++) {
var at = new BlockPos(pos.x(), y, pos.z());
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(TransitPlan21.Module 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("transit21-create-proof.json"); }
private static String planHash(TransitPlan21 plan) { return hash(plan.modules().toString()); }
private static void write(ServerLevel level, Map<String, Object> report, boolean reload) throws IOException {
var path = Path.of("diagnostics", "transit21-" + (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); }
}
}
@@ -0,0 +1,126 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16;
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind;
import fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Module;
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.StairBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
/** Native circulation proof on the already generated piece footprint. */
public final class Underground22NativeChecks {
private static final Direction[] HORIZONTAL = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST};
private Underground22NativeChecks() {}
public static Map<String, Integer> verify(GameTestHelper helper, LostCityUnderground16.Plan plan) {
var level = helper.getLevel(); var chunks = new HashSet<Long>();
int halls = 0, ladders = 0, apertures = 0, walked = 0;
helper.assertTrue(!plan.isEmpty(), "Independent underground hall plan must be present");
for (var network : plan.networks()) {
for (var m : network.modules()) {
for (int cx = m.minX() >> 4; cx <= m.maxX() >> 4; cx++) for (int cz = m.minZ() >> 4; cz <= m.maxZ() >> 4; cz++) {
long key = ((long) cx << 32) ^ (cz & 0xffffffffL);
// Short-lived read tickets can expire during the callers asynchronous footprint load.
// Refresh only these already generated chunks before inspecting their blocks.
if (chunks.add(key)) helper.assertTrue(level.getChunk(cx, cz, ChunkStatus.FULL, true) instanceof LevelChunk,
"Underground22 piece has no loaded FULL chunk at " + cx + "," + cz);
}
for (int d = 0; d < 4; d++) if ((m.openings() & 1 << d) != 0) {
for (int side = -1; side <= 1; side++) for (int dy = 1; dy <= 3; dy++) {
var at = mouth(m, d, side, dy);
helper.assertTrue(passable(level.getBlockState(at)), "Native utility doorway blocked at " + at + " in " + m.kind());
}
apertures++;
}
}
var access = network.modules().stream().filter(m -> m.kind() == Kind.ACCESS).findFirst().orElseThrow();
BlockPos hatch = null;
for (int x = access.minX(); x <= access.maxX(); x++) for (int z = access.minZ(); z <= access.maxZ(); z++) {
var at = new BlockPos(x, access.maxY() - 1, z);
if (level.getBlockState(at).is(Blocks.DARK_OAK_TRAPDOOR)) {
helper.assertTrue(hatch == null, "The access has more than one surface hatch"); hatch = at;
}
}
helper.assertTrue(hatch != null, "Independent access has no real wooden surface hatch");
final BlockPos entrance = hatch;
var closed = level.getBlockState(entrance);
for (int y = access.floorY() + 1; y < entrance.getY(); y++) {
var at = new BlockPos(entrance.getX(), y, entrance.getZ()); var ladder = level.getBlockState(at);
helper.assertTrue(ladder.is(Blocks.LADDER), "Access ladder is interrupted at " + at);
var wall = at.relative(ladder.getValue(BlockStateProperties.HORIZONTAL_FACING).getOpposite());
helper.assertTrue(level.getBlockState(wall).isCollisionShapeFullBlock(level, wall), "Ladder has no real backing wall at " + at);
ladders++;
}
level.setBlock(entrance, closed.setValue(BlockStateProperties.OPEN, true), 2);
try {
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(entrance::relative)
.filter(inside).filter(pos -> standable(level, pos)).findFirst().orElseThrow();
var reached = walk(helper, approach, inside); walked += reached.size();
helper.assertTrue(reached.contains(entrance), "Public deck cannot reach its hatch");
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()),
"Surface access cannot reach emitted utility module " + m.kind() + " at " + m.centerX() + "," + m.centerZ());
if (m.kind() != Kind.EXPANSION_HALL) continue;
halls++;
var plinth = new BlockPos(m.centerX(), m.minY() + 2, m.centerZ());
helper.assertTrue(level.getBlockState(plinth).is(Blocks.SMOOTH_STONE) && reached.contains(plinth.above()),
"Reserved expansion plinth must be emitted and reachable");
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)),
"Expansion hall has a blocked quarter");
}
} finally { level.setBlock(entrance, closed, 2); }
}
helper.assertTrue(halls > 0, "Native networks have no usable expansion hall");
var report = new LinkedHashMap<String, Integer>(); report.put("full_chunks", chunks.size()); report.put("usable_halls", halls);
report.put("ladder_blocks", ladders); report.put("clear_openings", apertures); report.put("reached_positions", walked);
return Map.copyOf(report);
}
private static BlockPos mouth(Module m, int direction, int across, int dy) {
return new BlockPos(direction == 1 ? m.maxX() : direction == 3 ? m.minX() : m.centerX() + across,
m.floorY() + dy, direction == 0 ? m.minZ() : direction == 2 ? m.maxZ() : m.centerZ() + across);
}
/** Same finite walking/ladder rule used by the native city16/20/21 access proofs. */
private static Set<BlockPos> walk(GameTestHelper helper, BlockPos start, Predicate<BlockPos> inside) {
var level = helper.getLevel(); var reached = new HashSet<BlockPos>(); var queue = new ArrayDeque<BlockPos>();
helper.assertTrue(inside.test(start) && standable(level, start), "Blocked utility approach"); reached.add(start); queue.add(start);
while (!queue.isEmpty()) {
var pos = queue.remove();
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 && !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) && (level.getBlockState(pos).is(Blocks.LADDER) || level.getBlockState(next).is(Blocks.LADDER)
|| level.getBlockState(pos.below()).is(Blocks.LADDER)) && standable(level, next) && reached.add(next)) queue.add(next);
}
helper.assertTrue(reached.size() < 100_000, "Utility traversal exceeded its bounded footprint");
}
return reached;
}
private static boolean standable(ServerLevel level, BlockPos pos) {
var below = level.getBlockState(pos.below()); var at = level.getBlockState(pos);
return passable(at) && passable(level.getBlockState(pos.above()))
&& (below.isSolid() || below.getBlock() instanceof StairBlock || at.is(Blocks.LADDER) || below.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_TRAPDOOR) && state.getValue(BlockStateProperties.OPEN);
}
}
@@ -0,0 +1,322 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.worldgen.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.GenerationChunkHolder;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.util.StaticCache2D;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ImposterProtoChunk;
import net.minecraft.world.level.chunk.status.ChunkPyramid;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
/** Alpha.13 engine checks use the real starter binding and never reconfigure saved worlds. */
public final class UnifiedWorldGameTests {
private static final List<String> CAVES = List.of("minecraft:lush_caves", "minecraft:dripstone_caves", "minecraft:sulfur_caves");
private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST};
@GameTest(maxTicks = 3000)
public void retainedHydrologySurvivesNativeDecoration(GameTestHelper helper) throws IOException {
PopulationDiagnostics.start(helper);
}
@GameTest(maxTicks = 200)
public void sizeAndPublicChoiceSurviveDiskStorage(GameTestHelper helper) throws IOException {
UnifiedWorldSettingsDiagnostics.verify(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void legacyPipelinesRemainIsolated(GameTestHelper helper) {
PopulationDiagnostics.verifyLegacyIsolation(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void populationTerrainAndNaturalSpawnRemainPresent(GameTestHelper helper) throws IOException {
PopulationDiagnostics.verifyDensity(helper);
var level = helper.getLevel();
var binding = binding(level);
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384, "Unification preserves the complete vertical world extent");
var spawn = SanctuaryWorldGameTests.initialSpawn;
helper.assertTrue(spawn != null, "Capture the original natural spawn before the GameTest fixtures move it");
var at = spawn.pos();
helper.assertTrue(level.getBlockState(at.below()).isCollisionShapeFullBlock(level, at.below())
&& level.getBlockState(at).getCollisionShape(level, at).isEmpty()
&& level.getBlockState(at.above()).getCollisionShape(level, at.above()).isEmpty(),
"The unmodified starter spawn must stand on real usable terrain");
helper.assertTrue(natural(binding, at.below()) > 0, "The starter spawn never requires an added platform");
helper.succeed();
}
@GameTest(maxTicks = 200)
public void exteriorAndLowerMarginRemainVoid(GameTestHelper helper) {
PopulationDiagnostics.verifyExterior(helper);
PopulationDiagnostics.verifyLowerMargin(helper);
helper.succeed();
}
@GameTest(maxTicks = 200)
public void nativeCaveFeaturesExistOnNaturalLayers(GameTestHelper helper) throws IOException {
ServerLevel level = helper.getLevel();
var binding = binding(level);
var generator = unified(level);
List<Site> sites = caveSites(binding);
Map<Long, LevelChunk> chunks = new TreeMap<>();
for (Site site : sites) {
int cx = site.x() >> 4, cz = site.z() >> 4;
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) {
long key = ChunkPos.pack(cx + dx, cz + dz);
if (!chunks.containsKey(key)) chunks.put(key, full(helper, level, cx + dx, cz + dz));
}
}
helper.assertTrue(chunks.size() <= 27, "Native cave inspection is at most three independent 3x3 FULL patches");
Map<String, Map<String, Long>> counts = new TreeMap<>();
CAVES.forEach(name -> counts.put(name, new TreeMap<>()));
Map<String, Long> allBlocks = new TreeMap<>();
Map<String, Long> surfaceBiomes = new TreeMap<>();
int largestDripstoneColumn = 0;
var density = binding.generator().generatorSettings().value().noiseRouter().finalDensity();
for (LevelChunk chunk : chunks.values()) {
for (var section : chunk.getSections()) section.getStates().count((state, count) -> {
if (!state.isAir()) allBlocks.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), (long) count, Long::sum);
});
for (int dx = 0; dx < 16; dx++) for (int dz = 0; dz < 16; dz++) {
int x = chunk.getPos().getMinBlockX() + dx, z = chunk.getPos().getMinBlockZ() + dz;
int run = 0;
for (int y = 0; y < 256; y++) {
var pos = new BlockPos(x, y, z);
var state = chunk.getBlockState(pos);
String biome = chunk.getNoiseBiome(x >> 2, y >> 2, z >> 2).getRegisteredName();
var bucket = counts.get(biome);
if (bucket != null && !state.isAir()) bucket.merge(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), 1L, Long::sum);
// Large vanilla pillars add thick dripstone into original cave air. A painted
// floor or the old hand-placed support block cannot satisfy this witness.
if (state.is(Blocks.DRIPSTONE_BLOCK) && biome.equals("minecraft:dripstone_caves")
&& binding.random().sampleBlockValueUncached(density, x, y, z) <= 0) run++;
else run = 0;
largestDripstoneColumn = Math.max(largestDripstoneColumn, run);
}
int top = chunk.getHeight(net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE, dx, dz);
if (top > 190) surfaceBiomes.merge(chunk.getNoiseBiome(x >> 2, top >> 2, z >> 2).getRegisteredName(), 1L, Long::sum);
}
}
var report = report(level);
report.put("sites", sites); report.put("inspected_full_chunks", chunks.size());
report.put("native_cave_blocks", counts); report.put("all_observed_blocks", allBlocks);
report.put("surface_biomes", surfaceBiomes); report.put("largest_natural_air_dripstone_column", largestDripstoneColumn);
report.put("rejected_decoration_writes", generator.rejectedDecorationWrites());
Map<String, Object> atmospheres = new TreeMap<>();
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
for (String name : CAVES) {
var biome = level.registryAccess().lookupOrThrow(Registries.BIOME)
.getValue(net.minecraft.resources.Identifier.parse(name));
var encoded = Biome.DIRECT_CODEC.encodeStart(ops, biome).getOrThrow().getAsJsonObject();
atmospheres.put(name, encoded.get("attributes"));
helper.assertTrue(encoded.get("attributes").toString().contains("music.overworld." + name.substring("minecraft:".length())),
"The native cave's music must remain present in its loaded biome attributes: " + name);
if (name.equals("minecraft:sulfur_caves")) helper.assertTrue(encoded.get("attributes").toString().contains("minecraft:visual/fog_color")
&& encoded.get("attributes").toString().contains("minecraft:sulfur_cube"),
"The sulfur cave retains its distinct fog and native creature table");
}
report.put("native_attributes", atmospheres);
report.put("scope", "Up to 27 FULL chunks selected from real cave density; counts are observed blocks, not island-wide resource estimates or client screenshots");
boolean passed = false;
Set<String> encountered = new TreeSet<>(); sites.forEach(site -> encountered.add(site.biome()));
Set<String> verified = new TreeSet<>();
try {
helper.assertTrue(!sites.isEmpty(), "The demonstration world must provide at least one real native cave site");
if (encountered.contains("minecraft:lush_caves")) {
helper.assertTrue(count(counts, "minecraft:lush_caves", "minecraft:moss_block") > 0
&& count(counts, "minecraft:lush_caves", "minecraft:cave_vines")
+ count(counts, "minecraft:lush_caves", "minecraft:cave_vines_plant") > 0,
"Actual native lush caverns must have moss and harvestable hanging vines");
verified.add("minecraft:lush_caves");
}
if (encountered.contains("minecraft:dripstone_caves")) {
helper.assertTrue(count(counts, "minecraft:dripstone_caves", "minecraft:pointed_dripstone") > 0 && largestDripstoneColumn >= 5,
"Actual native dripstone caverns must include points and a substantial pillar in natural cave air");
verified.add("minecraft:dripstone_caves");
}
if (encountered.contains("minecraft:sulfur_caves")) {
helper.assertTrue(count(counts, "minecraft:sulfur_caves", "minecraft:sulfur") > 0
&& count(counts, "minecraft:sulfur_caves", "minecraft:sulfur_spike") > 0,
"Actual native sulfur caverns must include native rock and spikes");
verified.add("minecraft:sulfur_caves");
}
helper.assertTrue(surfaceBiomes.keySet().stream().anyMatch(name -> name.startsWith("sanctuary:population_")),
"The starter surface must retain its Population biome family above these lower cave patches");
passed = true; helper.succeed();
} finally {
report.put("encountered_native_types", encountered); report.put("verified_native_types", verified);
report.put("unverified_native_types", CAVES.stream().filter(name -> !verified.contains(name)).toList());
report.put("all_three_types_demonstrated_here", verified.containsAll(CAVES));
report.put("validation_contract", "Every encountered type must pass its full block evidence; the release also requires cumulative evidence for all three types across its fresh demonstration worlds");
report.put("passed", passed); write(level, "native-caves", report);
}
}
@GameTest(maxTicks = 200)
public void decorationGuardRejectsChangesToHydrology(GameTestHelper helper) throws IOException {
var level = helper.getLevel(); var binding = binding(level); var generator = unified(level);
var plan = PopulationHydrologyRuntime.regionPlan(binding.generator(), binding.random(), new IslandCapacity.Region(0, 0)).water();
LinkedHashSet<BlockPos> witnesses = new LinkedHashSet<>();
int shells = 0;
for (var cell : plan.cells()) {
if (!cell.hasWater()) continue;
witnesses.add(new BlockPos(cell.x(), cell.waterY(), cell.z()));
witnesses.add(new BlockPos(cell.x(), cell.bedY() - cell.sedimentDepth() - 1, cell.z()));
for (Direction side : SIDES) {
var wall = new BlockPos(cell.x(), cell.waterY(), cell.z()).relative(side);
var next = plan.cellAt(wall.getX(), wall.getY(), wall.getZ());
if ((next == null || !next.hasWater()) && natural(binding, wall) > 0) { witnesses.add(wall); shells++; }
}
if (shells >= 2 && witnesses.size() >= 12) break;
}
int boundaryWitnesses = 0;
for (var cell : plan.cells()) {
if (!cell.hasWater()) continue;
for (Direction side : SIDES) {
var water = new BlockPos(cell.x(), cell.waterY(), cell.z());
var wall = water.relative(side);
var next = plan.cellAt(wall.getX(), wall.getY(), wall.getZ());
if (ChunkPos.pack(water) != ChunkPos.pack(wall) && (next == null || !next.hasWater()) && natural(binding, wall) > 0) {
witnesses.add(wall); boundaryWitnesses++;
}
}
if (boundaryWitnesses >= 4) break;
}
for (var spring : plan.springs()) { witnesses.add(position(spring.source())); witnesses.add(position(spring.outlet())); }
for (var spill : plan.spills()) { witnesses.add(position(spill.source())); witnesses.add(position(spill.outlet())); }
helper.assertTrue(!witnesses.isEmpty() && shells > 0, "Inspect real source, bed and natural wall witnesses");
Map<Long, SanctuaryDecorationProtection> masks = new HashMap<>();
int compared = 0;
for (var witness : witnesses) {
var mask = masks.computeIfAbsent(ChunkPos.pack(witness), ignored -> SanctuaryDecorationProtection.create(binding, witness.getX() >> 4, witness.getZ() >> 4));
helper.assertTrue(mask.protects(witness) && generator.protectsDecoration(witness), "The published decoration mask protects the complete water geometry: " + witness);
helper.assertTrue(PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), witness, 1, 1, 1),
"The mask must agree with the hydrology protection oracle including one-block clearance at natural walls");
compared++;
}
var witness = witnesses.getFirst();
LevelChunk chunk = full(helper, level, witness.getX() >> 4, witness.getZ() >> 4);
// FULL holders deliberately expose a read-only ImposterProtoChunk to generation.
// Use a test-local writable view; leave the world's real holder and saved status alone.
var writable = new ImposterProtoChunk(chunk, true);
GenerationChunkHolder testHolder = new GenerationChunkHolder(chunk.getPos()) {
@Override protected void addSaveDependency(java.util.concurrent.CompletableFuture<?> dependency) {}
@Override public int getTicketLevel() { return 0; }
@Override public int getQueueLevel() { return 0; }
@Override public ChunkAccess getChunkIfPresentUnchecked(ChunkStatus status) { return writable; }
};
var holders = StaticCache2D.<GenerationChunkHolder>create(chunk.getPos().x(), chunk.getPos().z(), 0,
(x, z) -> testHolder);
var worldgen = new WorldGenRegion(level, holders, ChunkPyramid.GENERATION_PYRAMID.getStepTo(ChunkStatus.FEATURES), chunk);
var before = chunk.getBlockState(witness);
long rejected = generator.rejectedDecorationWrites();
try {
helper.assertFalse(worldgen.setBlock(witness, Blocks.DIAMOND_BLOCK.defaultBlockState(), 2, 0),
"A real WorldGenRegion write must be denied on protected retained water");
helper.assertTrue(chunk.getBlockState(witness).equals(before) && generator.rejectedDecorationWrites() == rejected + 1,
"The native write path must preserve the original block and record the rejection");
} finally { if (!chunk.getBlockState(witness).equals(before)) chunk.setBlockState(witness, before, 0); }
BlockPos free = new BlockPos(chunk.getPos().getMinBlockX() + 8, 383, chunk.getPos().getMinBlockZ() + 8);
var freeBefore = chunk.getBlockState(free);
helper.assertFalse(generator.protectsDecoration(free), "A distant air witness must be outside the hydrology mask");
try {
helper.assertTrue(worldgen.setBlock(free, Blocks.DIAMOND_BLOCK.defaultBlockState(), 2, 0)
&& chunk.getBlockState(free).is(Blocks.DIAMOND_BLOCK),
"Decoration outside protected volumes must remain writable");
} finally { chunk.setBlockState(free, freeBefore, 0); }
write(level, "decoration-guard", Map.of("generation", 13, "seed", level.getSeed(),
"diameter", generator.initialDiameter(), "compared_plan_points", compared,
"natural_wall_witnesses", shells, "walls_owned_by_adjacent_chunk", boundaryWitnesses, "actual_write_rejected", true, "unprotected_write_allowed", true, "passed", true));
helper.succeed();
}
private record Site(String biome, int x, int floorY, int roofY, int z, int score) {}
private static List<Site> caveSites(PopulationGenerationContext.Binding binding) {
var capacity = IslandCapacity.forGenerator(binding.generator());
var resolver = binding.generator().getBiomeSource().createUncachedResolver(binding.random());
var density = binding.generator().generatorSettings().value().noiseRouter().finalDensity();
Map<String, List<Site>> candidates = new TreeMap<>(); CAVES.forEach(name -> candidates.put(name, new ArrayList<>()));
int radius = (capacity.terrainLimit() + 15) / 16;
for (int cx = -radius; cx <= radius; cx++) for (int cz = -radius; cz <= radius; cz++) {
int x = cx * 16 + 8, z = cz * 16 + 8;
if (Math.hypot(x, z) >= capacity.radius() - 24) continue;
for (int y = 164; y >= 40; y--) {
String biome = resolver.getNoiseBiome(x >> 2, (y + 1) >> 2, z >> 2).getRegisteredName();
var bucket = candidates.get(biome); if (bucket == null) continue;
if (sample(binding, density, x, y, z) <= 0 || sample(binding, density, x, y + 1, z) > 0
|| sample(binding, density, x, y + 2, z) > 0 || sample(binding, density, x, y - 1, z) <= 0) continue;
int roof = y + 3;
while (roof < Math.min(256, y + 96) && sample(binding, density, x, roof, z) <= 0) roof++;
if (roof >= Math.min(256, y + 96) || roof - y < 10 || sample(binding, density, x, roof + 1, z) <= 0) continue;
int score = Math.min(48, roof - y);
for (int dx : new int[]{-4, 0, 4}) for (int dz : new int[]{-4, 0, 4})
if (sample(binding, density, x + dx, y, z + dz) > 0 && sample(binding, density, x + dx, roof, z + dz) > 0) score += 8;
bucket.add(new Site(biome, x, y, roof, z, score));
break;
}
}
List<Site> sites = new ArrayList<>();
for (String cave : CAVES) {
var ordered = candidates.get(cave).stream().sorted(Comparator.comparingInt(Site::score).reversed()
.thenComparingDouble(s -> Math.hypot(s.x(), s.z())).thenComparingInt(Site::x).thenComparingInt(Site::z)).toList();
for (Site candidate : ordered.stream().limit(32).toList()) {
var base = new BlockPos(candidate.x(), candidate.floorY() + 1, candidate.z());
if (!PopulationHydrologyRuntime.protects(binding.generator(), binding.random(), base, 8,
candidate.roofY() - candidate.floorY(), 3)) { sites.add(candidate); break; }
}
}
return List.copyOf(sites);
}
private static float sample(PopulationGenerationContext.Binding binding, DensityFunction density, int x, int y, int z) {
return binding.random().sampleBlockValueUncached(density, x, y, z);
}
private static float natural(PopulationGenerationContext.Binding binding, BlockPos pos) {
return sample(binding, binding.generator().generatorSettings().value().noiseRouter().finalDensity(), pos.getX(), pos.getY(), pos.getZ());
}
private static long count(Map<String, Map<String, Long>> counts, String biome, String block) { return counts.get(biome).getOrDefault(block, 0L); }
private static BlockPos position(PopulationHydrology.Position p) { return new BlockPos(p.x(), p.y(), p.z()); }
private static PopulationGenerationContext.Binding binding(ServerLevel level) { return Objects.requireNonNull(PopulationGenerationContext.of(level)); }
private static SanctuaryChunkGenerator unified(ServerLevel level) { return (SanctuaryChunkGenerator) level.getChunkSource().getGenerator(); }
private static LevelChunk full(GameTestHelper helper, ServerLevel level, int x, int z) {
var chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, true);
helper.assertTrue(chunk instanceof LevelChunk, "Inspect only real fully generated chunks"); return (LevelChunk) chunk;
}
private static Map<String, Object> report(ServerLevel level) {
var report = new LinkedHashMap<String, Object>();
report.put("generation", 13); report.put("seed", level.getSeed()); report.put("diameter", unified(level).initialDiameter());
report.put("mod_version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString());
return report;
}
private static void write(ServerLevel level, String kind, Object report) throws IOException {
Path target = Path.of("diagnostics", "unified-" + unified(level).initialDiameter() + "-seed-" + level.getSeed() + "-" + kind + ".json");
Files.createDirectories(target.getParent());
Files.writeString(target, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
SanctuaryMod.LOGGER.info("Unified {} seed {} wrote {}", unified(level).initialDiameter(), level.getSeed(), target);
}
}
@@ -0,0 +1,152 @@
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 historical17 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v17"))).value().createWorldDimensions();
helper.assertTrue(historical17.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 17
&& old.source().generation() == 17 && old.experimentalStructures(), "Historical17 keeps its original experimental generator");
var historical19 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v19"))).value().createWorldDimensions();
helper.assertTrue(historical19.overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 19
&& old.source().generation() == 19 && old.experimentalStructures(), "Historical19 keeps its original renderer and world settings");
var generator = (SanctuaryChunkGenerator) standard.overworld();
var historical20 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v20")));
helper.assertTrue(historical20.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 20
&& old.source().generation() == 20 && old.experimentalStructures(), "Historical20 keeps its original architecture");
var historical21 = presets.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id("sanctuary_v21")));
helper.assertTrue(historical21.value().createWorldDimensions().overworld() instanceof SanctuaryChunkGenerator old && old.rootGeneration() == 21
&& old.source().generation() == 21 && old.experimentalStructures(), "Historical21 keeps its original placement rules");
helper.assertTrue(generator.rootGeneration() == 22 && generator.source().generation() == 22 && generator.experimentalStructures(), "The public preset uses generation22 with its new-world option");
helper.assertTrue(generator.initialDiameter() == 724, "The public preset defaults to Medium / 724 blocks");
verifyConstructionMasks(helper, (SanctuaryChunkGenerator) historical21.value().createWorldDimensions().overworld(), generator);
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() == 22 ? SanctuaryChunkGenerator.SELECTIVE22_CODEC : generator.rootGeneration() == 21 ? SanctuaryChunkGenerator.NETWORK21_CODEC : generator.rootGeneration() == 20 ? SanctuaryChunkGenerator.ARCHITECTURE20_CODEC : generator.rootGeneration() == 19 ? SanctuaryChunkGenerator.HERITAGE19_CODEC : 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(),
"Generation22 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");
}
private static void verifyConstructionMasks(GameTestHelper helper, SanctuaryChunkGenerator historical21,
SanctuaryChunkGenerator generation22) {
var outside = new net.minecraft.core.BlockPos(200_000, 192, 200_000);
// Decoding a generation must not reserve the world before any structures are planned.
// These checks also cover the old OFF contract without initializing another island.
for (var generator : List.of(historical21, historical21.withExperimentalStructures(false),
generation22, generation22.withExperimentalStructures(false))) {
helper.assertTrue(!generator.protectsCityDecoration(outside),
"Unplanned generation" + generator.rootGeneration() + " does not protect arbitrary blocks");
helper.assertTrue(!generator.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(),
outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1),
"Unplanned generation" + generator.rootGeneration() + " does not reject arbitrary structure bounds");
}
// Reuse the attached world's real plans: no extra native-noise planning or chunk loads.
if (!(helper.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator active)
|| active.rootGeneration() != 21 && active.rootGeneration() != 22) return;
helper.assertTrue(!active.protectsCityDecoration(outside)
&& !active.intersectsExperimentalStructures(outside.getX(), outside.getY(), outside.getZ(),
outside.getX() + 1, outside.getY() + 1, outside.getZ() + 1),
"Prepared construction masks remain local to their real structures");
var interior = new ArrayList<net.minecraft.core.BlockPos>();
for (var module : active.transitPlan21().modules())
interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ()));
for (var network : active.undergroundHall22().networks()) for (var module : network.modules())
interior.add(new net.minecraft.core.BlockPos(module.centerX(), module.floorY(), module.centerZ()));
if (active.rootGeneration() == 22 && helper.getLevel().structureManager().shouldGenerateStructures())
helper.assertTrue(!interior.isEmpty(), "The attached generation22 world has core construction to protect");
for (var position : interior) helper.assertTrue(active.protectsCityDecoration(position)
&& active.intersectsExperimentalStructures(position.getX(), position.getY(), position.getZ(),
position.getX(), position.getY(), position.getZ()),
"Real underground construction remains protected from decoration and overlapping native structures");
}
}
@@ -23,13 +23,20 @@ public final class WorldgenPerformanceGameTests {
@GameTest(maxTicks = 3000)
public void spawnAreaAndExploration(GameTestHelper helper) throws Exception {
ServerLevel level = helper.getLevel();
var capacity = IslandCapacity.forGenerator(level.getChunkSource().getGenerator());
var binding = fr.koka.sanctuary.worldgen.PopulationGenerationContext.of(level);
var capacity = IslandCapacity.forGenerator(binding.generator());
var spawn = SanctuaryWorldGameTests.initialSpawn.pos();
int cx = spawn.getX() >> 4, cz = spawn.getZ() >> 4;
var report = new LinkedHashMap<String, Object>();
report.put("version", FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow()
.getMetadata().getVersion().getFriendlyString());
report.put("players", capacity.players()); report.put("seed", level.getSeed());
var host = level.getChunkSource().getGenerator();
String profile = "players-" + capacity.players();
if (host instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator unified) {
report.put("generation", 13); report.put("initial_diameter", unified.initialDiameter());
profile = "unified-" + unified.initialDiameter();
}
report.put("spawn", List.of(spawn.getX(), spawn.getY(), spawn.getZ()));
report.put("java", System.getProperty("java.version"));
report.put("processors", Runtime.getRuntime().availableProcessors());
@@ -78,13 +85,13 @@ public final class WorldgenPerformanceGameTests {
report.put("exploration_ms", millis(System.nanoTime() - exploreStart));
report.put("exploration_full_chunks", 80); report.put("exploration_steps", steps);
report.put("exploration_chunk_latency_ms", latency(chunkTimes));
report.put("regions_cached", PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState()));
report.put("regions_cached", PopulationHydrologyRuntime.cachedRegionCount(binding.random()));
report.put("scope", "Fresh development world; 289 FULL chunks around natural spawn, then 80 eastward FULL chunks. "
+ "Synchronous server requests include dependent generation up to FULL, not guaranteed ticking readiness, client GPU, network, FPS or player load. "
+ "World creation before the test is timed separately by Minecraft's startup log.");
helper.assertTrue(count == 289 && land > 100, "The benchmark must generate actual playable terrain");
report.put("passed", true);
Path output = Path.of("diagnostics", "generation-performance-players-" + capacity.players() + "-seed-" + level.getSeed() + ".json");
Path output = Path.of("diagnostics", "generation-performance-" + profile + "-seed-" + level.getSeed() + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
helper.succeed();
@@ -17,6 +17,20 @@ 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.selective22") || (Boolean.getBoolean("sanctuary.test.heritage20") || Boolean.getBoolean("sanctuary.test.heritage21") || Boolean.getBoolean("sanctuary.test.lostCity21") || Boolean.getBoolean("sanctuary.test.transit21")) || Boolean.getBoolean("sanctuary.test.lostCity20")
|| Boolean.getBoolean("sanctuary.test.heritage19") || 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,10 +46,16 @@ public abstract class SanctuaryGameTestServerMixin {
@Redirect(method = "lambda$create$1", at = @At(value = "FIELD", target =
"Lnet/minecraft/world/level/levelgen/presets/WorldPresets;FLAT_ALL_DIMENSIONS:Lnet/minecraft/resources/ResourceKey;"))
private static ResourceKey<WorldPreset> sanctuary$selectActualPreset() {
if (Boolean.getBoolean("sanctuary.test.unified")) {
int generation = Integer.getInteger("sanctuary.test.generation", 22);
if (generation != 13 && generation != 14 && generation != 15 && generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) throw new IllegalArgumentException("Unsupported test generation");
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(generation == 22 ? "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";
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");
@@ -43,11 +63,27 @@ public abstract class SanctuaryGameTestServerMixin {
return ResourceKey.create(Registries.WORLD_PRESET, SanctuaryMod.id(preset));
}
@Redirect(method = "lambda$create$1", at = @At(value = "INVOKE", target =
"Lnet/minecraft/world/level/levelgen/presets/WorldPreset;createWorldDimensions()Lnet/minecraft/world/level/levelgen/WorldDimensions;"))
private static net.minecraft.world.level.levelgen.WorldDimensions sanctuary$customizeSize(WorldPreset preset,
net.minecraft.world.level.LevelSettings settings, net.minecraft.server.WorldLoader.DataLoadContext context) {
var dimensions = preset.createWorldDimensions();
if (Boolean.getBoolean("sanctuary.test.unified")) {
var generator = (fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator) dimensions.overworld();
return dimensions.replaceOverworldGenerator(context.datapackWorldRegistries(),
generator.withInitialDiameter(Integer.getInteger("sanctuary.test.diameter", 724))
.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();
fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn =
fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(level.getChunkSource().randomState());
if (Boolean.getBoolean("sanctuary.test.expansion")) return;
var binding = fr.koka.sanctuary.worldgen.PopulationGenerationContext.of(level);
fr.koka.sanctuary.gametest.PopulationDiagnostics.regionsAtInitialSpawn = binding == null ? 0 :
fr.koka.sanctuary.worldgen.PopulationHydrologyRuntime.cachedRegionCount(binding.random());
}
}
@@ -1,5 +1,8 @@
package fr.koka.sanctuary;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import fr.koka.sanctuary.worldgen.SanctuaryBiomeSource;
import fr.koka.sanctuary.worldgen.CavernIslandBiomeSource;
import fr.koka.sanctuary.worldgen.PopulationIslandDensity;
import fr.koka.sanctuary.worldgen.PopulationIslandBiomeSource;
@@ -40,7 +43,36 @@ 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();
fr.koka.sanctuary.worldgen.heritage.HeritageCommands19.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();
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v19"), SanctuaryChunkGenerator.HERITAGE19_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v19"), SanctuaryBiomeSource.HERITAGE19_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v20"), SanctuaryChunkGenerator.ARCHITECTURE20_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v20"), SanctuaryBiomeSource.ARCHITECTURE20_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v21"), SanctuaryChunkGenerator.NETWORK21_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v21"), SanctuaryBiomeSource.NETWORK21_CODEC);
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("island_v22"), SanctuaryChunkGenerator.SELECTIVE22_CODEC);
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("island_v22"), SanctuaryBiomeSource.SELECTIVE22_CODEC);
fr.koka.sanctuary.worldgen.transit.TransitStructures21.register();
fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.register();
fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.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);
@@ -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 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) {
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(generator.rootGeneration() == 22 ? "sanctuary.world_options.heritage22_hint" : (generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22) ? "sanctuary.world_options.heritage_hint" : "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; }
}
@@ -0,0 +1,160 @@
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);
fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.transit.TransitStructures21.generate(this, registries, state, structures, chunk, templates, dimension);
fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.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, 17, 19, 20, 21 or 22 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 || generation == 19 || generation == 20 || generation == 21 || generation == 22; }
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);
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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,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();
}
@@ -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; }
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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();
}
@@ -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();
}
@@ -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();
}
@@ -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,53 @@
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()
&& !fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.isPlacing()
&& !fr.koka.sanctuary.worldgen.transit.TransitStructures21.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 || generator.rootGeneration() == 19 || generator.rootGeneration() == 20 || generator.rootGeneration() == 21 || generator.rootGeneration() == 22)
&& 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,50 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.SanctuaryWorldOptionsScreen;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.util.Map;
import java.util.Optional;
import net.minecraft.client.gui.screens.worldselection.PresetEditor;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.levelgen.presets.WorldPreset;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Supplies the existing vanilla Customize button without replacing its editor registry. */
@Mixin(WorldCreationUiState.class)
public abstract class SanctuaryWorldOptionsMixin {
@Unique
private static final ResourceKey<WorldPreset> SANCTUARY_PRESET = ResourceKey.create(
Registries.WORLD_PRESET, Identifier.fromNamespaceAndPath("sanctuary", "sanctuary"));
@Unique
private static final PresetEditor SANCTUARY_EDITOR = SanctuaryWorldOptionsScreen::new;
@Inject(method = "getPresetEditor", at = @At("HEAD"), cancellable = true)
private void sanctuary$worldOptions(CallbackInfoReturnable<PresetEditor> callback) {
if (sanctuary$hasEditableGenerator()) callback.setReturnValue(SANCTUARY_EDITOR);
}
// Vanilla preserves custom dimensions on registry refresh only when this direct lookup finds an editor.
@Redirect(method = "updatePresetLists", at = @At(value = "INVOKE",
target = "Ljava/util/Map;get(Ljava/lang/Object;)Ljava/lang/Object;"))
private Object sanctuary$preserveCustomSize(Map<?, ?> editors, Object key) {
if (Optional.of(SANCTUARY_PRESET).equals(key) && sanctuary$hasEditableGenerator()) return SANCTUARY_EDITOR;
return editors.get(key);
}
@Unique
private boolean sanctuary$hasEditableGenerator() {
WorldCreationUiState state = (WorldCreationUiState) (Object) this;
var preset = state.getWorldType().preset();
// Recreating an older save may retain the same public preset key with a legacy generator.
return preset != null && preset.is(SANCTUARY_PRESET)
&& state.getSettings().selectedDimensions().overworld() instanceof SanctuaryChunkGenerator;
}
}
@@ -27,9 +27,9 @@ public enum IslandCapacity {
public static IslandCapacity forGenerator(ChunkGenerator generator) {
if (!(generator instanceof NoiseBasedChunkGenerator noise)) return null;
var settings = noise.generatorSettings();
if (settings.is(SanctuarySpawn.POPULATION_5_SETTINGS)) return FIVE;
if (settings.is(SanctuarySpawn.POPULATION_10_SETTINGS)) return TEN;
if (settings.is(SanctuarySpawn.POPULATION_20_SETTINGS)) return TWENTY;
if (settings.is(SanctuarySpawn.POPULATION_5_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_5"))) return FIVE;
if (settings.is(SanctuarySpawn.POPULATION_10_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_10"))) return TEN;
if (settings.is(SanctuarySpawn.POPULATION_20_SETTINGS) || settings.is(fr.koka.sanctuary.SanctuaryMod.id("unified_20"))) return TWENTY;
if (settings.is(SanctuarySpawn.POPULATION_100_SETTINGS)) return HUNDRED;
return null;
}
@@ -76,8 +76,10 @@ public final class PopulationDecorationsFeature implements Feature {
}
@Override public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
var state = level.getLevel().getChunkSource().randomState();
var binding = PopulationGenerationContext.of(generator, level.getLevel());
if (binding == null) return false;
var noise = binding.generator();
var state = binding.random();
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
int cx = origin.getX() >> 4, cz = origin.getZ() >> 4;
@@ -0,0 +1,22 @@
package fr.koka.sanctuary.worldgen;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import org.jspecify.annotations.Nullable;
/** Resolves the actual starter noise state; an expansion host's fallback state has no terrain. */
public final class PopulationGenerationContext {
public record Binding(NoiseBasedChunkGenerator generator, RandomState random) {}
private PopulationGenerationContext() {}
public static @Nullable Binding of(ServerLevel level) {
return of(level.getChunkSource().getGenerator(), level);
}
public static @Nullable Binding of(ChunkGenerator generator, ServerLevel level) {
if (generator instanceof SanctuaryChunkGenerator unified) return unified.starterContext();
if (generator instanceof NoiseBasedChunkGenerator noise && PopulationHydrologyRuntime.enabled(noise))
return new Binding(noise, level.getChunkSource().randomState());
return null;
}
}
@@ -53,8 +53,8 @@ public final class PopulationGrovesFeature implements Feature {
@Override public MapCodec<PopulationGrovesFeature> codec() { return CODEC; }
public static Optional<RemarkableTree> remarkablePlan(ServerLevel level) {
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)) return Optional.empty();
return remarkablePlan(noise, level.getChunkSource().randomState());
var binding = PopulationGenerationContext.of(level);
return binding == null ? Optional.empty() : remarkablePlan(binding.generator(), binding.random());
}
public static Optional<RemarkableTree> remarkablePlan(NoiseBasedChunkGenerator noise, RandomState state) {
@@ -87,10 +87,12 @@ public final class PopulationGrovesFeature implements Feature {
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource ignored, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
var binding = PopulationGenerationContext.of(generator, level.getLevel());
if (binding == null) return false;
var noise = binding.generator();
int minX = (origin.getX() >> 4) * 16, minZ = (origin.getZ() >> 4) * 16;
if (!PopulationHydrologyRuntime.capacity(noise).areaExists(minX >> 4, minZ >> 4)) return false;
var state = level.getLevel().getChunkSource().randomState();
var state = binding.random();
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
WoodlandGroveGeometry.Sampler sampler = (x, y, z) -> state.sampleBlockValueUncached(density, x, y, z);
Protection protection = (base, radius, height, rootDepth) ->
@@ -57,7 +57,8 @@ public final class PopulationHydrologyRuntime {
});
}
public static RegionPlan regionPlan(ServerLevel level, IslandCapacity.Region region) {
return regionPlan((NoiseBasedChunkGenerator) level.getChunkSource().getGenerator(), level.getChunkSource().randomState(), region);
var binding = Objects.requireNonNull(PopulationGenerationContext.of(level), "Population world required");
return regionPlan(binding.generator(), binding.random(), region);
}
public static List<RegionPlan> plansIntersecting(NoiseBasedChunkGenerator generator, RandomState state,
int minX, int minZ, int maxX, int maxZ) {
@@ -20,8 +20,10 @@ public final class PopulationShoreSugarCaneFeature implements Feature {
@Override
public boolean place(WorldGenLevel level, ChunkGenerator generator, RandomSource random, BlockPos origin) {
if (!(generator instanceof NoiseBasedChunkGenerator noise) || !PopulationHydrologyRuntime.enabled(noise)) return false;
var state = level.getLevel().getChunkSource().randomState();
var binding = PopulationGenerationContext.of(generator, level.getLevel());
if (binding == null) return false;
var noise = binding.generator();
var state = binding.random();
var cells = PopulationHydrologyRuntime.cellsInChunk(noise, state, origin.getX() >> 4, origin.getZ() >> 4);
var cane = Blocks.SUGAR_CANE.defaultBlockState();
boolean placed = false;
@@ -26,8 +26,9 @@ public final class PopulationSpringOutlets {
/** Called before vanilla consumes the queue; a fully processed/reloaded chunk has no work. */
public static Pending capturePending(ServerLevel level, LevelChunk chunk) {
if (!(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|| !PopulationHydrologyRuntime.enabled(noise)) return Pending.EMPTY;
var binding = PopulationGenerationContext.of(level);
if (binding == null) return Pending.EMPTY;
var noise = binding.generator();
if (!PopulationHydrologyRuntime.capacity(noise).areaExists(chunk.getPos().x(), chunk.getPos().z())) return Pending.EMPTY;
boolean queued = false;
for (var section : chunk.getPostProcessing()) if (section != null && !section.isEmpty()) {
@@ -35,7 +36,7 @@ public final class PopulationSpringOutlets {
break;
}
if (!queued) return Pending.EMPTY;
var regional = PopulationHydrologyRuntime.regionPlan(noise, level.getChunkSource().randomState(),
var regional = PopulationHydrologyRuntime.regionPlan(noise, binding.random(),
PopulationHydrologyRuntime.capacity(noise).regionAt(chunk.getPos().getMinBlockX(), chunk.getPos().getMinBlockZ()));
var plan = regional.water();
List<PopulationHydrology.Position> sources = new ArrayList<>(), outlets = new ArrayList<>();
@@ -58,9 +59,11 @@ public final class PopulationSpringOutlets {
/** All neighbouring FEATURES have finished before a chunk reaches this stage. */
public static void finish(ServerLevel level, LevelChunk chunk, Pending pending) {
if (pending.isEmpty() || !(level.getChunkSource().getGenerator() instanceof NoiseBasedChunkGenerator noise)
|| !PopulationHydrologyRuntime.enabled(noise)) return;
var random = level.getChunkSource().randomState();
if (pending.isEmpty()) return;
var binding = PopulationGenerationContext.of(level);
if (binding == null) return;
var noise = binding.generator();
var random = binding.random();
var density = noise.generatorSettings().value().noiseRouter().finalDensity();
for (var outlet : pending.outlets()) {
if (!owns(chunk, outlet)) throw new IllegalArgumentException("An outlet belongs to another chunk: " + outlet);
@@ -0,0 +1,130 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka.sanctuary.expansion.ExpansionBiomeSource;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.core.Holder;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.biome.Climate;
/** Alpha.13 keeps Population's origin woodlands and the laboratory's native cave pockets.
* Child islands retain the laboratory's complete climate palette. */
public final class SanctuaryBiomeSource extends ExpansionBiomeSource {
public static final List<String> REQUIRED_BIOMES = Stream.concat(
ExpansionBiomeSource.REQUIRED_BIOMES.stream(), Stream.of(
PopulationIslandBiomeSource.OAK_FOREST, PopulationIslandBiomeSource.BIRCH_FOREST,
PopulationIslandBiomeSource.CLEARING, PopulationIslandBiomeSource.DRY_WOODLAND,
PopulationIslandBiomeSource.ROCKY_HEATH, PopulationIslandBiomeSource.DARK_GROVE,
PopulationIslandBiomeSource.BAMBOO_GROVE, PopulationIslandBiomeSource.SULFUR_DEPTHS,
PopulationIslandBiomeSource.LUSH_CAVES, PopulationIslandBiomeSource.DRIPSTONE_CAVES
).map(key -> key.identifier().toString())).toList();
public static final MapCodec<SanctuaryBiomeSource> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, SanctuaryBiomeSource::new));
public static final MapCodec<SanctuaryBiomeSource> CITY_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 14)));
public static final MapCodec<SanctuaryBiomeSource> CITY15_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 15)));
public static final MapCodec<SanctuaryBiomeSource> CITY16_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 16)));
public static final MapCodec<SanctuaryBiomeSource> RUINS17_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 17)));
public static final MapCodec<SanctuaryBiomeSource> HERITAGE19_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 19)));
public static final MapCodec<SanctuaryBiomeSource> ARCHITECTURE20_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 20)));
public static final MapCodec<SanctuaryBiomeSource> NETWORK21_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 21)));
public static final MapCodec<SanctuaryBiomeSource> SELECTIVE22_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
Biome.CODEC.listOf().fieldOf("biomes").forGetter(source -> source.allBiomes)
).apply(i, biomes -> new SanctuaryBiomeSource(biomes, 22)));
private final List<Holder<Biome>> allBiomes;
private final PopulationIslandBiomeSource population;
private final boolean cities;
private final int generation;
private final Holder<Biome> cityBiome;
private volatile java.util.Map<String, fr.koka.sanctuary.worldgen.city.LostCityPlan> cityPlans = java.util.Map.of();
public SanctuaryBiomeSource(List<Holder<Biome>> biomes) {
this(biomes, 13);
}
private SanctuaryBiomeSource(List<Holder<Biome>> biomes, int generation) {
super(biomes);
this.generation = generation;
this.cities = ExpansionIsland.spatialGeneration(generation);
allBiomes = List.copyOf(biomes);
cityBiome = cities ? required(ResourceKey.create(net.minecraft.core.registries.Registries.BIOME,
fr.koka.sanctuary.SanctuaryMod.id("lost_city"))) : null;
population = new PopulationIslandBiomeSource(
required(PopulationIslandBiomeSource.OAK_FOREST), required(PopulationIslandBiomeSource.BIRCH_FOREST),
required(PopulationIslandBiomeSource.CLEARING), required(PopulationIslandBiomeSource.DRY_WOODLAND),
required(PopulationIslandBiomeSource.ROCKY_HEATH), required(PopulationIslandBiomeSource.DARK_GROVE),
required(PopulationIslandBiomeSource.BAMBOO_GROVE), required(PopulationIslandBiomeSource.SULFUR_DEPTHS),
required(PopulationIslandBiomeSource.LUSH_CAVES), required(PopulationIslandBiomeSource.DRIPSTONE_CAVES));
}
/** A world or delegate gets its own activation snapshot; registered holders remain immutable. */
public SanctuaryBiomeSource copy() { return new SanctuaryBiomeSource(allBiomes, generation); }
public boolean citiesEnabled() { return cities; }
public int generation() { return generation; }
public void setCityPlans(java.util.Map<String, fr.koka.sanctuary.worldgen.city.LostCityPlan> plans) {
cityPlans = java.util.Map.copyOf(plans);
}
@Override protected MapCodec<SanctuaryBiomeSource> codec() { return generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : cities ? CITY_CODEC : CODEC; }
@Override public BiomeResolver createResolver(Climate.Sampler sampler) {
BiomeResolver woodlands = population.createResolver(sampler);
return (quartX, quartY, quartZ) -> resolveCombined(islands(), woodlands, quartX, quartY, quartZ);
}
@Override public BiomeResolver createResolverForChunk(Climate.Sampler sampler, int minQuartX, int minQuartY,
int minQuartZ, int quartSizeX, int quartSizeY, int quartSizeZ) {
List<ExpansionIsland> snapshot = islands();
BiomeResolver woodlands = population.createResolver(sampler);
return (quartX, quartY, quartZ) -> resolveCombined(snapshot, woodlands, quartX, quartY, quartZ);
}
private Holder<Biome> resolveCombined(List<ExpansionIsland> snapshot, BiomeResolver woodlands,
int quartX, int quartY, int quartZ) {
int x = quartX * 4, y = quartY * 4, z = quartZ * 4;
// Reuse the historical selector itself: its noise, thresholds, order and child
// climate choices stay exact rather than being approximated by another climate field.
Holder<Biome> laboratory = super.resolve(snapshot, x, y, z);
if (y < 0 || y >= 384 || laboratory.is(Biomes.LUSH_CAVES)
|| laboratory.is(Biomes.DRIPSTONE_CAVES) || laboratory.is(Biomes.SULFUR_CAVES)) return laboratory;
for (ExpansionIsland island : snapshot) {
if (cities && island.generation() == generation && island.contains(x, z)) {
var plan = cityPlans.get(island.id());
if (plan != null && plan.contains(x, y, z)) return cityBiome;
}
if (island.origin() && island.contains(x, z)) return woodlands.getNoiseBiome(quartX, quartY, quartZ);
}
return laboratory;
}
private Holder<Biome> required(ResourceKey<Biome> key) {
return allBiomes.stream().filter(holder -> holder.is(key)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Missing Sanctuary biome: " + key.identifier()));
}
}
@@ -0,0 +1,555 @@
package fr.koka.sanctuary.worldgen;
import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.expansion.ExpansionChunkGenerator;
import fr.koka.sanctuary.expansion.ExpansionIsland;
import fr.koka.sanctuary.worldgen.city.LostCityPlanner15;
import fr.koka.sanctuary.worldgen.city.LostCityPlanner16;
import java.util.List;
import net.minecraft.core.registries.Registries;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
/** Alpha.13 combines the complete starter island with the versioned expansion runtime.
* The historical laboratory codec keeps its original algorithm and journal. */
public final class SanctuaryChunkGenerator extends ExpansionChunkGenerator {
public static final Codec<Integer> DIAMETER_CODEC = Codec.INT.validate(value ->
List.of(512, 724, 1024).contains(value) ? DataResult.success(value)
: DataResult.error(() -> "Sanctuary diameter must be 512, 724 or 1024"));
public static final MapCodec<SanctuaryChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
).apply(i, SanctuaryChunkGenerator::new));
public static final MapCodec<SanctuaryChunkGenerator> CITY_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.CITY_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
).apply(i, (source, diameter) -> new SanctuaryChunkGenerator(source, diameter, 14)));
public static final MapCodec<SanctuaryChunkGenerator> CITY15_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.CITY15_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
).apply(i, (source, diameter) -> new SanctuaryChunkGenerator(source, diameter, 15)));
public static final MapCodec<SanctuaryChunkGenerator> CITY16_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.CITY16_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter)
).apply(i, (source, diameter) -> new SanctuaryChunkGenerator(source, diameter, 16)));
public static final MapCodec<SanctuaryChunkGenerator> RUINS17_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.RUINS17_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 17, experimental)));
public static final MapCodec<SanctuaryChunkGenerator> HERITAGE19_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.HERITAGE19_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 19, experimental)));
public static final MapCodec<SanctuaryChunkGenerator> ARCHITECTURE20_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.ARCHITECTURE20_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 20, experimental)));
public static final MapCodec<SanctuaryChunkGenerator> NETWORK21_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.NETWORK21_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 21, experimental)));
public static final MapCodec<SanctuaryChunkGenerator> SELECTIVE22_CODEC = RecordCodecBuilder.mapCodec(i -> i.group(
SanctuaryBiomeSource.SELECTIVE22_CODEC.fieldOf("biome_source").forGetter(SanctuaryChunkGenerator::source),
DIAMETER_CODEC.optionalFieldOf("initial_diameter", 724).forGetter(SanctuaryChunkGenerator::initialDiameter),
Codec.BOOL.optionalFieldOf("experimental_structures", true).forGetter(SanctuaryChunkGenerator::experimentalStructures)
).apply(i, (source, diameter, experimental) -> new SanctuaryChunkGenerator(source, diameter, 22, experimental)));
private final boolean experimentalStructures;
private volatile fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17 = fr.koka.sanctuary.worldgen.ruins.RuinsPlan17.EMPTY;
private boolean ruinsPrepared;
private volatile fr.koka.sanctuary.worldgen.heritage.HeritagePlan19 heritagePlan19 = fr.koka.sanctuary.worldgen.heritage.HeritagePlan19.EMPTY;
private boolean heritagePrepared;
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityPlanner21.Result> cityPlacements21 = new java.util.concurrent.ConcurrentHashMap<>();
private volatile fr.koka.sanctuary.worldgen.transit.TransitPlan21 transitPlan21 = fr.koka.sanctuary.worldgen.transit.TransitPlan21.EMPTY;
private boolean transitPrepared;
private volatile fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundHall22 = new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0);
private volatile fr.koka.sanctuary.worldgen.city.LostCityProtection16 undergroundHallProtection22 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY;
private boolean undergroundHallPrepared22;
public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundHall22() { return undergroundHall22; }
public fr.koka.sanctuary.worldgen.transit.TransitPlan21 transitPlan21() { return transitPlan21; }
public fr.koka.sanctuary.worldgen.city.LostCityPlanner21.Result cityPlacement21(ExpansionIsland island) {
lostCityPlan(island);
return cityPlacements21.get(cityIdentity(island));
}
private volatile Context originContext;
private final int generation;
private net.minecraft.world.level.LevelHeightAccessor heightAccessor;
// A prospect may reuse an ID at a different position after admission rejects a candidate.
// Terrain identity includes all descriptor fields except preparation state and reuse receipt.
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityPlan> cityPlans = new java.util.concurrent.ConcurrentHashMap<>();
private volatile java.util.Map<String, fr.koka.sanctuary.worldgen.city.LostCityPlan> activeCityPlans = java.util.Map.of();
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan> undergroundPlans = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.Map<ExpansionIsland, List<LostCityPlanner15.Support>> citySupports = new java.util.concurrent.ConcurrentHashMap<>();
private volatile fr.koka.sanctuary.worldgen.city.LostCityProtection15 cityProtection15 = fr.koka.sanctuary.worldgen.city.LostCityProtection15.EMPTY;
private final java.util.Map<ExpansionIsland, fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan> undergroundPlans16 = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.Map<ExpansionIsland, List<LostCityPlanner16.Support>> citySupports16 = new java.util.concurrent.ConcurrentHashMap<>();
private volatile fr.koka.sanctuary.worldgen.city.LostCityProtection16 cityProtection16 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY;
private final java.util.Map<ExpansionIsland, LostCityPlanner16.Fallback> cityReservations16 = new java.util.concurrent.ConcurrentHashMap<>();
private boolean cityStructuresEnabled;
// Root extent is finite (at most 68x68 chunks); each immutable mask is at most 12 KiB.
private final java.util.concurrent.ConcurrentHashMap<Long, SanctuaryDecorationProtection> protections = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.atomic.LongAdder rejectedWrites = new java.util.concurrent.atomic.LongAdder();
public boolean protectsDecoration(net.minecraft.core.BlockPos pos) {
if (pos.getY() < 0 || pos.getY() >= 384 || !capacity().areaExists(pos.getX() >> 4, pos.getZ() >> 4)) return false;
int cx = pos.getX() >> 4, cz = pos.getZ() >> 4;
long key = ((long) cx << 32) ^ (cz & 0xffffffffL);
return protections.computeIfAbsent(key, ignored -> SanctuaryDecorationProtection.create(starterContext(), cx, cz)).protects(pos);
}
public void rejectedDecorationWrite() { rejectedWrites.increment(); }
public long rejectedDecorationWrites() { return rejectedWrites.sum(); }
public SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter) {
this(source, initialDiameter, 13);
}
private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation) {
this(source, initialDiameter, generation, false);
}
private SanctuaryChunkGenerator(SanctuaryBiomeSource source, int initialDiameter, int generation, boolean experimental) {
super(source, initialDiameter, true);
this.experimentalStructures = (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && experimental;
this.generation = generation;
if (generation != source.generation()) throw new IllegalArgumentException("Sanctuary generator and biome source versions must agree");
}
@Override public SanctuaryBiomeSource source() { return (SanctuaryBiomeSource) super.source(); }
@Override protected MapCodec<SanctuaryChunkGenerator> codec() { return generation == 22 ? SELECTIVE22_CODEC : generation == 21 ? NETWORK21_CODEC : generation == 20 ? ARCHITECTURE20_CODEC : generation == 19 ? HERITAGE19_CODEC : generation == 17 ? RUINS17_CODEC : generation == 16 ? CITY16_CODEC : generation == 15 ? CITY15_CODEC : generation == 14 ? CITY_CODEC : CODEC; }
public SanctuaryChunkGenerator withInitialDiameter(int diameter) {
return new SanctuaryChunkGenerator(source().copy(), diameter, generation, experimentalStructures);
}
public boolean experimentalStructures() { return experimentalStructures; }
public SanctuaryChunkGenerator withExperimentalStructures(boolean enabled) {
return new SanctuaryChunkGenerator(source().copy(), initialDiameter(), generation, enabled);
}
public fr.koka.sanctuary.worldgen.ruins.RuinsPlan17 ruinsPlan17() { return ruinsPlan17; }
public fr.koka.sanctuary.worldgen.heritage.HeritagePlan19 heritagePlan19() { return heritagePlan19; }
public boolean intersectsExperimentalStructures(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
return (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && ruinsPlan17.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ)
|| (generation == 19 || generation == 20 || generation == 21 || generation == 22) && heritagePlan19.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ)
|| (generation == 21 || generation == 22) && transitPlan21.intersectsProtection(minX, minY, minZ, maxX, maxY, maxZ)
|| generation == 22 && undergroundHallProtection22.intersects(minX, minY, minZ, maxX, maxY, maxZ);
}
public IslandCapacity capacity() {
return switch (initialDiameter()) {
case 512 -> IslandCapacity.FIVE;
case 724 -> IslandCapacity.TEN;
case 1024 -> IslandCapacity.TWENTY;
default -> throw new IllegalStateException("Unvalidated Sanctuary size");
};
}
@Override public net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> structureBiome(
net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> biome) {
String name = biome.unwrapKey().orElseThrow().identifier().toString();
String equivalent = switch (name) {
case "sanctuary:population_oak_forest" -> "forest";
case "sanctuary:population_birch_forest" -> "birch_forest";
case "sanctuary:population_clearing" -> "plains";
case "sanctuary:population_dry_woodland" -> "forest";
case "sanctuary:population_rocky_heath" -> "meadow";
case "sanctuary:population_dark_grove" -> "dark_forest";
case "sanctuary:population_bamboo_grove" -> "bamboo_jungle";
default -> null;
};
return equivalent == null ? biome : registries.lookupOrThrow(Registries.BIOME).getOrThrow(
net.minecraft.resources.ResourceKey.create(Registries.BIOME, net.minecraft.resources.Identifier.withDefaultNamespace(equivalent)));
}
@Override public String journalDirectory() { return "sanctuary-world-v" + generation; }
@Override public int rootGeneration() { return generation; }
@Override public void initialize(ServerLevel level, List<ExpansionIsland> islands) {
heightAccessor = level;
cityStructuresEnabled = level.structureManager().shouldGenerateStructures();
super.initialize(level, islands);
// Publish one root context before any generation workers use it.
context(islands.getFirst());
prepareCities(islands);
if (generation == 22) { prepareTransit(islands); prepareUndergroundHall22(islands); }
prepareRuins(islands);
prepareHeritage(islands);
prepareTransit(islands);
}
@Override public void setIslands(List<ExpansionIsland> islands) {
// Complete and publish city geometry before workers can observe the new owner list.
prepareCities(islands);
if (generation == 22) { prepareTransit(islands); prepareUndergroundHall22(islands); }
prepareRuins(islands);
prepareHeritage(islands);
prepareTransit(islands);
super.setIslands(islands);
}
private void prepareCities(List<ExpansionIsland> islands) {
if (!ExpansionIsland.spatialGeneration(generation)) return;
if (generation == 15 && !cityStructuresEnabled) {
cityProtection15 = fr.koka.sanctuary.worldgen.city.LostCityProtection15.EMPTY;
activeCityPlans = java.util.Map.of();
source().setCityPlans(java.util.Map.of());
return;
}
if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && !cityStructuresEnabled) {
cityProtection16 = fr.koka.sanctuary.worldgen.city.LostCityProtection16.EMPTY;
activeCityPlans = java.util.Map.of();
source().setCityPlans(java.util.Map.of());
return;
}
var published = new java.util.LinkedHashMap<String, fr.koka.sanctuary.worldgen.city.LostCityPlan>();
for (var island : islands) published.put(island.id(), lostCityPlan(island));
if (generation == 15) {
var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection15.Builder();
for (var island : islands) {
protection.add(published.get(island.id()), undergroundPlan(island));
for (var support : citySupports(island)) protection.addViaductSupport(support);
}
cityProtection15 = protection.build();
}
if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) {
var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
for (var island : islands) {
protection.add(published.get(island.id()), undergroundPlan16(island));
for (var support : citySupports16(island)) protection.addViaductSupport(support);
var reservation = cityReservation16(island);
if (reservation != null) protection.addReservation(reservation);
}
cityProtection16 = protection.build();
}
// Rejected read-only prospects remain private and never enter the biome source.
activeCityPlans = java.util.Map.copyOf(published);
source().setCityPlans(published);
}
private void prepareRuins(List<ExpansionIsland> islands) {
if ((generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) || !experimentalStructures || !cityStructuresEnabled || ruinsPrepared) return;
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
var terrain = context(root);
var sampled = new fr.koka.sanctuary.worldgen.ruins.RuinsTerrain17(root, terrain.generator(), terrain.random(),
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()));
long start = System.nanoTime();
var plan = fr.koka.sanctuary.worldgen.ruins.RuinsPlanner17.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
ruinsPlan17 = plan;
ruinsPrepared = true;
SanctuaryMod.LOGGER.info("Experimental ruins17 seed {}: {} networks, {} probes, {} noise columns in {} ms",
root.seed(), plan.networks().size(), plan.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000);
}
private void prepareHeritage(List<ExpansionIsland> islands) {
if ((generation != 19 && generation != 20 && generation != 21 && generation != 22) || !experimentalStructures || !cityStructuresEnabled || heritagePrepared) return;
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
var terrain = context(root);
if (generation == 21 || generation == 22) {
var sampled = new fr.koka.sanctuary.worldgen.heritage.HeritageTerrain21(root, terrain.generator(), terrain.random(),
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|| ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()));
long start = System.nanoTime();
sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17);
var plan = generation == 22
? fr.koka.sanctuary.worldgen.heritage.HeritagePlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled)
: fr.koka.sanctuary.worldgen.heritage.HeritagePlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
heritagePlan19 = plan;
heritagePrepared = true;
SanctuaryMod.LOGGER.info("Heritage" + generation + " seed {}: {} sites, {} routes, {} segments, {} probes, {} native columns in {} ms; {}",
root.seed(), plan.sites().size(), plan.links().size(), plan.segments().size(), plan.samples(), sampled.columnsUsed(),
(System.nanoTime() - start) / 1_000_000, plan.diagnostics());
return;
}
var sampled = new fr.koka.sanctuary.worldgen.heritage.HeritageTerrain19(root, terrain.generator(), terrain.random(),
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|| ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()));
long start = System.nanoTime();
sampled.discoverLandmarks(lostCityPlan(root), ruinsPlan17);
var plan = fr.koka.sanctuary.worldgen.heritage.HeritagePlanner19.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
heritagePlan19 = plan;
heritagePrepared = true;
SanctuaryMod.LOGGER.info("Heritage19 seed {}: {} sites, {} routes, {} segments, {} probes, {} native columns in {} ms; {}",
root.seed(), plan.sites().size(), plan.links().size(), plan.segments().size(), plan.samples(), sampled.columnsUsed(),
(System.nanoTime() - start) / 1_000_000, plan.diagnostics());
}
private void prepareTransit(List<ExpansionIsland> islands) {
if ((generation != 21 && generation != 22) || generation == 21 && !experimentalStructures || !cityStructuresEnabled || transitPrepared) return;
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
var terrain = context(root);
var sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(),
heightAccessor, pos -> protectsCore22(pos) || protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|| ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ()) || heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ()));
long start = System.nanoTime();
transitPlan21 = generation == 22
? fr.koka.sanctuary.worldgen.transit.TransitPlanner22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled)
: fr.koka.sanctuary.worldgen.transit.TransitPlanner21.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
int directColumns = 0;
if (generation == 22 && transitPlan21.isEmpty()) {
directColumns = sampled.columnsUsed();
sampled = new fr.koka.sanctuary.worldgen.transit.TransitTerrain21(root, terrain.generator(), terrain.random(),
heightAccessor, pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()));
transitPlan21 = fr.koka.sanctuary.worldgen.transit.TransitRoute22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(), sampled);
}
transitPrepared = true;
SanctuaryMod.LOGGER.info("Transit" + generation + " seed {}: {} modules, {} probes, {} columns in {} ms; {}", root.seed(),
transitPlan21.modules().size(), transitPlan21.samples(), directColumns + sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000, transitPlan21.diagnostic());
}
private boolean protectsCore22(net.minecraft.core.BlockPos pos) {
return generation == 22 && (transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ())
|| undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ()));
}
private void prepareUndergroundHall22(List<ExpansionIsland> islands) {
if (generation != 22 || !cityStructuresEnabled || undergroundHallPrepared22) return;
var root = islands.stream().filter(ExpansionIsland::origin).findFirst().orElseThrow();
if (undergroundPlan16(root).networks().stream().flatMap(n -> n.modules().stream())
.anyMatch(m -> m.kind() == fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Kind.EXPANSION_HALL)) {
undergroundHallPrepared22 = true; return;
}
var terrain = context(root);
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(root, terrain.generator(), terrain.random(), heightAccessor,
pos -> protectsDecoration(pos) || cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ()) || protectsCore22(pos));
long start = System.nanoTime();
undergroundHall22 = fr.koka.sanctuary.worldgen.city.LostCityUnderground22.plan(root.seed(), root.centerX(), root.centerZ(), root.diameter(),
new fr.koka.sanctuary.worldgen.city.LostCityUnderground22.Terrain() {
@Override public int surfaceY(int x, int z) { var ground = sampled.ground(x, z); return ground.dry() ? ground.top() : -1; }
@Override public boolean owns(int x, int z) { return sampled.owns(x, z); }
@Override public boolean solid(int x, int y, int z) { return sampled.solid(x, y, z); }
@Override public boolean protectedAt(int x, int y, int z) { return sampled.protectedAt(x, y, z); }
@Override public boolean exteriorAir(int x, int y, int z, int dx, int dz) { return sampled.exteriorAir(x, y, z, dx, dz); }
});
var protection = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
protection.add(fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY, undergroundHall22);
undergroundHallProtection22 = protection.build(); undergroundHallPrepared22 = true;
SanctuaryMod.LOGGER.info("Independent hall22 seed {}: {} networks, {} probes, {} columns in {} ms", root.seed(),
undergroundHall22.networks().size(), undergroundHall22.samples(), sampled.columnsUsed(), (System.nanoTime() - start) / 1_000_000);
}
private static ExpansionIsland cityIdentity(ExpansionIsland island) {
// State and receipt do not change the terrain geometry used by a cached plan.
var ready = island.status().equals("ready") ? island : island.ready();
return ready.reuseToken().isEmpty() ? ready : ready.withReuseToken("");
}
@Override public void discardUnpublishedContext(ExpansionIsland island) {
super.discardUnpublishedContext(island);
var key = cityIdentity(island);
if (source().islands().stream().noneMatch(saved -> cityIdentity(saved).equals(key))) {
cityPlans.remove(key);
cityPlacements21.remove(key);
undergroundPlans.remove(key);
undergroundPlans16.remove(key);
citySupports.remove(key);
citySupports16.remove(key);
cityReservations16.remove(key);
// City prospecting may have prepared the receipt-free terrain context too.
super.discardUnpublishedContext(key);
}
}
public fr.koka.sanctuary.worldgen.city.LostCityPlan lostCityPlan(ExpansionIsland island) {
if (!ExpansionIsland.spatialGeneration(generation) || island.generation() != generation || (!island.origin() && !island.climate().equals("lost_city")))
return fr.koka.sanctuary.worldgen.city.LostCityPlan.EMPTY;
return cityPlans.computeIfAbsent(cityIdentity(island), ignored -> {
long start = System.nanoTime();
var terrain = context(island);
if (generation == 21 || generation == 22) {
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain21(island, terrain.generator(), terrain.random(),
heightAccessor, pos -> island.origin() && protectsDecoration(pos));
var placement = generation == 22
? fr.koka.sanctuary.worldgen.city.LostCityPlanner22.plan(island.seed(), island.centerX(), island.centerZ(), island.diameter(), island.origin(), sampled)
: fr.koka.sanctuary.worldgen.city.LostCityPlanner21.plan(island.seed(), island.centerX(), island.centerZ(), island.diameter(), island.origin(), sampled);
var surface = placement.surface();
cityPlacements21.put(cityIdentity(island), placement);
citySupports16.put(cityIdentity(island), placement.supports());
if (placement.reservation() != null) cityReservations16.put(cityIdentity(island), placement.reservation());
var supports = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
for (var support : placement.supports()) supports.addViaductSupport(support);
if (placement.reservation() != null) supports.addReservation(placement.reservation());
var extraDistricts = surface.districts().stream()
.filter(d -> !placement.core().surface().districts().contains(d)).toList();
supports.add(new fr.koka.sanctuary.worldgen.city.LostCityPlan(extraDistricts),
new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0));
var supportMask = supports.build();
var underground = fr.koka.sanctuary.worldgen.city.LostCityUnderground16.plan(island.seed(), placement.core().surface(),
new fr.koka.sanctuary.worldgen.city.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);
}
});
undergroundPlans16.put(cityIdentity(island), underground);
SanctuaryMod.LOGGER.info("Lost City" + generation + " {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms",
island.id(), island.seed(), surface.districts().size(), underground.networks().size(), sampled.columnsUsed(),
(System.nanoTime() - start) / 1_000_000);
SanctuaryMod.LOGGER.info("City" + generation + " extensions: {}", placement.diagnostics());
return surface;
}
if (generation == 16 || generation == 17 || generation == 19 || generation == 20) {
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain16(island, terrain.generator(), terrain.random(),
heightAccessor, pos -> island.origin() && protectsDecoration(pos));
var placement = LostCityPlanner16.plan(island.seed(), island.centerX(), island.centerZ(),
island.diameter(), island.origin(), sampled);
var surface = placement.surface();
citySupports16.put(cityIdentity(island), placement.supports());
if (placement.reservation() != null) cityReservations16.put(cityIdentity(island), placement.reservation());
var supports = new fr.koka.sanctuary.worldgen.city.LostCityProtection16.Builder();
for (var support : placement.supports()) supports.addViaductSupport(support);
if (placement.reservation() != null) supports.addReservation(placement.reservation());
var supportMask = supports.build();
var underground = fr.koka.sanctuary.worldgen.city.LostCityUnderground16.plan(island.seed(), surface,
new fr.koka.sanctuary.worldgen.city.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);
}
});
undergroundPlans16.put(cityIdentity(island), underground);
SanctuaryMod.LOGGER.info("Lost City16 {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms",
island.id(), island.seed(), surface.districts().size(), underground.networks().size(), sampled.columnsUsed(),
(System.nanoTime() - start) / 1_000_000);
return surface;
}
if (generation == 15) {
var sampled = new fr.koka.sanctuary.worldgen.city.LostCityTerrain15(island, terrain.generator(), terrain.random(),
heightAccessor, pos -> island.origin() && protectsDecoration(pos));
var placement = LostCityPlanner15.plan(island.seed(), island.centerX(), island.centerZ(),
island.diameter(), island.origin(), sampled);
var surface = placement.surface();
citySupports.put(cityIdentity(island), placement.supports());
var supports = new fr.koka.sanctuary.worldgen.city.LostCityProtection15.Builder();
for (var support : placement.supports()) supports.addViaductSupport(support);
var supportMask = supports.build();
var underground = fr.koka.sanctuary.worldgen.city.LostCityUnderground15.plan(island.seed(), surface,
new fr.koka.sanctuary.worldgen.city.LostCityUnderground15.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 protectedAt(int x, int y, int z) {
return supportMask.contains(x, y, z) || sampled.protectedAt(x, y, z);
}
});
undergroundPlans.put(cityIdentity(island), underground);
SanctuaryMod.LOGGER.info("Lost City15 {} seed {}: {} city districts, {} underground networks, {} noise columns in {} ms",
island.id(), island.seed(), surface.districts().size(), underground.networks().size(), sampled.columnsUsed(),
(System.nanoTime() - start) / 1_000_000);
return surface;
}
var plan = fr.koka.sanctuary.worldgen.city.LostCityPlanner.plan(island.seed(), island.centerX(), island.centerZ(),
island.diameter(), island.origin(), (x, z) -> {
if (!island.contains(x, z)) return new fr.koka.sanctuary.worldgen.city.LostCityPlanner.Ground(0, 0, false, false);
var column = terrain.generator().getBaseColumn(x, z, heightAccessor, terrain.random());
int top = 383;
while (top > 0 && column.getBlock(top).isAir()) top--;
int bottom = top;
while (bottom > 0 && top - bottom < 24 && column.getBlock(bottom).isSolid()
&& column.getBlock(bottom).getFluidState().isEmpty()) bottom--;
boolean dry = column.getBlock(top).isSolid() && column.getBlock(top).getFluidState().isEmpty();
boolean protectedWater = island.origin() && top >= 168 && dry && top - bottom >= 6
&& PopulationHydrologyRuntime.protects(terrain.generator(), terrain.random(), new net.minecraft.core.BlockPos(x, top, z), 1, 30, 12);
return new fr.koka.sanctuary.worldgen.city.LostCityPlanner.Ground(top, top - bottom, dry, protectedWater);
});
SanctuaryMod.LOGGER.info("Lost City plan {} seed {}: {} districts in {} ms", island.id(), island.seed(),
plan.districts().size(), (System.nanoTime() - start) / 1_000_000);
return plan;
});
}
public boolean protectsCityDecoration(net.minecraft.core.BlockPos pos) {
if (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) return cityProtection16.contains(pos.getX(), pos.getY(), pos.getZ())
|| (generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && ruinsPlan17.protects(pos.getX(), pos.getY(), pos.getZ())
|| (generation == 19 || generation == 20 || generation == 21 || generation == 22) && heritagePlan19.protects(pos.getX(), pos.getY(), pos.getZ())
|| (generation == 21 || generation == 22) && transitPlan21.protects(pos.getX(), pos.getY(), pos.getZ())
|| generation == 22 && undergroundHallProtection22.contains(pos.getX(), pos.getY(), pos.getZ());
if (generation == 15) return cityProtection15.contains(pos.getX(), pos.getY(), pos.getZ());
if (generation != 14) return false;
var island = islandAt(pos.getX(), pos.getZ());
// Decoration is a per-block hot path, including while child islands are reserved.
var plan = island == null ? null : activeCityPlans.get(island.id());
return plan != null && plan.contains(pos.getX(), pos.getY(), pos.getZ());
}
public boolean intersectsCityConstruction15(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
return generation == 15 && cityProtection15.intersects(minX, minY, minZ, maxX, maxY, maxZ);
}
public fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan undergroundPlan(ExpansionIsland island) {
if (generation != 15) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan(List.of(), 0);
lostCityPlan(island);
return undergroundPlans.getOrDefault(cityIdentity(island), new fr.koka.sanctuary.worldgen.city.LostCityUnderground15.Plan(List.of(), 0));
}
public List<LostCityPlanner15.Support> citySupports(ExpansionIsland island) {
if (generation != 15) return List.of();
lostCityPlan(island);
return citySupports.getOrDefault(cityIdentity(island), List.of());
}
public boolean intersectsCityConstruction16(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
return (generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && cityProtection16.intersects(minX, minY, minZ, maxX, maxY, maxZ);
}
public fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan undergroundPlan16(ExpansionIsland island) {
if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0);
lostCityPlan(island);
return undergroundPlans16.getOrDefault(cityIdentity(island), new fr.koka.sanctuary.worldgen.city.LostCityUnderground16.Plan(List.of(), 0));
}
public List<LostCityPlanner16.Support> citySupports16(ExpansionIsland island) {
if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return List.of();
lostCityPlan(island);
return citySupports16.getOrDefault(cityIdentity(island), List.of());
}
public LostCityPlanner16.Fallback cityReservation16(ExpansionIsland island) {
if (generation != 16 && generation != 17 && generation != 19 && generation != 20 && generation != 21 && generation != 22) return null;
lostCityPlan(island);
return cityReservations16.get(cityIdentity(island));
}
/** Lost City handles exact, single-structure queries. Mixed sets retain the vanilla
* search unchanged: searching both engines with skipReferenced=true would consume
* two starts before choosing one result. Mixed-set city discovery is not added here. */
@Override public com.mojang.datafixers.util.Pair<net.minecraft.core.BlockPos, net.minecraft.core.Holder<net.minecraft.world.level.levelgen.structure.Structure>> findNearestMapStructure(
ServerLevel level, net.minecraft.core.HolderSet<net.minecraft.world.level.levelgen.structure.Structure> structures,
net.minecraft.core.BlockPos origin, int radius, boolean skipReferenced) {
if (generation == 22 && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.KEY))
return fr.koka.sanctuary.worldgen.city.LostCityUndergroundStructures22.findNearest(level, structures, origin, radius, skipReferenced);
if (structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.city.LostCityStructures.KEY))
return fr.koka.sanctuary.worldgen.city.LostCityStructures.findNearest(level, structures, origin, radius, skipReferenced);
if ((generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.KEY))
return fr.koka.sanctuary.worldgen.ruins.RuinsStructures17.findNearest(level, structures, origin, radius, skipReferenced);
if ((generation == 19 || generation == 20 || generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.KEY))
return fr.koka.sanctuary.worldgen.heritage.HeritageStructures19.findNearest(level, structures, origin, radius, skipReferenced);
if ((generation == 21 || generation == 22) && structures.size() == 1 && structures.get(0).is(fr.koka.sanctuary.worldgen.transit.TransitStructures21.KEY))
return fr.koka.sanctuary.worldgen.transit.TransitStructures21.findNearest(level, structures, origin, radius, skipReferenced);
return super.findNearestMapStructure(level, structures, origin, radius, skipReferenced);
}
@Override protected Context context(ExpansionIsland island) {
if (!island.origin()) return super.context(island);
Context current = originContext;
if (current != null) return current;
synchronized (this) {
if (originContext == null) {
if (registries == null) throw new IllegalStateException("Sanctuary used before world attachment");
var settings = registries.lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(net.minecraft.resources.ResourceKey.create(Registries.NOISE_SETTINGS,
SanctuaryMod.id("unified_" + capacity().players())));
var delegate = new NoiseBasedChunkGenerator(source(), settings);
var random = RandomState.create(registries.lookupOrThrow(Registries.NOISE), island.seed(), settings.value());
originContext = new Context(delegate, random);
}
return originContext;
}
}
public PopulationGenerationContext.Binding starterContext() {
var current = originContext;
if (current == null) throw new IllegalStateException("Sanctuary used before world attachment");
return new PopulationGenerationContext.Binding(current.generator(), current.random());
}
@Override protected void afterTerrain(ExpansionIsland island, ChunkAccess chunk) {
// Root water/lava are already applied by the Population hook on the named delegate.
if (!island.origin()) super.afterTerrain(island, chunk);
if ((generation == 16 || generation == 17 || generation == 19 || generation == 20 || generation == 21 || generation == 22) && cityStructuresEnabled) {
var reserved = cityReservations16.get(cityIdentity(island));
if (reserved != null) {
var mutable = new net.minecraft.core.BlockPos.MutableBlockPos();
var at = chunk.getPos();
for (int x = Math.max(reserved.minX(), at.getMinBlockX()); x <= Math.min(reserved.maxX(), at.getMaxBlockX()); x++)
for (int z = Math.max(reserved.minZ(), at.getMinBlockZ()); z <= Math.min(reserved.maxZ(), at.getMaxBlockZ()); z++)
for (int y = reserved.minY(); y <= reserved.maxY(); y++)
chunk.setBlockState(mutable.set(x, y, z), net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 0);
}
}
}
}
@@ -0,0 +1,60 @@
package fr.koka.sanctuary.worldgen;
import java.util.BitSet;
import net.minecraft.core.BlockPos;
/** Immutable per-chunk mask of the starter's water, beds, supports and flow passages.
* Native cave features may decorate freely outside these previously planned volumes. */
public final class SanctuaryDecorationProtection {
private final int minX, minZ;
private final BitSet mask = new BitSet(16 * 16 * 384);
private SanctuaryDecorationProtection(int chunkX, int chunkZ) { minX = chunkX * 16; minZ = chunkZ * 16; }
public static SanctuaryDecorationProtection create(PopulationGenerationContext.Binding binding, int chunkX, int chunkZ) {
var result = new SanctuaryDecorationProtection(chunkX, chunkZ);
for (var region : PopulationHydrologyRuntime.plansIntersecting(binding.generator(), binding.random(),
result.minX - 1, result.minZ - 1, result.minX + 16, result.minZ + 16)) {
// A retaining wall can belong to the neighbouring chunk of its water cell.
for (int cx = chunkX - 1; cx <= chunkX + 1; cx++)
for (int cz = chunkZ - 1; cz <= chunkZ + 1; cz++)
for (var cell : region.water().cellsInChunk(cx, cz)) {
if (cell.x() < result.minX - 1 || cell.x() > result.minX + 16
|| cell.z() < result.minZ - 1 || cell.z() > result.minZ + 16) continue;
result.box(cell.x() - 1, cell.bedY() - cell.sedimentDepth() - 2, cell.z() - 1,
cell.x() + 1, cell.carveTop() + 1, cell.z() + 1);
}
for (var spring : region.water().springs()) {
result.flow(spring.flowBounds(), 2);
var source = spring.source();
result.cube(source.x(), source.y(), source.z(), 3);
}
for (var spill : region.water().spills()) result.flow(spill.flowBounds(), 3);
for (var fall : region.lava().flowBounds()) result.flow(fall, 8);
for (var cell : region.lava().cells()) result.cube(cell.x(), cell.y(), cell.z(), 2);
for (var support : region.lava().supports()) result.cube(support.x(), support.y(), support.z(), 2);
}
return result;
}
public boolean protects(BlockPos pos) {
int x = pos.getX() - minX, z = pos.getZ() - minZ, y = pos.getY();
return x >= 0 && x < 16 && z >= 0 && z < 16 && y >= 0 && y < 384
&& mask.get((x * 16 + z) * 384 + y);
}
public int protectedBlocks() { return mask.cardinality(); }
private void flow(PopulationHydrology.FlowBounds b, int pad) {
box(b.minX() - pad, b.minY() - pad, b.minZ() - pad,
b.maxX() + pad, b.maxY() + pad, b.maxZ() + pad);
}
private void cube(int x, int y, int z, int radius) {
box(x - radius, y - radius, z - radius, x + radius, y + radius, z + radius);
}
private void box(int x1, int y1, int z1, int x2, int y2, int z2) {
int lo = Math.max(0, y1), hi = Math.min(383, y2);
if (lo > hi) return;
for (int x = Math.max(minX, x1); x <= Math.min(minX + 15, x2); x++)
for (int z = Math.max(minZ, z1); z <= Math.min(minZ + 15, z2); z++) {
int column = ((x - minX) * 16 + z - minZ) * 384;
mask.set(column + lo, column + hi + 1);
}
}
}
@@ -0,0 +1,45 @@
package fr.koka.sanctuary.worldgen.city;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
/** Explicit masonry for the mandatory starter's last-resort architectural foundation.
* The upper city owns every block from floorY-2 upward. This renderer writes only below that
* boundary, so loading native pieces in another order cannot erase streets or buildings. */
public final class LostCityFoundation16 {
private LostCityFoundation16() {}
@FunctionalInterface public interface Writer { void set(int x, int y, int z, BlockState state); }
/** World coordinates, with no terrain reads and no chunk requests. */
public static void render(LostCityPlanner16.Fallback reservation, long seed, Writer writer) {
int floor = reservation.floorY();
for (int z = reservation.minZ(); z <= reservation.maxZ(); z++)
for (int x = reservation.minX(); x <= reservation.maxX(); x++) {
int lx = x - reservation.minX(), lz = z - reservation.minZ();
boolean wall = lx == 0 || lx == 48 || lz == 0 || lz == 48;
boolean column = nearPier(lx) && nearPier(lz);
for (int y = reservation.minY(); y <= floor - 3; y++) {
BlockState state;
if (y >= floor - 5) state = y == floor - 3 ? Blocks.SMOOTH_STONE.defaultBlockState()
: LostCityPalette.masonry(seed, lx, y - floor, lz);
else if (y == reservation.minY()) state = Blocks.SMOOTH_STONE.defaultBlockState();
else if (wall || column || y == floor - 6 && (nearPier(lx) || nearPier(lz)))
state = LostCityPalette.masonry(seed, lx, y - floor, lz);
else state = Blocks.AIR.defaultBlockState();
writer.set(x, y, z, state);
}
}
for (var anchor : reservation.anchors())
for (int z = anchor.minZ(); z <= anchor.maxZ(); z++)
for (int x = anchor.minX(); x <= anchor.maxX(); x++)
for (int y = anchor.minY(); y <= anchor.maxY(); y++)
writer.set(x, y, z, y == anchor.minY() || y == anchor.maxY()
? Blocks.SMOOTH_STONE.defaultBlockState()
: LostCityPalette.masonry(seed, x - reservation.minX(), y - floor, z - reservation.minZ()));
}
private static boolean nearPier(int coordinate) {
for (int center = 2; center <= 46; center += 11) if (Math.abs(coordinate - center) <= 1) return true;
return false;
}
}

Some files were not shown because too many files have changed in this diff Show More